feat: switch to new dialog across all code
This commit is contained in:
parent
0f75636342
commit
741f9aa6b5
@ -21,10 +21,12 @@ import Title from '~/components/Title';
|
|||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
export interface DialogProps extends OverlayTriggerProps {
|
export interface DialogProps extends OverlayTriggerProps {
|
||||||
children: [
|
children:
|
||||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
| [
|
||||||
React.ReactElement<DialogPanelProps>,
|
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||||
];
|
React.ReactElement<DialogPanelProps>,
|
||||||
|
]
|
||||||
|
| React.ReactElement<DialogPanelProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dialog(props: DialogProps) {
|
function Dialog(props: DialogProps) {
|
||||||
@ -36,34 +38,53 @@ function Dialog(props: DialogProps) {
|
|||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [button, panel] = props.children;
|
if (Array.isArray(props.children)) {
|
||||||
|
const [button, panel] = props.children;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cloneElement(button, triggerProps)}
|
||||||
|
{state.isOpen && (
|
||||||
|
<DModal state={state}>
|
||||||
|
{cloneElement(panel, {
|
||||||
|
...overlayProps,
|
||||||
|
close: () => state.close(),
|
||||||
|
})}
|
||||||
|
</DModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DModal state={state}>
|
||||||
{cloneElement(button, triggerProps)}
|
{cloneElement(props.children, {
|
||||||
{state.isOpen && (
|
...overlayProps,
|
||||||
<DModal state={state}>
|
close: () => state.close(),
|
||||||
{cloneElement(panel, {
|
})}
|
||||||
...overlayProps,
|
</DModal>
|
||||||
close: () => state.close(),
|
|
||||||
})}
|
|
||||||
</DModal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogPanelProps extends AriaDialogProps {
|
export interface DialogPanelProps extends AriaDialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
variant?: 'normal' | 'destructive';
|
variant?: 'normal' | 'destructive' | 'unactionable';
|
||||||
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
||||||
method?: HTMLFormMethod;
|
method?: HTMLFormMethod;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
|
||||||
// Anonymous (passed by parent)
|
// Anonymous (passed by parent)
|
||||||
close?: () => void;
|
close?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel(props: DialogPanelProps) {
|
function Panel(props: DialogPanelProps) {
|
||||||
const { children, onSubmit, close, variant, method = 'POST' } = props;
|
const {
|
||||||
|
children,
|
||||||
|
onSubmit,
|
||||||
|
isDisabled,
|
||||||
|
close,
|
||||||
|
variant,
|
||||||
|
method = 'POST',
|
||||||
|
} = props;
|
||||||
const ref = useRef<HTMLFormElement | null>(null);
|
const ref = useRef<HTMLFormElement | null>(null);
|
||||||
const { dialogProps } = useDialog(
|
const { dialogProps } = useDialog(
|
||||||
{
|
{
|
||||||
@ -93,14 +114,22 @@ function Panel(props: DialogPanelProps) {
|
|||||||
<Card className="w-full max-w-lg">
|
<Card className="w-full max-w-lg">
|
||||||
{children}
|
{children}
|
||||||
<div className="mt-6 flex justify-end gap-4">
|
<div className="mt-6 flex justify-end gap-4">
|
||||||
<Button onPress={close}>Cancel</Button>
|
{variant === 'unactionable' ? (
|
||||||
<Button
|
<Button onPress={close}>Close</Button>
|
||||||
type="submit"
|
) : (
|
||||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
<>
|
||||||
isDisabled={!(ref.current?.checkVisibility() ?? false)}
|
<Button onPress={close}>Cancel</Button>
|
||||||
>
|
<Button
|
||||||
Confirm
|
type="submit"
|
||||||
</Button>
|
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||||
|
isDisabled={
|
||||||
|
isDisabled || !(ref.current?.checkValidity() ?? true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useFetcher } from 'react-router';
|
import { useFetcher } from 'react-router';
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Spinner from '~/components/Spinner';
|
import Spinner from '~/components/Spinner';
|
||||||
|
|
||||||
@ -8,6 +7,8 @@ type Properties = {
|
|||||||
readonly disabled?: 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) {
|
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
@ -17,42 +18,26 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
|||||||
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
|
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
|
||||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||||
</Dialog.Button>
|
</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel
|
||||||
{(close) => (
|
onSubmit={() => {
|
||||||
<>
|
fetcher.submit(
|
||||||
<Dialog.Title>
|
{
|
||||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
'dns.magic_dns': !isEnabled,
|
||||||
</Dialog.Title>
|
},
|
||||||
<Dialog.Text>
|
{
|
||||||
Devices will no longer be accessible via your tailnet domain. The
|
method: 'PATCH',
|
||||||
search domain will also be disabled.
|
encType: 'application/json',
|
||||||
</Dialog.Text>
|
},
|
||||||
<Dialog.Gutter>
|
);
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
}}
|
||||||
Cancel
|
>
|
||||||
</Dialog.Action>
|
<Dialog.Title>
|
||||||
<Dialog.Action
|
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||||
variant="confirm"
|
</Dialog.Title>
|
||||||
onPress={() => {
|
<Dialog.Text>
|
||||||
fetcher.submit(
|
Devices will no longer be accessible via your tailnet domain. The
|
||||||
{
|
search domain will also be disabled.
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
</Dialog.Text>
|
||||||
'dns.magic_dns': !isEnabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useFetcher } from 'react-router';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Input } from 'react-aria-components';
|
import { Input } from 'react-aria-components';
|
||||||
|
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';
|
||||||
@ -13,6 +13,7 @@ type Properties = {
|
|||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Switch to form submit instead of JSON patch
|
||||||
export default function Modal({ name, disabled }: Properties) {
|
export default function Modal({ name, disabled }: Properties) {
|
||||||
const [newName, setNewName] = useState(name);
|
const [newName, setNewName] = useState(name);
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
@ -45,46 +46,30 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
)}
|
)}
|
||||||
Rename Tailnet
|
Rename Tailnet
|
||||||
</Dialog.Button>
|
</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel
|
||||||
{(close) => (
|
onSubmit={() => {
|
||||||
<>
|
fetcher.submit(
|
||||||
<Dialog.Title>Rename Tailnet</Dialog.Title>
|
{
|
||||||
<Dialog.Text>
|
'dns.base_domain': newName,
|
||||||
Keep in mind that changing this can lead to all sorts of
|
},
|
||||||
unexpected behavior and may break existing devices in your
|
{
|
||||||
tailnet.
|
method: 'PATCH',
|
||||||
</Dialog.Text>
|
encType: 'application/json',
|
||||||
<TextField
|
},
|
||||||
label="Tailnet name"
|
);
|
||||||
placeholder="ts.net"
|
}}
|
||||||
state={[newName, setNewName]}
|
>
|
||||||
className="my-2"
|
<Dialog.Title>Rename Tailnet</Dialog.Title>
|
||||||
/>
|
<Dialog.Text>
|
||||||
<Dialog.Gutter>
|
Keep in mind that changing this can lead to all sorts of unexpected
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
behavior and may break existing devices in your tailnet.
|
||||||
Cancel
|
</Dialog.Text>
|
||||||
</Dialog.Action>
|
<TextField
|
||||||
<Dialog.Action
|
label="Tailnet name"
|
||||||
variant="confirm"
|
placeholder="ts.net"
|
||||||
onPress={() => {
|
state={[newName, setNewName]}
|
||||||
fetcher.submit(
|
className="my-2"
|
||||||
{
|
/>
|
||||||
'dns.base_domain': newName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
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';
|
||||||
@ -23,80 +23,61 @@ 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
|
||||||
{(close) => (
|
onSubmit={(event) => {
|
||||||
<>
|
event.preventDefault();
|
||||||
<Dialog.Title>Add DNS record</Dialog.Title>
|
if (!name || !ip) return;
|
||||||
<Dialog.Text>
|
|
||||||
Enter the domain and IP address for the new DNS record.
|
|
||||||
</Dialog.Text>
|
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!name || !ip) return;
|
|
||||||
|
|
||||||
setName('');
|
setName('');
|
||||||
setIp('');
|
setIp('');
|
||||||
|
submit(
|
||||||
submit(
|
{
|
||||||
{
|
'dns.extra_records': [
|
||||||
'dns.extra_records': [
|
...records,
|
||||||
...records,
|
{
|
||||||
{
|
name,
|
||||||
name,
|
type: 'A',
|
||||||
type: 'A',
|
value: ip,
|
||||||
value: ip,
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
method: 'PATCH',
|
||||||
method: 'PATCH',
|
encType: 'application/json',
|
||||||
encType: 'application/json',
|
},
|
||||||
},
|
);
|
||||||
);
|
}}
|
||||||
|
>
|
||||||
close();
|
<Dialog.Title>Add DNS record</Dialog.Title>
|
||||||
}}
|
<Dialog.Text>
|
||||||
>
|
Enter the domain and IP address for the new DNS record.
|
||||||
<TextField
|
</Dialog.Text>
|
||||||
label="Domain"
|
<TextField
|
||||||
placeholder="test.example.com"
|
isRequired
|
||||||
name="domain"
|
label="Domain"
|
||||||
state={[name, setName]}
|
placeholder="test.example.com"
|
||||||
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
|
name="domain"
|
||||||
/>
|
state={[name, setName]}
|
||||||
<TextField
|
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
|
||||||
label="IP Address"
|
/>
|
||||||
placeholder="101.101.101.101"
|
<TextField
|
||||||
name="ip"
|
isRequired
|
||||||
state={[ip, setIp]}
|
label="IP Address"
|
||||||
className={cn(isDuplicate && 'outline outline-red-500')}
|
placeholder="101.101.101.101"
|
||||||
/>
|
name="ip"
|
||||||
{isDuplicate ? (
|
state={[ip, setIp]}
|
||||||
<p className="text-sm opacity-50">
|
className={cn(isDuplicate && 'outline outline-red-500')}
|
||||||
A record with the domain name <Code>{name}</Code> and IP
|
/>
|
||||||
address <Code>{ip}</Code> already exists.
|
{isDuplicate ? (
|
||||||
</p>
|
<p className="text-sm opacity-50">
|
||||||
) : undefined}
|
A record with the domain name <Code>{name}</Code> and IP address{' '}
|
||||||
<Dialog.Gutter>
|
<Code>{ip}</Code> already exists.
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
</p>
|
||||||
Cancel
|
) : undefined}
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
onPress={close}
|
|
||||||
isDisabled={isDuplicate}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
import { RepoForkedIcon } from '@primer/octicons-react';
|
||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useSubmit } from 'react-router';
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Switch from '~/components/Switch';
|
import Switch from '~/components/Switch';
|
||||||
@ -21,135 +21,116 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button>Add nameserver</Dialog.Button>
|
<Dialog.Button>Add nameserver</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel
|
||||||
{(close) => (
|
onSubmit={(event) => {
|
||||||
<>
|
event.preventDefault();
|
||||||
<Dialog.Title>Add nameserver</Dialog.Title>
|
if (!ns) return;
|
||||||
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
|
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>
|
||||||
|
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Use this IPv4 or IPv6 address to resolve names.
|
||||||
|
</Dialog.Text>
|
||||||
|
<TextField
|
||||||
|
label="DNS Server"
|
||||||
|
placeholder="1.2.3.4"
|
||||||
|
name="ns"
|
||||||
|
state={[ns, setNs]}
|
||||||
|
className="mt-2 mb-8"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="block">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Dialog.Text className="font-semibold">
|
||||||
|
Restrict to domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button
|
||||||
|
className={cn(
|
||||||
|
'text-xs rounded-md px-1.5 py-0.5',
|
||||||
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||||
|
Split DNS
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Only clients that support split DNS (Tailscale v1.8 or later
|
||||||
|
for most platforms) will use this nameserver. Older clients
|
||||||
|
will ignore it.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<Dialog.Text className="text-sm">
|
<Dialog.Text className="text-sm">
|
||||||
Use this IPv4 or IPv6 address to resolve names.
|
This nameserver will only be used for some domains.
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label="Split DNS"
|
||||||
|
defaultSelected={split}
|
||||||
|
onChange={() => {
|
||||||
|
setSplit(!split);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{split ? (
|
||||||
|
<>
|
||||||
|
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
placeholder="example.com"
|
||||||
|
name="domain"
|
||||||
|
state={[domain, setDomain]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Only single-label or fully-qualified queries matching this suffix
|
||||||
|
should use the nameserver.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
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);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="DNS Server"
|
|
||||||
placeholder="1.2.3.4"
|
|
||||||
name="ns"
|
|
||||||
state={[ns, setNs]}
|
|
||||||
className="mt-2 mb-8"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="block">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<Dialog.Text className="font-semibold">
|
|
||||||
Restrict to domain
|
|
||||||
</Dialog.Text>
|
|
||||||
<Tooltip>
|
|
||||||
<Tooltip.Button
|
|
||||||
className={cn(
|
|
||||||
'text-xs rounded-md px-1.5 py-0.5',
|
|
||||||
'bg-ui-200 dark:bg-ui-800',
|
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
|
||||||
Split DNS
|
|
||||||
</Tooltip.Button>
|
|
||||||
<Tooltip.Body>
|
|
||||||
Only clients that support split DNS (Tailscale v1.8 or
|
|
||||||
later for most platforms) will use this nameserver.
|
|
||||||
Older clients will ignore it.
|
|
||||||
</Tooltip.Body>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Dialog.Text className="text-sm">
|
|
||||||
This nameserver will only be used for some domains.
|
|
||||||
</Dialog.Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
label="Split DNS"
|
|
||||||
defaultSelected={split}
|
|
||||||
onChange={() => {
|
|
||||||
setSplit(!split);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{split ? (
|
|
||||||
<>
|
|
||||||
<Dialog.Text className="font-semibold mt-8">
|
|
||||||
Domain
|
|
||||||
</Dialog.Text>
|
|
||||||
<TextField
|
|
||||||
label="Domain"
|
|
||||||
placeholder="example.com"
|
|
||||||
name="domain"
|
|
||||||
state={[domain, setDomain]}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
|
||||||
<Dialog.Text className="text-sm">
|
|
||||||
Only single-label or fully-qualified queries matching this
|
|
||||||
suffix should use the nameserver.
|
|
||||||
</Dialog.Text>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
|
||||||
Add
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
</>
|
||||||
)}
|
) : undefined}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -49,7 +49,12 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
return data({ success: false }, { status: 403 });
|
return data({ success: false }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch = (await request.json()) as Record<string, unknown>;
|
const textData = await request.text();
|
||||||
|
if (!textData) {
|
||||||
|
return data({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = JSON.parse(textData) as Record<string, unknown>;
|
||||||
await patchConfig(patch);
|
await patchConfig(patch);
|
||||||
|
|
||||||
if (context.integration?.onConfigChange) {
|
if (context.integration?.onConfigChange) {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import type { ActionFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs } from 'react-router';
|
||||||
import { del, post } from '~/utils/headscale';
|
import { del, post } from '~/utils/headscale';
|
||||||
import { getSession } from '~/utils/sessions.server';
|
|
||||||
import { send } from '~/utils/res';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
|
import { send } from '~/utils/res';
|
||||||
|
import { getSession } from '~/utils/sessions.server';
|
||||||
|
|
||||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||||
const session = await getSession(request.headers.get('Cookie'));
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { KebabHorizontalIcon } from '@primer/octicons-react';
|
import { KebabHorizontalIcon } from '@primer/octicons-react';
|
||||||
import { type ReactNode, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import MenuComponent from '~/components/Menu';
|
import MenuComponent from '~/components/Menu';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { Machine, Route, User } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
@ -17,9 +16,11 @@ interface MenuProps {
|
|||||||
routes: Route[];
|
routes: Route[];
|
||||||
users: User[];
|
users: User[];
|
||||||
magic?: string;
|
magic?: string;
|
||||||
buttonChild?: ReactNode;
|
buttonChild?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||||
|
|
||||||
export default function Menu({
|
export default function Menu({
|
||||||
machine,
|
machine,
|
||||||
routes,
|
routes,
|
||||||
@ -27,12 +28,7 @@ export default function Menu({
|
|||||||
users,
|
users,
|
||||||
buttonChild,
|
buttonChild,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const renameState = useState(false);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
const expireState = useState(false);
|
|
||||||
const removeState = useState(false);
|
|
||||||
const routesState = useState(false);
|
|
||||||
const moveState = useState(false);
|
|
||||||
const tagsState = useState(false);
|
|
||||||
|
|
||||||
const expired =
|
const expired =
|
||||||
machine.expiry === '0001-01-01 00:00:00' ||
|
machine.expiry === '0001-01-01 00:00:00' ||
|
||||||
@ -43,12 +39,63 @@ export default function Menu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Rename machine={machine} state={renameState} magic={magic} />
|
{modal === 'remove' && (
|
||||||
<Delete machine={machine} state={removeState} />
|
<Delete
|
||||||
{expired ? undefined : <Expire machine={machine} state={expireState} />}
|
machine={machine}
|
||||||
<Routes machine={machine} routes={routes} state={routesState} />
|
isOpen={modal === 'remove'}
|
||||||
<Tags machine={machine} state={tagsState} />
|
setIsOpen={(isOpen) => {
|
||||||
<Move machine={machine} state={moveState} users={users} magic={magic} />
|
if (!isOpen) setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modal === 'move' && (
|
||||||
|
<Move
|
||||||
|
machine={machine}
|
||||||
|
users={users}
|
||||||
|
isOpen={modal === 'move'}
|
||||||
|
setIsOpen={(isOpen) => {
|
||||||
|
if (!isOpen) setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modal === 'rename' && (
|
||||||
|
<Rename
|
||||||
|
machine={machine}
|
||||||
|
magic={magic}
|
||||||
|
isOpen={modal === 'rename'}
|
||||||
|
setIsOpen={(isOpen) => {
|
||||||
|
if (!isOpen) setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modal === 'routes' && (
|
||||||
|
<Routes
|
||||||
|
machine={machine}
|
||||||
|
routes={routes}
|
||||||
|
isOpen={modal === 'routes'}
|
||||||
|
setIsOpen={(isOpen) => {
|
||||||
|
if (!isOpen) setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modal === 'tags' && (
|
||||||
|
<Tags
|
||||||
|
machine={machine}
|
||||||
|
isOpen={modal === 'tags'}
|
||||||
|
setIsOpen={(isOpen) => {
|
||||||
|
if (!isOpen) setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{expired && modal === 'expire' ? undefined : (
|
||||||
|
<Expire
|
||||||
|
machine={machine}
|
||||||
|
isOpen={modal === 'expire'}
|
||||||
|
setIsOpen={(isOpen) => {
|
||||||
|
if (!isOpen) setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<MenuComponent>
|
<MenuComponent>
|
||||||
{buttonChild ?? (
|
{buttonChild ?? (
|
||||||
@ -63,26 +110,26 @@ export default function Menu({
|
|||||||
</MenuComponent.Button>
|
</MenuComponent.Button>
|
||||||
)}
|
)}
|
||||||
<MenuComponent.Items>
|
<MenuComponent.Items>
|
||||||
<MenuComponent.ItemButton control={renameState}>
|
<MenuComponent.ItemButton onPress={() => setModal('rename')}>
|
||||||
Edit machine name
|
Edit machine name
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
<MenuComponent.ItemButton control={routesState}>
|
<MenuComponent.ItemButton onPress={() => setModal('routes')}>
|
||||||
Edit route settings
|
Edit route settings
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
<MenuComponent.ItemButton control={tagsState}>
|
<MenuComponent.ItemButton onPress={() => setModal('tags')}>
|
||||||
Edit ACL tags
|
Edit ACL tags
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
<MenuComponent.ItemButton control={moveState}>
|
<MenuComponent.ItemButton onPress={() => setModal('move')}>
|
||||||
Change owner
|
Change owner
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
{expired ? undefined : (
|
{expired ? undefined : (
|
||||||
<MenuComponent.ItemButton control={expireState}>
|
<MenuComponent.ItemButton onPress={() => setModal('expire')}>
|
||||||
Expire
|
Expire
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
)}
|
)}
|
||||||
<MenuComponent.ItemButton
|
<MenuComponent.ItemButton
|
||||||
className="text-red-500 dark:text-red-400"
|
className="text-red-500 dark:text-red-400"
|
||||||
control={removeState}
|
onPress={() => setModal('remove')}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
|
|||||||
@ -1,57 +1,23 @@
|
|||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import type { Machine } from '~/types';
|
import type { Machine } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
interface DeleteProps {
|
interface DeleteProps {
|
||||||
readonly machine: Machine;
|
machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Delete({ machine, state }: DeleteProps) {
|
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
||||||
const submit = useSubmit();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel variant="destructive">
|
||||||
{(close) => (
|
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
||||||
<>
|
<Dialog.Text>
|
||||||
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
This machine will be permanently removed from your network. To re-add
|
||||||
<Dialog.Text>
|
it, you will need to reauthenticate to your tailnet from the device.
|
||||||
This machine will be permanently removed from your network. To
|
</Dialog.Text>
|
||||||
re-add it, you will need to reauthenticate to your tailnet from
|
<input type="hidden" name="_method" value="delete" />
|
||||||
the device.
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
</Dialog.Text>
|
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
submit(e.currentTarget);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="delete" />
|
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
className={cn(
|
|
||||||
'bg-red-500 hover:border-red-700',
|
|
||||||
'dark:bg-red-600 dark:hover:border-red-700',
|
|
||||||
'pressed:bg-red-600 hover:bg-red-600',
|
|
||||||
'text-white dark:text-white',
|
|
||||||
)}
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,56 +1,23 @@
|
|||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import type { Machine } from '~/types';
|
import type { Machine } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
interface ExpireProps {
|
interface ExpireProps {
|
||||||
readonly machine: Machine;
|
machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Expire({ machine, state }: ExpireProps) {
|
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
|
||||||
const submit = useSubmit();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel variant="destructive">
|
||||||
{(close) => (
|
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
|
||||||
<>
|
<Dialog.Text>
|
||||||
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
|
This will disconnect the machine from your Tailnet. In order to
|
||||||
<Dialog.Text>
|
reconnect, you will need to re-authenticate from the device.
|
||||||
This will disconnect the machine from your Tailnet. In order to
|
</Dialog.Text>
|
||||||
reconnect, you will need to re-authenticate from the device.
|
<input type="hidden" name="_method" value="expire" />
|
||||||
</Dialog.Text>
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
submit(e.currentTarget);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="expire" />
|
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
className={cn(
|
|
||||||
'bg-red-500 hover:border-red-700',
|
|
||||||
'dark:bg-red-600 dark:hover:border-red-700',
|
|
||||||
'pressed:bg-red-600 hover:bg-red-600',
|
|
||||||
'text-white dark:text-white',
|
|
||||||
)}
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
Expire
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,71 +1,36 @@
|
|||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
|
||||||
|
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Select from '~/components/Select';
|
import Select from '~/components/Select';
|
||||||
import type { Machine, User } from '~/types';
|
import type { Machine, User } from '~/types';
|
||||||
|
|
||||||
interface MoveProps {
|
interface MoveProps {
|
||||||
readonly machine: Machine;
|
machine: Machine;
|
||||||
readonly users: User[];
|
users: User[];
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
isOpen: boolean;
|
||||||
readonly magic?: string;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Move({ machine, state, magic, users }: MoveProps) {
|
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||||
const [owner, setOwner] = useState(machine.user.name);
|
|
||||||
const submit = useSubmit();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel>
|
||||||
{(close) => (
|
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||||
<>
|
<Dialog.Text>
|
||||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
The owner of the machine is the user associated with it.
|
||||||
<Dialog.Text>
|
</Dialog.Text>
|
||||||
The owner of the machine is the user associated with it.
|
<input type="hidden" name="_method" value="move" />
|
||||||
</Dialog.Text>
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
<Form
|
<Select
|
||||||
method="POST"
|
label="Owner"
|
||||||
onSubmit={(e) => {
|
name="to"
|
||||||
submit(e.currentTarget);
|
placeholder="Select a user"
|
||||||
}}
|
defaultSelectedKey={machine.user.id}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="_method" value="move" />
|
{users.map((user) => (
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
<Select.Item key={user.id} id={user.name}>
|
||||||
<Select
|
{user.name}
|
||||||
label="Owner"
|
</Select.Item>
|
||||||
name="to"
|
))}
|
||||||
placeholder="Select a user"
|
</Select>
|
||||||
state={[owner, setOwner]}
|
|
||||||
>
|
|
||||||
{users.map((user) => (
|
|
||||||
<Select.Item key={user.id} id={user.name}>
|
|
||||||
{user.name}
|
|
||||||
</Select.Item>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{magic ? (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
|
||||||
This machine is accessible by the hostname{' '}
|
|
||||||
<Code className="text-sm">
|
|
||||||
{machine.givenName}.{magic}
|
|
||||||
</Code>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
) : undefined}
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
|
||||||
Change owner
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import { Form, useFetcher, Link } from 'react-router';
|
import { KeyIcon, ServerIcon } from '@primer/octicons-react';
|
||||||
import { Dispatch, SetStateAction, useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
|
import { Link, useFetcher } from 'react-router';
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import TextField from '~/components/TextField';
|
|
||||||
import Select from '~/components/Select';
|
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
|
import Select from '~/components/Select';
|
||||||
import Spinner from '~/components/Spinner';
|
import Spinner from '~/components/Spinner';
|
||||||
|
import TextField from '~/components/TextField';
|
||||||
import { toast } from '~/components/Toaster';
|
import { toast } from '~/components/Toaster';
|
||||||
import { Machine, type User } from '~/types';
|
import type { User } from '~/types';
|
||||||
|
|
||||||
export interface NewProps {
|
export interface NewProps {
|
||||||
server: string;
|
server: string;
|
||||||
@ -19,7 +18,7 @@ export interface NewProps {
|
|||||||
|
|
||||||
export default function New(data: NewProps) {
|
export default function New(data: NewProps) {
|
||||||
const fetcher = useFetcher<{ success?: boolean }>();
|
const fetcher = useFetcher<{ success?: boolean }>();
|
||||||
const mkeyState = useState(false);
|
const [pushDialog, setPushDialog] = useState(false);
|
||||||
const [mkey, setMkey] = useState('');
|
const [mkey, setMkey] = useState('');
|
||||||
const [user, setUser] = useState('');
|
const [user, setUser] = useState('');
|
||||||
const [toasted, setToasted] = useState(false);
|
const [toasted, setToasted] = useState(false);
|
||||||
@ -36,78 +35,50 @@ export default function New(data: NewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setToasted(true);
|
setToasted(true);
|
||||||
}, [fetcher.data, toasted, mkey]);
|
}, [fetcher.data, toasted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog>
|
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||||
<Dialog.Panel control={mkeyState}>
|
<Dialog.Panel
|
||||||
{(close) => (
|
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
|
||||||
<>
|
>
|
||||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||||
<Dialog.Text className="mb-4">
|
<Dialog.Text className="mb-4">
|
||||||
The machine key is given when you run{' '}
|
The machine key is given when you run{' '}
|
||||||
<Code isCopyable>
|
<Code isCopyable>
|
||||||
tailscale up --login-server=
|
tailscale up --login-server=
|
||||||
{data.server}
|
{data.server}
|
||||||
</Code>{' '}
|
</Code>{' '}
|
||||||
on your device.
|
on your device.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<fetcher.Form
|
<input type="hidden" name="_method" value="register" />
|
||||||
method="POST"
|
<input type="hidden" name="id" value="_" />
|
||||||
onSubmit={(e) => {
|
<TextField
|
||||||
fetcher.submit(e.currentTarget);
|
label="Machine Key"
|
||||||
close();
|
placeholder="mkey:ff....."
|
||||||
}}
|
name="mkey"
|
||||||
>
|
state={[mkey, setMkey]}
|
||||||
<input type="hidden" name="_method" value="register" />
|
className="my-2 font-mono"
|
||||||
<input type="hidden" name="id" value="_" />
|
/>
|
||||||
<TextField
|
<Select
|
||||||
label="Machine Key"
|
label="Owner"
|
||||||
placeholder="mkey:ff....."
|
name="user"
|
||||||
name="mkey"
|
placeholder="Select a user"
|
||||||
state={[mkey, setMkey]}
|
state={[user, setUser]}
|
||||||
className="my-2 font-mono"
|
>
|
||||||
/>
|
{data.users.map((user) => (
|
||||||
<Select
|
<Select.Item key={user.id} id={user.name}>
|
||||||
label="Owner"
|
{user.name}
|
||||||
name="user"
|
</Select.Item>
|
||||||
placeholder="Select a user"
|
))}
|
||||||
state={[user, setUser]}
|
</Select>
|
||||||
>
|
|
||||||
{data.users.map((user) => (
|
|
||||||
<Select.Item key={user.id} id={user.name}>
|
|
||||||
{user.name}
|
|
||||||
</Select.Item>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
isDisabled={
|
|
||||||
!mkey || !mkey.trim().startsWith('mkey:') || !user
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Register
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</fetcher.Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button variant="heavy">
|
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||||
Add Device
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items>
|
<Menu.Items>
|
||||||
<Menu.ItemButton control={mkeyState}>
|
<Menu.ItemButton onPress={() => setPushDialog(true)}>
|
||||||
<ServerIcon className="w-4 h-4 mr-2" />
|
<ServerIcon className="w-4 h-4 mr-2" />
|
||||||
Register Machine Key
|
Register Machine Key
|
||||||
</Menu.ItemButton>
|
</Menu.ItemButton>
|
||||||
|
|||||||
@ -1,78 +1,60 @@
|
|||||||
import { Form, useSubmit } from 'react-router';
|
import { useState } from 'react';
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
|
||||||
|
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import TextField from '~/components/TextField';
|
import TextField from '~/components/TextField';
|
||||||
import type { Machine } from '~/types';
|
import type { Machine } from '~/types';
|
||||||
|
|
||||||
interface RenameProps {
|
interface RenameProps {
|
||||||
readonly machine: Machine;
|
machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
isOpen: boolean;
|
||||||
readonly magic?: string;
|
magic?: string;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
export default function Rename({
|
||||||
|
machine,
|
||||||
|
magic,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
}: RenameProps) {
|
||||||
const [name, setName] = useState(machine.givenName);
|
const [name, setName] = useState(machine.givenName);
|
||||||
const submit = useSubmit();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel>
|
||||||
{(close) => (
|
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
|
||||||
<>
|
<Dialog.Text>
|
||||||
<Dialog.Title>
|
This name is shown in the admin panel, in Tailscale clients, and used
|
||||||
Edit machine name for {machine.givenName}
|
when generating MagicDNS names.
|
||||||
</Dialog.Title>
|
</Dialog.Text>
|
||||||
<Dialog.Text>
|
<input type="hidden" name="_method" value="rename" />
|
||||||
This name is shown in the admin panel, in Tailscale clients, and
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
used when generating MagicDNS names.
|
<TextField
|
||||||
</Dialog.Text>
|
label="Machine name"
|
||||||
<Form
|
placeholder="Machine name"
|
||||||
method="POST"
|
name="name"
|
||||||
onSubmit={(e) => {
|
className="my-2"
|
||||||
submit(e.currentTarget);
|
defaultValue={machine.givenName}
|
||||||
}}
|
onChange={setName}
|
||||||
>
|
/>
|
||||||
<input type="hidden" name="_method" value="rename" />
|
{magic ? (
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
name.length > 0 && name !== machine.givenName ? (
|
||||||
<TextField
|
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||||
label="Machine name"
|
This machine will be accessible by the hostname{' '}
|
||||||
placeholder="Machine name"
|
<Code className="text-sm">
|
||||||
name="name"
|
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||||
state={[name, setName]}
|
</Code>
|
||||||
className="my-2"
|
{'. '}
|
||||||
/>
|
The hostname <Code className="text-sm">{machine.givenName}</Code>{' '}
|
||||||
{magic ? (
|
will no longer point to this machine.
|
||||||
name.length > 0 && name !== machine.givenName ? (
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
) : (
|
||||||
This machine will be accessible by the hostname{' '}
|
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||||
<Code className="text-sm">
|
This machine is accessible by the hostname{' '}
|
||||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
<Code className="text-sm">{machine.givenName}</Code>.
|
||||||
</Code>
|
</p>
|
||||||
{'. '}
|
)
|
||||||
The hostname{' '}
|
) : undefined}
|
||||||
<Code className="text-sm">{machine.givenName}</Code> will no
|
|
||||||
longer point to this machine.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
|
||||||
This machine is accessible by the hostname{' '}
|
|
||||||
<Code className="text-sm">{machine.givenName}</Code>.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
) : undefined}
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
|
||||||
Rename
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,20 +1,25 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useFetcher } from 'react-router';
|
import { useFetcher } from 'react-router';
|
||||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Switch from '~/components/Switch';
|
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
|
import Switch from '~/components/Switch';
|
||||||
import type { Machine, Route } from '~/types';
|
import type { Machine, Route } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
interface RoutesProps {
|
interface RoutesProps {
|
||||||
readonly machine: Machine;
|
machine: Machine;
|
||||||
readonly routes: Route[];
|
routes: Route[];
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support deleting routes
|
// TODO: Support deleting routes
|
||||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
export default function Routes({
|
||||||
|
machine,
|
||||||
|
routes,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
}: RoutesProps) {
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
// This is much easier with Object.groupBy but it's too new for us
|
// This is much easier with Object.groupBy but it's too new for us
|
||||||
@ -40,136 +45,118 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
|
|||||||
}, [exit]);
|
}, [exit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel variant="unactionable">
|
||||||
{(close) => (
|
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
|
||||||
<>
|
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
||||||
<Dialog.Title>
|
<Dialog.Text>
|
||||||
Edit route settings of {machine.givenName}
|
Connect to devices you can't install Tailscale on by advertising
|
||||||
</Dialog.Title>
|
IP ranges as subnet routes.{' '}
|
||||||
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
<Link
|
||||||
<Dialog.Text>
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
Connect to devices you can't install Tailscale on by
|
name="Tailscale Subnets Documentation"
|
||||||
advertising IP ranges as subnet routes.{' '}
|
>
|
||||||
<Link
|
Learn More
|
||||||
to="https://tailscale.com/kb/1019/subnets"
|
</Link>
|
||||||
name="Tailscale Subnets Documentation"
|
</Dialog.Text>
|
||||||
>
|
<div
|
||||||
Learn More
|
className={cn(
|
||||||
</Link>
|
'rounded-lg overflow-y-auto my-2',
|
||||||
</Dialog.Text>
|
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||||
|
'border border-zinc-200 dark:border-zinc-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subnet.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg overflow-y-auto my-2',
|
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
'items-center justify-center',
|
||||||
'border border-zinc-200 dark:border-zinc-700',
|
'text-ui-600 dark:text-ui-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{subnet.length === 0 ? (
|
<p>No routes are advertised on this machine.</p>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-center',
|
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>No routes are advertised on this machine.</p>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
{subnet.map((route) => (
|
|
||||||
<div
|
|
||||||
key={route.id}
|
|
||||||
className={cn(
|
|
||||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-between',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>{route.prefix}</p>
|
|
||||||
<Switch
|
|
||||||
defaultSelected={route.enabled}
|
|
||||||
label="Enabled"
|
|
||||||
onChange={(checked) => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.set('id', machine.id);
|
|
||||||
form.set('_method', 'routes');
|
|
||||||
form.set('route', route.id);
|
|
||||||
|
|
||||||
form.set('enabled', String(checked));
|
|
||||||
fetcher.submit(form, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
|
) : undefined}
|
||||||
<Dialog.Text>
|
{subnet.map((route) => (
|
||||||
Allow your network to route internet traffic through this machine.{' '}
|
<div
|
||||||
<Link
|
key={route.id}
|
||||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
className={cn(
|
||||||
name="Tailscale Exit-node Documentation"
|
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
>
|
'items-center justify-between',
|
||||||
Learn More
|
)}
|
||||||
</Link>
|
>
|
||||||
</Dialog.Text>
|
<p>{route.prefix}</p>
|
||||||
|
<Switch
|
||||||
|
defaultSelected={route.enabled}
|
||||||
|
label="Enabled"
|
||||||
|
onChange={(checked) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', machine.id);
|
||||||
|
form.set('_method', 'routes');
|
||||||
|
form.set('route', route.id);
|
||||||
|
|
||||||
|
form.set('enabled', String(checked));
|
||||||
|
fetcher.submit(form, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
|
||||||
|
<Dialog.Text>
|
||||||
|
Allow your network to route internet traffic through this machine.{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||||
|
name="Tailscale Exit-node Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</Dialog.Text>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg overflow-y-auto my-2',
|
||||||
|
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||||
|
'border border-zinc-200 dark:border-zinc-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exit.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg overflow-y-auto my-2',
|
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
'items-center justify-center',
|
||||||
'border border-zinc-200 dark:border-zinc-700',
|
'text-ui-600 dark:text-ui-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{exit.length === 0 ? (
|
<p>This machine is not an exit node.</p>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-center',
|
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>This machine is not an exit node.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-between',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>Use as exit node</p>
|
|
||||||
<Switch
|
|
||||||
defaultSelected={exitEnabled}
|
|
||||||
label="Enabled"
|
|
||||||
onChange={(checked) => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.set('id', machine.id);
|
|
||||||
form.set('_method', 'exit-node');
|
|
||||||
form.set(
|
|
||||||
'routes',
|
|
||||||
exit.map((route) => route.id).join(','),
|
|
||||||
);
|
|
||||||
|
|
||||||
form.set('enabled', String(checked));
|
|
||||||
fetcher.submit(form, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Gutter>
|
) : (
|
||||||
<Dialog.Action
|
<div
|
||||||
variant="cancel"
|
className={cn(
|
||||||
isDisabled={fetcher.state === 'submitting'}
|
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
onPress={close}
|
'items-center justify-between',
|
||||||
>
|
)}
|
||||||
Close
|
>
|
||||||
</Dialog.Action>
|
<p>Use as exit node</p>
|
||||||
</Dialog.Gutter>
|
<Switch
|
||||||
</>
|
defaultSelected={exitEnabled}
|
||||||
)}
|
label="Enabled"
|
||||||
|
onChange={(checked) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('id', machine.id);
|
||||||
|
form.set('_method', 'exit-node');
|
||||||
|
form.set('routes', exit.map((route) => route.id).join(','));
|
||||||
|
|
||||||
|
form.set('enabled', String(checked));
|
||||||
|
fetcher.submit(form, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,146 +1,124 @@
|
|||||||
import { PlusIcon, XIcon } from '@primer/octicons-react';
|
import { PlusIcon, XIcon } from '@primer/octicons-react';
|
||||||
import { Form, useSubmit } from 'react-router';
|
import { useState } from 'react';
|
||||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
|
||||||
import { Button, Input } from 'react-aria-components';
|
import { Button, Input } from 'react-aria-components';
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import type { Machine } from '~/types';
|
import type { Machine } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
interface TagsProps {
|
interface TagsProps {
|
||||||
readonly machine: Machine;
|
machine: Machine;
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tags({ machine, state }: TagsProps) {
|
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||||
const [tags, setTags] = useState(machine.forcedTags);
|
const [tags, setTags] = useState(machine.forcedTags);
|
||||||
const [tag, setTag] = useState('');
|
const [tag, setTag] = useState('');
|
||||||
const submit = useSubmit();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel>
|
||||||
{(close) => (
|
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||||
<>
|
<Dialog.Text>
|
||||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
ACL tags can be used to reference machines in your ACL policies. See
|
||||||
<Dialog.Text>
|
the{' '}
|
||||||
ACL tags can be used to reference machines in your ACL policies.
|
<Link
|
||||||
See the{' '}
|
to="https://tailscale.com/kb/1068/acl-tags"
|
||||||
<Link
|
name="Tailscale documentation"
|
||||||
to="https://tailscale.com/kb/1068/acl-tags"
|
>
|
||||||
name="Tailscale documentation"
|
Tailscale documentation
|
||||||
>
|
</Link>{' '}
|
||||||
Tailscale documentation
|
for more information.
|
||||||
</Link>{' '}
|
</Dialog.Text>
|
||||||
for more information.
|
<input type="hidden" name="_method" value="tags" />
|
||||||
</Dialog.Text>
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
<Form
|
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||||
method="POST"
|
<div
|
||||||
onSubmit={(e) => {
|
className={cn(
|
||||||
submit(e.currentTarget);
|
'border border-ui-300 rounded-lg overflow-visible',
|
||||||
}}
|
'dark:border-ui-700 dark:text-ui-300 mt-4',
|
||||||
>
|
)}
|
||||||
<input type="hidden" name="_method" value="tags" />
|
>
|
||||||
<input type="hidden" name="id" value={machine.id} />
|
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
{tags.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border border-ui-300 rounded-lg overflow-visible',
|
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
'dark:border-ui-700 dark:text-ui-300 mt-4',
|
'items-center justify-center rounded-t-lg',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
<p>No tags are set on this machine.</p>
|
||||||
{tags.length === 0 ? (
|
</div>
|
||||||
<div
|
) : (
|
||||||
className={cn(
|
tags.map((item) => (
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-center rounded-t-lg',
|
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>No tags are set on this machine.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
tags.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
id={item}
|
|
||||||
className={cn(
|
|
||||||
'px-2.5 py-1.5 flex',
|
|
||||||
'items-center justify-between',
|
|
||||||
'font-mono text-sm',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
<Button
|
|
||||||
className="rounded-full p-0 w-6 h-6"
|
|
||||||
onPress={() => {
|
|
||||||
setTags(tags.filter((tag) => tag !== item));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
|
key={item}
|
||||||
|
id={item}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex px-2.5 py-1.5 w-full',
|
'px-2.5 py-1.5 flex',
|
||||||
'border-t border-ui-300 dark:border-ui-700',
|
'items-center justify-between',
|
||||||
'rounded-b-lg justify-between items-center',
|
'font-mono text-sm',
|
||||||
'dark:bg-ui-800 dark:text-ui-300',
|
|
||||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
|
||||||
tag.length > 0 &&
|
|
||||||
!tag.startsWith('tag:') &&
|
|
||||||
'outline outline-red-500',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Input
|
{item}
|
||||||
placeholder="tag:example"
|
|
||||||
className={cn(
|
|
||||||
'bg-transparent w-full',
|
|
||||||
'border-none focus:ring-0',
|
|
||||||
'focus:outline-none font-mono text-sm',
|
|
||||||
'dark:bg-transparent dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
value={tag}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTag(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className="rounded-full p-0 w-6 h-6"
|
||||||
'rounded-lg p-0 h-6 w-6',
|
|
||||||
!tag.startsWith('tag:') &&
|
|
||||||
'opacity-50 cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
isDisabled={
|
|
||||||
tag.length === 0 ||
|
|
||||||
!tag.startsWith('tag:') ||
|
|
||||||
tags.includes(tag)
|
|
||||||
}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setTags([...tags, tag]);
|
setTags(tags.filter((tag) => tag !== item));
|
||||||
setTag('');
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-4 h-4" />
|
<XIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
<Dialog.Gutter>
|
)}
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
</div>
|
||||||
Cancel
|
<div
|
||||||
</Dialog.Action>
|
className={cn(
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
'flex px-2.5 py-1.5 w-full',
|
||||||
Save
|
'border-t border-ui-300 dark:border-ui-700',
|
||||||
</Dialog.Action>
|
'rounded-b-lg justify-between items-center',
|
||||||
</Dialog.Gutter>
|
'dark:bg-ui-800 dark:text-ui-300',
|
||||||
</Form>
|
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||||
</>
|
tag.length > 0 &&
|
||||||
)}
|
!tag.startsWith('tag:') &&
|
||||||
|
'outline outline-red-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="tag:example"
|
||||||
|
className={cn(
|
||||||
|
'bg-transparent w-full',
|
||||||
|
'border-none focus:ring-0',
|
||||||
|
'focus:outline-none font-mono text-sm',
|
||||||
|
'dark:bg-transparent dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTag(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-0 h-6 w-6',
|
||||||
|
!tag.startsWith('tag:') && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={
|
||||||
|
tag.length === 0 ||
|
||||||
|
!tag.startsWith('tag:') ||
|
||||||
|
tags.includes(tag)
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
setTags([...tags, tag]);
|
||||||
|
setTag('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
import { InfoIcon } from '@primer/octicons-react';
|
import { InfoIcon } from '@primer/octicons-react';
|
||||||
|
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { useLoaderData } from 'react-router';
|
import { useLoaderData } from 'react-router';
|
||||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
|
|
||||||
|
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
|
import { ErrorPopup } from '~/components/Error';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
|
import type { Machine, Route, User } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
import { loadContext } from '~/utils/config/headplane';
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
import { loadConfig } from '~/utils/config/headscale';
|
import { loadConfig } from '~/utils/config/headscale';
|
||||||
import { pull } from '~/utils/headscale';
|
import { pull } from '~/utils/headscale';
|
||||||
import { getSession } from '~/utils/sessions.server';
|
import { getSession } from '~/utils/sessions.server';
|
||||||
import { useLiveData } from '~/utils/useLiveData';
|
import { useLiveData } from '~/utils/useLiveData';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
|
||||||
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
|
|
||||||
import { ErrorPopup } from '~/components/Error'
|
|
||||||
|
|
||||||
import { menuAction } from './action';
|
import { menuAction } from './action';
|
||||||
import MachineRow from './components/machine';
|
import MachineRow from './components/machine';
|
||||||
@ -138,7 +138,5 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
return (
|
return <ErrorPopup type="embedded" />;
|
||||||
<ErrorPopup type="embedded" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,22 @@
|
|||||||
import { useFetcher } from 'react-router';
|
|
||||||
import type { PreAuthKey } from '~/types';
|
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Spinner from '~/components/Spinner';
|
import type { PreAuthKey } from '~/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
authKey: PreAuthKey;
|
authKey: PreAuthKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExpireKey({ authKey }: Props) {
|
export default function ExpireKey({ authKey }: Props) {
|
||||||
const fetcher = useFetcher();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button className="my-4">Expire Key</Dialog.Button>
|
<Dialog.Button>Expire Key</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel method="DELETE" variant="destructive">
|
||||||
{(close) => (
|
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||||
<>
|
<input type="hidden" name="user" value={authKey.user} />
|
||||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
<input type="hidden" name="key" value={authKey.key} />
|
||||||
<fetcher.Form
|
<Dialog.Text>
|
||||||
method="DELETE"
|
Expiring this authentication key will immediately prevent it from
|
||||||
onSubmit={(e) => {
|
being used to authenticate new devices. This action cannot be undone.
|
||||||
fetcher.submit(e.currentTarget);
|
</Dialog.Text>
|
||||||
close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="user" value={authKey.user} />
|
|
||||||
<input type="hidden" name="key" value={authKey.key} />
|
|
||||||
<Dialog.Text>
|
|
||||||
Expiring this authentication key will immediately prevent it
|
|
||||||
from being used to authenticate new devices. This action cannot
|
|
||||||
be undone.
|
|
||||||
</Dialog.Text>
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
className={cn(
|
|
||||||
'bg-red-500 hover:border-red-700',
|
|
||||||
'dark:bg-red-600 dark:hover:border-red-700',
|
|
||||||
'pressed:bg-red-600 hover:bg-red-600',
|
|
||||||
'text-white dark:text-white',
|
|
||||||
)}
|
|
||||||
onPress={close}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Expire
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</fetcher.Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,17 +1,10 @@
|
|||||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
|
||||||
import { useFetcher } from 'react-router';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useFetcher } from 'react-router';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import TextField from '~/components/TextField';
|
import Link from '~/components/Link';
|
||||||
import NumberField from '~/components/NumberField';
|
import NumberInput from '~/components/NumberInput';
|
||||||
import Tooltip from '~/components/Tooltip';
|
|
||||||
import Select from '~/components/Select';
|
import Select from '~/components/Select';
|
||||||
import Switch from '~/components/Switch';
|
import Switch from '~/components/Switch';
|
||||||
import Link from '~/components/Link';
|
|
||||||
import Spinner from '~/components/Spinner';
|
|
||||||
|
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
import type { User } from '~/types';
|
import type { User } from '~/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -31,117 +24,76 @@ export default function AddPreAuthKey(data: Props) {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
{(close) => (
|
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||||
<>
|
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
||||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
|
||||||
<fetcher.Form
|
<Select
|
||||||
method="POST"
|
label="Owner"
|
||||||
onSubmit={(e) => {
|
name="user"
|
||||||
fetcher.submit(e.currentTarget);
|
placeholder="Select a user"
|
||||||
close();
|
state={[user, setUser]}
|
||||||
}}
|
>
|
||||||
>
|
{data.users.map((user) => (
|
||||||
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
<Select.Item key={user.id} id={user.name}>
|
||||||
<Dialog.Text className="text-sm">
|
{user.name}
|
||||||
Attach this key to a user
|
</Select.Item>
|
||||||
</Dialog.Text>
|
))}
|
||||||
<Select
|
</Select>
|
||||||
label="Owner"
|
<NumberInput
|
||||||
name="user"
|
isRequired
|
||||||
placeholder="Select a user"
|
name="expiry"
|
||||||
state={[user, setUser]}
|
label="Key Expiration"
|
||||||
|
description="Set this key to expire after a certain number of days."
|
||||||
|
minValue={1}
|
||||||
|
maxValue={365_000} // 1000 years
|
||||||
|
defaultValue={90}
|
||||||
|
formatOptions={{
|
||||||
|
style: 'unit',
|
||||||
|
unit: 'day',
|
||||||
|
unitDisplay: 'short',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center mt-6">
|
||||||
|
<div>
|
||||||
|
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Use this key to authenticate more than one device.
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label="Reusable"
|
||||||
|
name="reusable"
|
||||||
|
defaultSelected={reusable}
|
||||||
|
onChange={() => {
|
||||||
|
setReusable(!reusable);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||||
|
<div className="flex justify-between items-center mt-6">
|
||||||
|
<div>
|
||||||
|
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Devices authenticated with this key will be automatically removed
|
||||||
|
once they go offline.{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||||
|
name="Tailscale Ephemeral Nodes Documentation"
|
||||||
>
|
>
|
||||||
{data.users.map((user) => (
|
Learn more
|
||||||
<Select.Item key={user.id} id={user.name}>
|
</Link>
|
||||||
{user.name}
|
</Dialog.Text>
|
||||||
</Select.Item>
|
</div>
|
||||||
))}
|
<Switch
|
||||||
</Select>
|
label="Ephemeral"
|
||||||
<Dialog.Text className="font-semibold mt-4">
|
name="ephemeral"
|
||||||
Key Expiration
|
defaultSelected={ephemeral}
|
||||||
</Dialog.Text>
|
onChange={() => {
|
||||||
<Dialog.Text className="text-sm">
|
setEphemeral(!ephemeral);
|
||||||
Set this key to expire after a certain number of days.
|
}}
|
||||||
</Dialog.Text>
|
/>
|
||||||
<NumberField
|
</div>
|
||||||
label="Expiry"
|
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
|
||||||
name="expiry"
|
|
||||||
minValue={1}
|
|
||||||
maxValue={365_000} // 1000 years
|
|
||||||
state={[expiry, setExpiry]}
|
|
||||||
formatOptions={{
|
|
||||||
style: 'unit',
|
|
||||||
unit: 'day',
|
|
||||||
unitDisplay: 'short',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between items-center mt-6">
|
|
||||||
<div>
|
|
||||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
|
||||||
<Dialog.Text className="text-sm">
|
|
||||||
Use this key to authenticate more than one device.
|
|
||||||
</Dialog.Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
label="Reusable"
|
|
||||||
name="reusable"
|
|
||||||
defaultSelected={reusable}
|
|
||||||
onChange={() => {
|
|
||||||
setReusable(!reusable);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="reusable"
|
|
||||||
value={reusable.toString()}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between items-center mt-6">
|
|
||||||
<div>
|
|
||||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
|
||||||
<Dialog.Text className="text-sm">
|
|
||||||
Devices authenticated with this key will be automatically
|
|
||||||
removed once they go offline.{' '}
|
|
||||||
<Link
|
|
||||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
|
||||||
name="Tailscale Ephemeral Nodes Documentation"
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
</Link>
|
|
||||||
</Dialog.Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
label="Ephemeral"
|
|
||||||
name="ephemeral"
|
|
||||||
defaultSelected={ephemeral}
|
|
||||||
onChange={() => {
|
|
||||||
setEphemeral(!ephemeral);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="ephemeral"
|
|
||||||
value={ephemeral.toString()}
|
|
||||||
/>
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action
|
|
||||||
variant="confirm"
|
|
||||||
onPress={close}
|
|
||||||
isDisabled={!user || !expiry}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Generate
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</fetcher.Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export default function Auth({ magic }: Props) {
|
|||||||
You can add, remove, and rename users here.
|
You can add, remove, and rename users here.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<Add magic={magic} />
|
<Add />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default function Oidc({ oidc, magic }: Props) {
|
|||||||
manage users through your OIDC provider.
|
manage users through your OIDC provider.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<Add magic={magic} />
|
<Add />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,61 +1,24 @@
|
|||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import TextField from '~/components/TextField';
|
import Input from '~/components/Input';
|
||||||
|
|
||||||
interface Props {
|
|
||||||
magic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Add({ magic }: Props) {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const submit = useSubmit();
|
|
||||||
|
|
||||||
|
export default function Add() {
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button>Add a new user</Dialog.Button>
|
<Dialog.Button>Add a new user</Dialog.Button>
|
||||||
|
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
{(close) => (
|
<Dialog.Title>Add a new user</Dialog.Title>
|
||||||
<>
|
<Dialog.Text className="mb-8">
|
||||||
<Dialog.Title>Add a new user</Dialog.Title>
|
Enter a username to create a new user. Usernames can be addressed when
|
||||||
<Dialog.Text className="mb-8">
|
managing ACL policies.
|
||||||
Enter a username to create a new user.{' '}
|
</Dialog.Text>
|
||||||
{magic ? (
|
<input type="hidden" name="_method" value="create" />
|
||||||
<>
|
<Input
|
||||||
Since Magic DNS is enabled, machines will be accessible via{' '}
|
isRequired
|
||||||
<Code>[machine]. .{magic}</Code>.
|
name="username"
|
||||||
</>
|
label="Username"
|
||||||
) : undefined}
|
placeholder="my-new-user"
|
||||||
</Dialog.Text>
|
/>
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
submit(event.currentTarget);
|
|
||||||
setUsername('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="create" />
|
|
||||||
<TextField
|
|
||||||
label="Username"
|
|
||||||
placeholder="my-new-user"
|
|
||||||
name="username"
|
|
||||||
state={[username, setUsername]}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
|
||||||
<Dialog.Gutter>
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
|
||||||
Create
|
|
||||||
</Dialog.Action>
|
|
||||||
</Dialog.Gutter>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import IconButton from '~/components/IconButton';
|
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
|
|
||||||
@ -11,47 +7,20 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Remove({ username }: Props) {
|
export default function Remove({ username }: Props) {
|
||||||
const submit = useSubmit();
|
|
||||||
const [dialog, setDialog] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog>
|
||||||
<IconButton
|
<Dialog.IconButton label={`Delete ${username}`}>
|
||||||
label={`Delete ${username}`}
|
|
||||||
onPress={() => setDialog(true)}
|
|
||||||
>
|
|
||||||
<X className="p-0.5" />
|
<X className="p-0.5" />
|
||||||
</IconButton>
|
</Dialog.IconButton>
|
||||||
<Dialog control={[dialog, setDialog]}>
|
<Dialog.Panel>
|
||||||
<Dialog.Panel control={[dialog, setDialog]}>
|
<Dialog.Title>Delete {username}?</Dialog.Title>
|
||||||
{(close) => (
|
<Dialog.Text className="mb-8">
|
||||||
<>
|
Are you sure you want to delete {username}? A deleted user cannot be
|
||||||
<Dialog.Title>Delete {username}?</Dialog.Title>
|
recovered.
|
||||||
<Dialog.Text className="mb-8">
|
</Dialog.Text>
|
||||||
Are you sure you want to delete {username}? A deleted user
|
<input type="hidden" name="_method" value="delete" />
|
||||||
cannot be recovered.
|
<input type="hidden" name="username" value={username} />
|
||||||
</Dialog.Text>
|
</Dialog.Panel>
|
||||||
<Form
|
</Dialog>
|
||||||
method="POST"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
submit(event.currentTarget);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="_method" value="delete" />
|
|
||||||
<input type="hidden" name="username" value={username} />
|
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
|
||||||
Delete
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +1,37 @@
|
|||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import { Form, useSubmit } from 'react-router';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import IconButton from '~/components/IconButton';
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import TextField from '~/components/TextField';
|
import Input from '~/components/Input';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username: string;
|
username: string;
|
||||||
magic?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Rename({ username, magic }: Props) {
|
// TODO: Server side validation before submitting
|
||||||
const submit = useSubmit();
|
export default function Rename({ username }: Props) {
|
||||||
const [dialog, setDialog] = useState(false);
|
|
||||||
const [newName, setNewName] = useState(username);
|
const [newName, setNewName] = useState(username);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog>
|
||||||
<IconButton
|
<Dialog.IconButton label={`Rename ${username}`}>
|
||||||
label={`Rename ${username}`}
|
|
||||||
onPress={() => setDialog(true)}
|
|
||||||
>
|
|
||||||
<Pencil className="p-1" />
|
<Pencil className="p-1" />
|
||||||
</IconButton>
|
</Dialog.IconButton>
|
||||||
<Dialog control={[dialog, setDialog]}>
|
<Dialog.Panel>
|
||||||
<Dialog.Panel control={[dialog, setDialog]}>
|
<Dialog.Title>Rename {username}?</Dialog.Title>
|
||||||
{(close) => (
|
<Dialog.Text className="mb-8">
|
||||||
<>
|
Enter a new username for {username}. Changing a username will not
|
||||||
<Dialog.Title>Rename {username}?</Dialog.Title>
|
update any ACL policies that may refer to this user by their old
|
||||||
<Dialog.Text className="mb-8">
|
username.
|
||||||
Enter a new username for {username}
|
</Dialog.Text>
|
||||||
</Dialog.Text>
|
<input type="hidden" name="_method" value="rename" />
|
||||||
<Form
|
<input type="hidden" name="old" value={username} />
|
||||||
method="POST"
|
<Input
|
||||||
onSubmit={(event) => {
|
isRequired
|
||||||
submit(event.currentTarget);
|
name="new"
|
||||||
}}
|
label="Username"
|
||||||
>
|
placeholder="my-new-name"
|
||||||
<input type="hidden" name="_method" value="rename" />
|
/>
|
||||||
<input type="hidden" name="old" value={username} />
|
</Dialog.Panel>
|
||||||
<TextField
|
</Dialog>
|
||||||
label="Username"
|
|
||||||
placeholder="my-new-name"
|
|
||||||
name="new"
|
|
||||||
state={[newName, setNewName]}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
|
||||||
<Dialog.Action variant="cancel" onPress={close}>
|
|
||||||
Cancel
|
|
||||||
</Dialog.Action>
|
|
||||||
<Dialog.Action variant="confirm" onPress={close}>
|
|
||||||
Rename
|
|
||||||
</Dialog.Action>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,23 @@
|
|||||||
import {
|
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
DataRef,
|
|
||||||
DndContext,
|
|
||||||
useDraggable,
|
|
||||||
useDroppable,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { PersonIcon } from '@primer/octicons-react';
|
import { PersonIcon } from '@primer/octicons-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { useActionData, useLoaderData, useSubmit } from 'react-router';
|
import { useActionData, useLoaderData, useSubmit } from 'react-router';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ClientOnly } from 'remix-utils/client-only';
|
import { ClientOnly } from 'remix-utils/client-only';
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute';
|
import Attribute from '~/components/Attribute';
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
|
||||||
import { ErrorPopup } from '~/components/Error';
|
import { ErrorPopup } from '~/components/Error';
|
||||||
|
import StatusCircle from '~/components/StatusCircle';
|
||||||
import { toast } from '~/components/Toaster';
|
import { toast } from '~/components/Toaster';
|
||||||
import type { Machine, User } from '~/types';
|
import type { Machine, User } from '~/types';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
import { loadContext } from '~/utils/config/headplane';
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
import { loadConfig } from '~/utils/config/headscale';
|
import { loadConfig } from '~/utils/config/headscale';
|
||||||
import { del, post, pull } from '~/utils/headscale';
|
import { del, post, pull } from '~/utils/headscale';
|
||||||
|
import { send } from '~/utils/res';
|
||||||
import { getSession } from '~/utils/sessions.server';
|
import { getSession } from '~/utils/sessions.server';
|
||||||
import { useLiveData } from '~/utils/useLiveData';
|
import { useLiveData } from '~/utils/useLiveData';
|
||||||
import { send } from '~/utils/res';
|
|
||||||
|
|
||||||
import Auth from './components/auth';
|
import Auth from './components/auth';
|
||||||
import Oidc from './components/oidc';
|
import Oidc from './components/oidc';
|
||||||
@ -305,7 +300,7 @@ function UserCard({ user, magic }: CardProps) {
|
|||||||
<span className="text-lg font-mono">{user.name}</span>
|
<span className="text-lg font-mono">{user.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Rename username={user.name} magic={magic} />
|
<Rename username={user.name} />
|
||||||
{user.machines.length === 0 ? (
|
{user.machines.length === 0 ? (
|
||||||
<Remove username={user.name} />
|
<Remove username={user.name} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@ -322,7 +317,5 @@ function UserCard({ user, magic }: CardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
return (
|
return <ErrorPopup type="embedded" />;
|
||||||
<ErrorPopup type="embedded" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user