Compare commits

..

19 Commits

Author SHA1 Message Date
Aarnav Tale 2b1e7be193 chore: v0.1.4 (attempt 2) 2024-05-15 22:27:03 -04:00
Aarnav Tale 65a76a7fa2 fix: docker needs the patches dir 2024-05-15 22:26:36 -04:00
Aarnav Tale 3d554a2434 chore: v0.1.4 2024-05-15 22:23:19 -04:00
Aarnav Tale d57ee2677c feat: react 19 to take advantage of compiler 2024-05-15 22:22:38 -04:00
Aarnav Tale 2f02ccc362 style: eslint changes 2024-05-15 21:54:40 -04:00
Aarnav Tale 37a31e30c7 chore: update eslint package 2024-05-15 21:54:34 -04:00
Aarnav Tale e7c6271322 chore: cleanup login page 2024-05-15 21:54:02 -04:00
Aarnav Tale 3deec75715 fix: Promise.all on concurrent promises 2024-05-15 21:53:39 -04:00
Aarnav Tale 18999357a8 fix: return the revalidator in useLiveData 2024-05-15 21:53:12 -04:00
Aarnav Tale e6eba645c4 feat: implement complete user control 2024-05-15 21:52:19 -04:00
Aarnav Tale f563335fab feat: add loading indicator 2024-05-11 23:07:24 -04:00
Aarnav Tale 6102fabfcb fix: add fallback values from the config 2024-05-11 23:07:06 -04:00
Aarnav Tale 87430ccf7b feat: continue moving older components to aria 2024-05-05 23:38:41 -04:00
Aarnav Tale ee42016a67 fix: patch @react-aria/overlays to support scroll gutter 2024-05-04 16:21:35 -04:00
Aarnav Tale 9f5ac6a9ef chore: v0.1.3 2024-05-04 15:07:27 -04:00
Aarnav Tale 7f55ad826c chore: add link to headscale acl faq on acl page 2024-05-04 15:04:55 -04:00
Aarnav Tale 2cc65e8783 chore: do a feature check for scrollbar-gutter-stable 2024-05-04 15:04:40 -04:00
Aarnav Tale 5ff09e44d9 feat: cleanup header and extract to component 2024-05-04 15:04:22 -04:00
Aarnav Tale c6cdcf35eb chore: switch from heroicons to primer icons 2024-05-04 15:03:21 -04:00
47 changed files with 3560 additions and 2298 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "tale"
}
+15
View File
@@ -1,3 +1,18 @@
### 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership.
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
- Implemented some fixes that should stop dialogs from hanging a webpage.
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
- Upgrade other dependencies
### 0.1.3 (May 4, 2024)
- Switched to a better icon set for the UI.
- Support stable scrollbar gutter if supported by the browser.
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
### 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine.
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
+28 -25
View File
@@ -1,40 +1,43 @@
import { ClipboardIcon } from '@heroicons/react/24/outline'
import { CopyIcon } from '@primer/octicons-react'
import { toast } from './Toaster'
type Properties = {
readonly name: string;
readonly value: string;
readonly isCopyable?: boolean;
interface Props {
name: string
value: string
isCopyable?: boolean
}
export default function Attribute({ name, value, isCopyable }: Properties) {
export default function Attribute({ name, value, isCopyable }: Props) {
const canCopy = isCopyable ?? false
return (
<dl className='flex gap-1 text-sm w-full'>
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'>
<dl className="flex gap-1 text-sm w-full">
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
{name}
</dt>
{(canCopy ?? false) ? (
<button
type='button'
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className='min-w-0 truncate px-2 py-1'>
{canCopy
? (
<button
type="button"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
</button>
) : (
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
)}
)}
</dl>
)
}
+29 -19
View File
@@ -1,26 +1,36 @@
import clsx from 'clsx'
import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react'
import { type Dispatch, type SetStateAction } from 'react'
import { Button as AriaButton } from 'react-aria-components'
type Properties = {
readonly variant?: 'emphasized' | 'normal' | 'destructive';
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
import { cn } from '~/utils/cn'
export default function Action(properties: Properties) {
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
}
export default function Button(properties: ButtonProperties) {
return (
<button
type='button'
<AriaButton
{...properties}
className={clsx(
'focus:outline-none focus:ring focus:ring-1',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.className,
properties.disabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'destructive' ? 'text-red-700 dark:text-red-500' : '',
properties.variant === 'emphasized' ? 'rounded-lg px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white' : '',
!properties.variant || properties.variant === 'normal' ? 'text-blue-700 dark:text-blue-400' : ''
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
properties.variant === 'heavy'
? 'bg-main-700 dark:bg-main-800'
: 'bg-main-200 dark:bg-main-700/30',
properties.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
properties.variant === 'heavy'
? 'text-white'
: 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
>
{properties.children}
</button>
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
/>
)
}
+40 -5
View File
@@ -1,14 +1,47 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import { Heading as AriaHeading } from 'react-aria-components'
type Properties = HTMLProps<HTMLDivElement>
import { cn } from '~/utils/cn'
export default function Card(properties: Properties) {
function Title(properties: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/>
)
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
}
type Properties = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat';
}
function Card(properties: Properties) {
return (
<div
{...properties}
className={clsx(
'p-4 md:p-6 border dark:border-zinc-700 rounded-lg',
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700',
properties.className
)}
>
@@ -16,3 +49,5 @@ export default function Card(properties: Properties) {
</div>
)
}
export default Object.assign(Card, { Title, Text })
+1
View File
@@ -23,6 +23,7 @@ function Button(properties: ButtonProperties) {
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
+28 -34
View File
@@ -1,52 +1,46 @@
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { AlertIcon } from '@primer/octicons-react'
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import { useState } from 'react'
import { cn } from '~/utils/cn'
import Card from './Card'
import Code from './Code'
import Dialog from './Dialog'
type Properties = {
readonly type?: 'full' | 'embedded';
}
export function ErrorPopup({ type = 'full' }: Properties) {
// eslint-disable-next-line react/hook-use-state
const open = useState(true)
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
return (
<Dialog>
<Dialog.Panel
className={cn(
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '',
)}
control={open}
>
{() => (
<>
<div className='flex items-center justify-between'>
<Dialog.Title className='text-3xl mb-0'>
{routing ? error.status : 'Error'}
</Dialog.Title>
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
</div>
<Dialog.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Dialog.Text>
</>
)}
</Dialog.Panel>
</Dialog>
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50'
)}
>
<Card>
<div className='flex items-center justify-between'>
<Card.Title className='text-3xl mb-0'>
{routing ? error.status : 'Error'}
</Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/>
</div>
<Card.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Card.Text>
</Card>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
import { Form } from '@remix-run/react'
import { cn } from '~/utils/cn'
import { type Context } from '~/utils/config'
import { type SessionData } from '~/utils/sessions'
import Menu from './Menu'
import TabLink from './TabLink'
type Properties = {
readonly data?: Context & { user?: SessionData['user'] };
}
type LinkProperties = {
readonly href: string;
readonly text: string;
readonly isMenu?: boolean;
}
function Link({ href, text, isMenu }: LinkProperties) {
return (
<a
href={href}
target='_blank'
rel='noreferrer'
className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block'
)}
>
{text}
</a>
)
}
export default function Header({ data }: Properties) {
return (
<header className='bg-main-700 dark:bg-main-800 text-ui-50'>
<div className='container flex items-center justify-between py-4'>
<div className='flex items-center gap-x-2'>
<PaperAirplaneIcon className='w-6 h-6'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<Link href='https://tailscale.com/download' text='Download'/>
<Link href='https://github.com/tale/headplane' text='GitHub'/>
<Link href='https://github.com/juanfont/headscale' text='Headscale'/>
{data?.user ? (
<Menu>
<Menu.Button className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<PersonIcon className='h-5 w-5 mt-0.5'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://tailscale.com/download'
text='Download'
/>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://github.com/tale/headplane'
text='GitHub'
/>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://github.com/juanfont/headscale'
text='Headscale'
/>
</Menu.Item>
<Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'>
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
) : undefined}
</div>
</div>
<nav className='container flex items-center gap-x-4 overflow-x-auto'>
<TabLink to='/machines' name='Machines' icon={<ServerIcon className='w-4 h-4'/>}/>
<TabLink to='/users' name='Users' icon={<PeopleIcon className='w-4 h-4'/>}/>
{data?.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockIcon className='w-4 h-4'/>}/> : undefined}
{data?.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeIcon className='w-4 h-4'/>}/>
<TabLink to='/settings' name='Settings' icon={<GearIcon className='w-4 h-4'/>}/>
</>
) : undefined}
</nav>
</header>
)
}
-26
View File
@@ -1,26 +0,0 @@
import clsx from 'clsx'
import { type DetailedHTMLProps, type InputHTMLAttributes } from 'react'
type Properties = {
readonly variant?: 'embedded' | 'normal';
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
export default function Input(properties: Properties) {
return (
<input
{...properties}
className={clsx(
'block w-full dark:text-gray-300',
'border-gray-300 dark:border-zinc-700',
'focus:outline-none focus:ring',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.variant === 'embedded' ? 'bg-transparent' : 'dark:bg-zinc-800',
properties.variant === 'embedded' ? 'p-0' : 'px-2.5 py-1.5',
properties.variant === 'embedded' ? 'border-none' : 'border',
properties.variant === 'embedded' ? 'focus:ring-0' : 'focus:ring-1',
properties.variant === 'embedded' ? 'rounded-none' : 'rounded-lg',
properties.className
)}
/>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
/* eslint-disable unicorn/no-keyword-prefix */
type Properties = {
readonly to: string;
readonly name: string;
readonly children: string;
readonly className?: string;
}
export default function Link({ to, name: alt, children, className }: Properties) {
return (
<a
href={to}
aria-label={alt}
target='_blank'
rel='noreferrer'
className={cn(
'inline-flex items-center gap-x-1',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
className
)}
>
{children}
<LinkExternalIcon className='h-3 w-3'/>
</a>
)
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import { InfoIcon } from '@primer/octicons-react'
import clsx from 'clsx'
import { type ReactNode } from 'react'
@@ -9,7 +9,7 @@ export default function Notice({ children }: { readonly children: ReactNode }) {
'bg-slate-400 dark:bg-slate-700'
)}
>
<InformationCircleIcon className='h-6 w-6 text-white'/>
<InfoIcon className='h-6 w-6 text-white'/>
{children}
</div>
)
+5 -11
View File
@@ -15,23 +15,17 @@ export default function Switch(properties: SwitchProperties) {
>
<div
className={cn(
'flex h-[26px] w-[44px] shrink-0 cursor-default',
'rounded-full shadow-inner bg-clip-padding',
'border border-solid border-white/30 p-[3px]',
'box-border transition duration-100 ease-in-out',
'outline-none group-focus-visible:ring-2 ring-black',
'bg-main-700 dark:bg-main-800',
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900',
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900',
'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
>
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white shadow transition duration-100',
'ease-in-out translate-x-0 group-selected:translate-x-[100%]'
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]'
)}
/>
</div>
+13 -4
View File
@@ -1,7 +1,8 @@
import { NavLink } from '@remix-run/react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
readonly to: string;
@@ -12,12 +13,20 @@ export default function TabLink({ name, to, icon }: Properties) {
return (
<NavLink
to={to}
className={({ isActive, isPending }) => clsx(
'flex items-center gap-x-2 p-2 border-b-2 text-md',
prefetch='intent'
className={({ isActive }) => cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent'
)}
>
{icon} {name}
<div
className={cn(
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
)}
>
{icon} {name}
</div>
</NavLink>
)
}
+3 -3
View File
@@ -9,7 +9,7 @@ import { cn } from '~/utils/cn'
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state: [string, Dispatch<SetStateAction<string>>];
readonly state?: [string, Dispatch<SetStateAction<string>>];
}
export default function TextField(properties: TextFieldProperties) {
@@ -21,7 +21,7 @@ export default function TextField(properties: TextFieldProperties) {
>
<Input
placeholder={properties.placeholder}
value={properties.state[0]}
value={properties.state?.[0]}
name={properties.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
@@ -30,7 +30,7 @@ export default function TextField(properties: TextFieldProperties) {
properties.className
)}
onChange={event => {
properties.state[1](event.target.value)
properties.state?.[1](event.target.value)
}}
/>
</AriaTextField>
+2 -2
View File
@@ -1,4 +1,4 @@
import { XMarkIcon } from '@heroicons/react/24/outline'
import { XIcon } from '@primer/octicons-react'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
@@ -35,7 +35,7 @@ function Toast({ state, ...properties }: ToastProperties) {
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<XMarkIcon className='w-4 h-4'/>
<XIcon className='w-4 h-4'/>
</Button>
</div>
)
+1 -1
View File
@@ -46,7 +46,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Meta/>
<Links/>
</head>
<body className='overscroll-none dark:bg-zinc-900 dark:text-white'>
<body className='overscroll-none dark:bg-ui-950 dark:text-ui-50'>
{children}
<Toaster/>
<ScrollRestoration/>
+6 -12
View File
@@ -87,9 +87,9 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
</div>
<Button
variant='emphasized'
className='text-sm w-fit mr-2'
onClick={() => {
variant='heavy'
className='mr-2'
onPress={() => {
fetcher.submit({
acl
}, {
@@ -105,15 +105,9 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
)}
Save
</Button>
<Button
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
onClick={() => {
setAcl(data.currentAcl)
}}
<Button onPress={() => {
setAcl(data.currentAcl)
}}
>
Discard Changes
</Button>
+3 -11
View File
@@ -28,20 +28,12 @@ export default function Fallback({ acl, where }: FallbackProperties) {
{where === 'server' ? (
<>
<Button
disabled
variant='emphasized'
className='text-sm w-fit mr-2'
variant='heavy'
className='mr-2'
>
Save
</Button>
<Button
disabled
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
>
<Button>
Discard Changes
</Button>
</>
+20 -11
View File
@@ -1,10 +1,11 @@
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config'
@@ -74,16 +75,24 @@ export default function Page() {
<p className='mb-4 max-w-prose'>
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the Tailscale documentation.
You can find more information about the ACL file in the
{' '}
<a
target='_blank'
rel='noreferrer'
href='https://tailscale.com/kb/1018/acls'
className='text-blue-500 dark:text-blue-400 hover:underline'
<Link
to='https://tailscale.com/kb/1018/acls'
name='Tailscale ACL documentation'
>
More information
</a>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to='https://headscale.net/acls'
name='Headscale ACL documentation'
>
Headscale docs
</Link>
.
</p>
<Tabs>
@@ -102,7 +111,7 @@ export default function Page() {
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<PencilSquareIcon className='w-5 h-5'/>
<PencilIcon className='w-5 h-5'/>
<p>Edit file</p>
</Tab>
<Tab
@@ -152,7 +161,7 @@ export default function Page() {
'p-16 flex flex-col items-center justify-center'
)}
>
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='w-1/2 text-center mt-4'>
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
+23 -16
View File
@@ -15,15 +15,14 @@ import {
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import Button from '~/components/Button'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
type Properties = {
readonly baseDomain?: string;
@@ -83,7 +82,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
{baseDomain ? (
<TableList.Item key='magic-dns-sd'>
<p className='font-mono text-sm'>{baseDomain}</p>
<LockClosedIcon className='h-4 w-4'/>
<LockIcon className='h-4 w-4'/>
</TableList.Item>
) : undefined}
<SortableContext
@@ -115,9 +114,8 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
className='font-mono text-sm bg-transparent w-full mr-2'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
@@ -126,9 +124,14 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
className={cn(
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
newDomain.length === 0 && 'opacity-50 cursor-not-allowed'
)}
isDisabled={newDomain.length === 0}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
@@ -177,7 +180,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
return (
<div
ref={setNodeRef}
className={clsx(
className={cn(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '',
@@ -190,7 +193,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
>
<p className='font-mono text-sm flex items-center gap-4'>
{disabled ? undefined : (
<Bars3Icon
<ThreeBarsIcon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
@@ -200,10 +203,14 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
</p>
{isDrag ? undefined : (
<Button
variant='destructive'
className='text-sm'
disabled={disabled}
onClick={() => {
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed'
)}
isDisabled={disabled}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
+1 -9
View File
@@ -2,7 +2,6 @@ import { useFetcher } from '@remix-run/react'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
type Properties = {
readonly isEnabled: boolean;
@@ -15,14 +14,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
return (
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
+8 -10
View File
@@ -2,10 +2,10 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import { Input } from 'react-aria-components'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
@@ -35,7 +35,12 @@ export default function Modal({ name, disabled }: Properties) {
</p>
<Input
readOnly
className='font-mono text-sm my-4 w-1/2'
className={cn(
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none'
)}
type='text'
value={name}
onFocus={event => {
@@ -43,14 +48,7 @@ export default function Modal({ name, disabled }: Properties) {
}}
/>
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
+26 -18
View File
@@ -1,14 +1,14 @@
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Button, Input } from 'react-aria-components'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
@@ -29,13 +29,13 @@ export async function loader() {
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
magicDns: config.dns_config.magic_dns ?? false,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records
overrideLocal: config.dns_config.override_local_dns ?? false,
nameservers: config.dns_config.nameservers ?? [],
splitDns: config.dns_config.restricted_nameservers ?? {},
searchDomains: config.dns_config.domains ?? [],
extraRecords: config.dns_config.extra_records ?? []
}
return {
@@ -119,10 +119,14 @@ export default function Page() {
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<Button
variant='destructive'
className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => {
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
!data.hasConfigWrite && 'opacity-50 cursor-not-allowed'
)}
isDisabled={!data.hasConfigWrite}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
@@ -139,9 +143,8 @@ export default function Page() {
{data.hasConfigWrite ? (
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
className='font-mono text-sm bg-transparent w-full mr-2'
placeholder='Nameserver'
value={ns}
onChange={event => {
@@ -150,9 +153,14 @@ export default function Page() {
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
className={cn(
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
ns.length === 0 && 'opacity-50 cursor-not-allowed'
)}
isDisabled={ns.length === 0}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
@@ -185,7 +193,7 @@ export default function Page() {
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'>
Automaticall register domain names for each device
Automatically register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Page() {
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card>
<Card variant='flat'>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
+4 -4
View File
@@ -1,5 +1,5 @@
/* eslint-disable react/hook-use-state */
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
@@ -96,7 +96,7 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
}}
>
{ip}
<ClipboardIcon className='w-3 h-3'/>
<CopyIcon className='w-3 h-3'/>
</Menu.ItemButton>
))}
{magic ? (
@@ -113,7 +113,7 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<ClipboardIcon className='w-3 h-3'/>
<CopyIcon className='w-3 h-3'/>
</Menu.ItemButton>
) : undefined}
</Menu.Items>
@@ -173,7 +173,7 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<EllipsisHorizontalIcon className='w-5'/>
<KebabHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
+78 -75
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
@@ -16,12 +16,14 @@ import MachineRow from './machine'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, routes] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
])
const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
const context = await getContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (config.dns_config.magic_dns) {
@@ -32,7 +34,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
nodes: machines.nodes,
routes: routes.routes,
magic
magic,
}
}
@@ -40,14 +42,14 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400
status: 400,
})
}
@@ -55,51 +57,49 @@ export async function action({ request }: ActionFunctionArgs) {
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
console.log('expire')
const data = await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
console.log(data)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400
})
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400
})
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
const name = String(data.get('name'))
default: {
return json({ message: 'Invalid method' }, {
status: 400
})
}
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -110,42 +110,45 @@ export default function Page() {
return (
<>
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-xs font-bold px-0.5'>
<th className='pb-2'>Name</th>
<th className='pb-2'>
<div className='flex items-center gap-x-1'>
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
<tr className="text-left uppercase text-xs font-bold px-0.5">
<th className="pb-2">Name</th>
<th className="pb-2">
<div className="flex items-center gap-x-1">
Addresses
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
</Button>
<Tooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700'
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].[user].{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
) : undefined}
{data.magic
? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700',
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].[user].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
)
: undefined}
</div>
</th>
<th className='pb-2'>Last Seen</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
'border-t border-zinc-200 dark:border-zinc-700',
)}
>
{data.nodes.map(machine => (
+3 -3
View File
@@ -1,9 +1,9 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
import { IssueDraftIcon } from '@primer/octicons-react'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
+19 -72
View File
@@ -1,10 +1,10 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData } from '@remix-run/react'
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Menu from '~/components/Menu'
import TabLink from '~/components/TabLink'
import Header from '~/components/Header'
import { cn } from '~/utils/cn'
import { getContext } from '~/utils/config'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
@@ -20,7 +20,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
console.error(error)
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
@@ -43,61 +42,23 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function Layout() {
const data = useLoaderData<typeof loader>()
const nav = useNavigation()
return (
<>
<header className='mb-6 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center justify-between mb-8 pt-4'>
<div className='flex items-center gap-x-2'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Download
</a>
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
GitHub
</a>
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<Menu>
<Menu.Button>
<UserCircleIcon className='w-8 h-8'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Menu.Item>
<Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'>
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
{data.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
</div>
</nav>
</header>
<main className='container mx-auto overscroll-contain mb-24'>
<ProgressBar
aria-label='Loading...'
>
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100'
)}
/>
</ProgressBar>
<Header data={data}/>
<main className='container mx-auto overscroll-contain mt-4 mb-24'>
<Outlet/>
</main>
</>
@@ -107,21 +68,7 @@ export default function Layout() {
export function ErrorBoundary() {
return (
<>
<header className='mb-16 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
<TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</div>
</nav>
</header>
<Header/>
<ErrorPopup type='embedded'/>
</>
)
-67
View File
@@ -1,67 +0,0 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { toast } from 'react-hot-toast/headless'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, type User } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const users = new Map<string, Machine[]>()
for (const machine of data.nodes) {
const { user } = machine
if (!users.has(user.id)) {
users.set(user.id, [])
}
users.get(user.id)?.push(machine)
}
return [...users.values()].map(machines => {
const { user } = machines[0]
return {
...user,
machines
}
})
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
return (
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
{data.map(user => (
<Card key={user.id}>
<div className='flex items-center gap-4'>
<UserIcon className='w-6 h-6'/>
<span className='text-lg font-mono'>
{user.name}
</span>
</div>
<div className='py-4'>
{user.machines.map(machine => (
<div key={machine.id} className='flex items-center w-full gap-4'>
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
</div>
))}
</div>
</Card>
))}
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
interface Props {
magic?: string
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('')
const submit = useSubmit()
return (
<Dialog>
<Dialog.Button>
Add a new user
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add a new user
</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user.
{' '}
{magic
? (
<>
Since Magic DNS is enabled, machines will be
accessible via
{' '}
<Code>
[machine].
{username.length > 0 ? username : '[username]'}
.
{magic}
</Code>
.
</>
)
: undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="create" />
<TextField
label="Username"
placeholder="my-new-user"
name="username"
state={[username, setUsername]}
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}
>
Create
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
import Card from '~/components/Card'
import Link from '~/components/Link'
import Add from './add'
interface Props {
readonly magic: string | undefined
}
export default function Auth({ magic }: Props) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<HomeIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
Basic Authentication
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are not managed externally.
Using OpenID Connect can create a better
experience when using Headscale.
{' '}
<Link
to="https://headscale.net/oidc"
name="Headscale OIDC Documentation"
>
Learn more
</Link>
</p>
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can add, remove, and rename users here.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
</div>
</div>
</div>
</Card>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
import Card from '~/components/Card'
import Link from '~/components/Link'
import { type Context } from '~/utils/config'
import Add from './add'
interface Props {
readonly oidc: NonNullable<Context['oidcConfig']>
readonly magic: string | undefined
}
export default function Oidc({ oidc, magic }: Props) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<OrganizationIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
OpenID Connect
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are managed through your
{' '}
<Link to={oidc.issuer} name="OIDC Provider">
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.
{' '}
<Link
to="https://headscale.net/oidc"
name="Headscale OIDC Documentation"
>
Learn more
</Link>
</p>
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can still add users manually, however it is recommended
that you manage users through your OIDC provider.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
</div>
</div>
</div>
</Card>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
interface Props {
username: string
magic?: string
}
export default function Remove({ username, magic }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
return (
<>
<Button
variant="light"
control={dialogState}
className="rounded-full p-0 w-8 h-8"
>
<XIcon className="w-4 h-4" />
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
<>
<Dialog.Title>
Delete
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8">
Are you sure you want to delete
{' '}
{username}
?
{' '}
A deleted user cannot be recovered.
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will become orphaned and inaccessible.
</p>
)
: undefined}
</Dialog.Text>
<Form
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>
</>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { PencilIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
interface Props {
username: string
magic?: string
}
export default function Rename({ username, magic }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const [newName, setNewName] = useState('')
return (
<>
<Button
variant="light"
control={dialogState}
className="rounded-full p-0 w-8 h-8"
>
<PencilIcon className="w-4 h-4" />
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
<>
<Dialog.Title>
Rename
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a new username for
{' '}
{username}
?
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will now become accessible via
{' '}
<Code>
[machine].
{newName.length > 0 ? newName : '[new-username]'}
.
{magic}
</Code>
.
</p>
)
: undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="old" value={username} />
<TextField
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>
</>
)
}
+356
View File
@@ -0,0 +1,356 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type User } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Auth from './auth'
import Oidc from './oidc'
import Remove from './remove'
import Rename from './rename'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
const users = apiUsers.users.map(user => ({
...user,
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
const context = await getContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
return {
oidcConfig: context.oidcConfig,
magic,
users,
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method')) {
return json({ message: 'No method provided' }, {
status: 400,
})
}
const method = String(data.get('_method'))
switch (method) {
case 'create': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const username = String(data.get('username'))
await post('v1/user', session.get('hsApiKey')!, {
name: username,
})
return json({ message: `User ${username} created` })
}
case 'delete': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!)
return json({ message: `User ${username} deleted` })
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
return json({ message: 'No old or new name provided' }, {
status: 400,
})
}
const old = String(data.get('old'))
const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return json({ message: `User ${old} renamed to ${newName}` })
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
return json({ message: 'No ID or destination provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const to = String(data.get('to'))
const name = String(data.get('name'))
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved ${name} to ${to}` })
} catch {
return json({ message: `Failed to move ${name} to ${to}` }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [users, setUsers] = useState(data.users)
const actionData = useActionData<typeof action>()
useLiveData({ interval: 3000 })
useEffect(() => {
if (!actionData) {
return
}
toast(actionData.message)
if (actionData.message.startsWith('Failed')) {
setUsers(data.users)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData])
useEffect(() => {
setUsers(data.users)
}, [data.users])
return (
<>
<h1 className="text-2xl font-medium mb-1.5">
Users
</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
</p>
{data.oidcConfig
? (
<Oidc
oidc={data.oidcConfig}
magic={data.magic}
/>
)
: (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
// @ts-expect-error: JsonifyObject is weird
<Users users={users} />
}
>
{() => (
<InteractiveUsers
// @ts-expect-error: JsonifyObject is weird
users={users}
// @ts-expect-error: JsonifyObject is weird
setUsers={setUsers}
magic={data.magic}
/>
)}
</ClientOnly>
</>
)
}
type UserMachine = User & { machines: Machine[] }
interface UserProps {
users: UserMachine[]
setUsers?: (users: UserMachine[]) => void
magic?: string
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
<UserCard
key={user.id}
user={user}
isFirst={i === 0}
magic={magic}
/>
))}
</div>
)
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit()
return (
<DndContext onDragEnd={(event) => {
const { over, active } = event
if (!over) {
return
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>()
const reference = active.data as DataRef<Machine>
if (!reference.current) {
return
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return
}
for (const user of users) {
newUsers.push({
...user,
machines: over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter(m => m.id !== active.id),
})
}
setUsers?.(newUsers)
const data = new FormData()
data.append('_method', 'move')
data.append('id', active.id.toString())
data.append('to', over.id.toString())
data.append('name', reference.current.givenName)
submit(data, {
method: 'POST',
})
}}
>
<div className="grid grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
<UserCard
key={user.id}
user={user}
isFirst={i === 0}
magic={magic}
/>
))}
</div>
</DndContext>
)
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
})
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center w-full gap-2 py-1',
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
)}
style={{
transform: transform
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
: undefined,
}}
{...listeners}
{...attributes}
>
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
<Attribute
name={machine.givenName}
value={machine.ipAddresses[0]}
/>
</div>
)
}
interface CardProps {
user: UserMachine
isFirst: boolean
magic?: string
}
function UserCard({ user, isFirst, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
return (
<div ref={setNodeRef}>
<Card
variant="flat"
className={cn(
'max-w-full w-full overflow-visible h-full',
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">
{user.name}
</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{isFirst
? undefined
: (
<Remove username={user.name} magic={magic} />
)}
</div>
</div>
<div className="mt-4">
{user.machines.map(machine => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
)
}
+71 -64
View File
@@ -5,7 +5,7 @@ import { useMemo } from 'react'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import Input from '~/components/Input'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { pull } from '~/utils/headscale'
@@ -18,8 +18,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
'Set-Cookie': await commitSession(session),
},
})
}
@@ -35,7 +35,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const data = {
oidc: issuer,
apiKey: normal === undefined
apiKey: normal === undefined,
}
if (!data.oidc && !data.apiKey) {
@@ -82,21 +82,21 @@ export async function action({ request }: ActionFunctionArgs) {
session.set('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays} days`
email: `${expiresDays.toString()} days`,
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn
})
}
maxAge: expiresIn,
}),
},
})
} catch (error) {
console.error(error)
return json({
error: 'Invalid API key'
error: 'Invalid API key',
})
}
}
@@ -107,62 +107,69 @@ export default function Page() {
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className='flex min-h-screen items-center justify-center'>
<Card className='w-96'>
<h1 className='text-2xl mb-8'>Login</h1>
{data.apiKey ? (
<Form method='post'>
<p className='text-sm text-gray-500 mb-4'>
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</p>
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title>
Welcome to Headplane
</Card.Title>
{data.apiKey
? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</Card.Text>
{actionData?.error ? (
<p className='text-red-500 text-sm mb-2'>{actionData.error}</p>
) : undefined}
<Input
required
type='text'
name='api-key'
id='api-key'
className='border rounded-md p-2 w-full'
placeholder='API Key'
/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full mt-4'
>
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className='flex items-center gap-x-2 py-2'>
<hr className='flex-1 dark:border-zinc-700'/>
<span className='text-gray-500'>or</span>
<hr className='flex-1 dark:border-zinc-700'/>
</div>
) : undefined}
{data.oidc ? (
<Form method='POST'>
<input type='hidden' name='oidc-start' value='true'/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full'
>
Login with SSO
</Button>
</Form>
) : undefined}
{actionData?.error
? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
)
: undefined}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
/>
<Button
className="w-full mt-2.5"
variant="heavy"
type="submit"
>
Login
</Button>
</Form>
)
: undefined}
{showOr
? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
)
: undefined}
{data.oidc
? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full"
variant="heavy"
type="submit"
>
Login with SSO
</Button>
</Form>
)
: undefined}
</Card>
</div>
)
+6
View File
@@ -1,3 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable
}
}
+98 -100
View File
@@ -1,122 +1,121 @@
import { type Document, parse, parseDocument } from 'yaml'
import { type FSWatcher, watch } from 'node:fs'
import { access, constants, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { type Document, parse, parseDocument } from 'yaml'
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
type Config = {
server_url: string;
listen_addr: string;
metrics_listen_addr: string;
grpc_listen_addr: string;
grpc_allow_insecure: boolean;
interface Config {
server_url: string
listen_addr: string
metrics_listen_addr: string
grpc_listen_addr: string
grpc_allow_insecure: boolean
private_key_path: string;
private_key_path: string
noise: {
private_key_path: string;
};
private_key_path: string
}
prefixes: {
v4: string;
v6: string;
};
v4: string
v6: string
}
derp: {
server: {
enabled: boolean;
region_id: number;
region_code: string;
region_name: string;
stun_listen_addr: string;
};
enabled: boolean
region_id: number
region_code: string
region_name: string
stun_listen_addr: string
}
urls: string[];
paths: string[];
auto_update_enabled: boolean;
update_frequency: Duration;
};
urls: string[]
paths: string[]
auto_update_enabled: boolean
update_frequency: Duration
}
disable_check_updates: boolean;
epheremal_node_inactivity_timeout: Duration;
node_update_check_interval: Duration;
disable_check_updates: boolean
epheremal_node_inactivity_timeout: Duration
node_update_check_interval: Duration
// Database is probably dangerous
database: {
type: 'sqlite3' | 'sqlite' | 'postgres';
type: 'sqlite3' | 'sqlite' | 'postgres'
sqlite?: {
path: string;
};
path: string
}
postgres?: {
host: string;
port: number;
name: string;
user: string;
pass: string;
max_open_conns: number;
max_idle_conns: number;
conn_max_idle_time_secs: number;
ssl: boolean;
};
};
host: string
port: number
name: string
user: string
pass: string
max_open_conns: number
max_idle_conns: number
conn_max_idle_time_secs: number
ssl: boolean
}
}
acme_url: string;
acme_email: string;
tls_letsencrypt_hostname: string;
tls_letsencrypt_cache_dir: string;
tls_letsencrypt_challenge_type: string;
tls_letsencrypt_listen: string;
tls_cert_path: string;
tls_key_path: string;
acme_url: string
acme_email: string
tls_letsencrypt_hostname: string
tls_letsencrypt_cache_dir: string
tls_letsencrypt_challenge_type: string
tls_letsencrypt_listen: string
tls_cert_path: string
tls_key_path: string
log: {
format: 'text' | 'json';
level: string;
};
format: 'text' | 'json'
level: string
}
acl_policy_path: string;
acl_policy_path: string
dns_config: {
override_local_dns: boolean;
nameservers: string[];
restricted_nameservers: Record<string, string[]>; // Split DNS
domains: string[];
extra_records: Array<{
name: string;
type: 'A';
value: string;
}>;
override_local_dns: boolean
nameservers: string[]
restricted_nameservers: Record<string, string[]> // Split DNS
domains: string[]
extra_records: {
name: string
type: 'A'
value: string
}[]
magic_dns: boolean;
base_domain: string;
};
magic_dns: boolean
base_domain: string
}
unix_socket: string;
unix_socket_permission: string;
unix_socket: string
unix_socket_permission: string
oidc: {
only_start_if_oidc_is_available: boolean;
issuer: string;
client_id: string;
client_secret: string;
expiry: Duration;
use_expiry_from_token: boolean;
scope: string[];
extra_params: Record<string, string>;
only_start_if_oidc_is_available: boolean
issuer: string
client_id: string
client_secret: string
expiry: Duration
use_expiry_from_token: boolean
scope: string[]
extra_params: Record<string, string>
allowed_domains: string[];
allowed_groups: string[];
allowed_users: string[];
allowed_domains: string[]
allowed_groups: string[]
allowed_users: string[]
strip_email_domain: boolean;
};
strip_email_domain: boolean
}
logtail: {
enabled: boolean;
};
enabled: boolean
}
randomize_client_port: boolean;
randomize_client_port: boolean
}
let config: Document
@@ -196,18 +195,18 @@ export function registerConfigWatcher() {
})
}
type Context = {
hasDockerSock: boolean;
hasConfig: boolean;
hasConfigWrite: boolean;
hasAcl: boolean;
hasAclWrite: boolean;
headscaleUrl: string;
export interface Context {
hasDockerSock: boolean
hasConfig: boolean
hasConfigWrite: boolean
hasAcl: boolean
hasAclWrite: boolean
headscaleUrl: string
oidcConfig?: {
issuer: string;
client: string;
secret: string;
};
issuer: string
client: string
secret: string
}
}
export let context: Context
@@ -221,7 +220,7 @@ export async function getContext() {
hasAcl: await hasAcl(),
hasAclWrite: await hasAclW(),
headscaleUrl: await getHeadscaleUrl(),
oidcConfig: await getOidcConfig()
oidcConfig: await getOidcConfig(),
}
}
@@ -237,9 +236,9 @@ async function getOidcConfig() {
if (!issuer || !client || !secret) {
const config = await getConfig()
issuer = config.oidc?.issuer
client = config.oidc?.client_id
secret = config.oidc?.client_secret
issuer = config.oidc.issuer
client = config.oidc.client_id
secret = config.oidc.client_secret
}
// If atleast one is defined but not all 3, throw an error
@@ -352,4 +351,3 @@ async function hasAclW() {
return false
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
type SessionData = {
export type SessionData = {
hsApiKey: string;
authState: string;
authNonce: string;
+4 -3
View File
@@ -2,11 +2,11 @@ import { useRevalidator } from '@remix-run/react'
import { useEffect } from 'react'
import { useInterval } from 'usehooks-ts'
type Properties = {
interval: number;
interface Props {
interval: number
}
export function useLiveData({ interval }: Properties) {
export function useLiveData({ interval }: Props) {
const revalidator = useRevalidator()
// Handle normal stale-while-revalidate behavior
@@ -31,4 +31,5 @@ export function useLiveData({ interval }: Properties) {
document.removeEventListener('focus', handler)
}
}, [revalidator])
return revalidator
}
+5
View File
@@ -0,0 +1,5 @@
import tale from 'eslint-config-tale'
export default [
...tale
]
+38 -27
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
},
@@ -17,43 +17,54 @@
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@heroicons/react": "^2.1.3",
"@react-aria/toast": "3.0.0-beta.10",
"@react-stately/toast": "3.0.0-beta.2",
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
"@remix-run/serve": "^2.8.1",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/react-codemirror": "^4.21.25",
"clsx": "^2.1.0",
"isbot": "^4.1.0",
"oauth4webapi": "^2.10.3",
"react": "^18.2.0",
"react-aria-components": "^1.1.1",
"react-codemirror-merge": "^4.21.25",
"react-dom": "^18.2.0",
"@primer/octicons-react": "^19.9.0",
"@react-aria/toast": "3.0.0-beta.11",
"@react-stately/toast": "3.0.0-beta.3",
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"@remix-run/serve": "^2.9.2",
"@uiw/codemirror-theme-github": "^4.22.0",
"@uiw/react-codemirror": "^4.22.0",
"clsx": "^2.1.1",
"isbot": "^5.1.6",
"oauth4webapi": "^2.10.4",
"react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.0",
"react-codemirror-merge": "^4.22.0",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.1",
"undici": "^6.10.2",
"usehooks-ts": "^3.0.2",
"yaml": "^2.4.1"
"tailwindcss-react-aria-components": "^1.1.2",
"undici": "^6.16.1",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@remix-run/dev": "^2.8.1",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@remix-run/dev": "^2.9.2",
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta",
"autoprefixer": "^10.4.19",
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
"eslint": "^8.57.0",
"eslint-config-tale": "^1.0.16",
"eslint-config-tale": "^2.0.4",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1",
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.1.6",
"vite": "^5.1.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.2.1"
},
"overrides": {
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta"
},
"engines": {
"node": ">=18.0.0"
},
"pnpm": {
"patchedDependencies": {
"@react-aria/overlays@3.22.0": "patches/@react-aria__overlays@3.22.0.patch"
}
}
}
@@ -0,0 +1,22 @@
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132fe53284c 100644
--- a/dist/usePreventScroll.mjs
+++ b/dist/usePreventScroll.mjs
@@ -48,7 +48,7 @@ function $49c51c25361d4cd2$export$ee0f7cc6afcd1c18(options = {}) {
// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
// add some padding to prevent the page from shifting when the scrollbar is hidden.
function $49c51c25361d4cd2$var$preventScrollStandard() {
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
}
// Mobile Safari is a whole different beast. Even with overflow: hidden,
// it still scrolls the page in many situations:
@@ -161,7 +161,7 @@ function $49c51c25361d4cd2$var$preventScrollMobileSafari() {
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
window.scrollTo(scrollX, scrollY);
});
// Scroll to the top. The negative margin on the body will make this appear the same.
+2031 -1644
View File
File diff suppressed because it is too large Load Diff
+24 -12
View File
@@ -10,23 +10,35 @@ export default {
container: {
center: true,
padding: {
DEFAULT: '1rem',
sm: '2rem',
lg: '4rem',
xl: '5rem',
'2xl': '6rem'
}
'DEFAULT': '1rem',
'sm': '2rem',
'lg': '4rem',
'xl': '5rem',
'2xl': '6rem',
},
},
extend: {
height: {
editor: 'calc(100vh - 20rem)'
editor: 'calc(100vh - 20rem)',
},
colors: {
main: colors.slate,
ui: colors.neutral
}
}
ui: colors.neutral,
},
keyframes: {
loader: {
from: {
transform: 'translateX(-100%)',
},
to: {
transform: 'translateX(100%)',
},
},
},
animation: {
loading: 'loader 0.8s infinite ease-in-out',
},
},
},
plugins: [animate, aria]
plugins: [animate, aria],
} satisfies Config
+18 -3
View File
@@ -1,13 +1,28 @@
import { vitePlugin as remix } from '@remix-run/dev'
import { installGlobals } from '@remix-run/node'
import { defineConfig } from 'vite'
import babel from 'vite-plugin-babel'
import tsconfigPaths from 'vite-tsconfig-paths'
installGlobals()
const ReactCompilerConfig = {}
export default defineConfig({
base: '/admin/',
plugins: [remix({
basename: '/admin/'
}), tsconfigPaths()]
plugins: [
remix({
basename: '/admin/',
}),
tsconfigPaths(),
babel({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'], // if you use TypeScript
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
],
},
}),
],
})