Compare commits

...

22 Commits

Author SHA1 Message Date
Aarnav Tale 69cc6985b4 chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale f9b38939ba chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale 4cfa1e5209 chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale e713dae91b fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale c9bcc1d7c6 chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale 401731fd09 fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale f623e7bc66 feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale 1af292a5b0 fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale 21778a43f1 feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale 33762e53b5 chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale 8867cca494 chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
Aarnav Tale 41b1d3c847 chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale 712fc28683 feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale 320dab1d4f fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale da0ee1382b feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale a7d127c7bf feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale 9d9cbd8e0e chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale feb8b8bba5 chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale c304effcdf fix: externalize process.env 2024-11-08 12:03:08 -05:00
31 changed files with 2686 additions and 2160 deletions
+19
View File
@@ -1,3 +1,22 @@
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024) ### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page. - Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server. - Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
+1
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm RUN npm install -g pnpm
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
COPY patches ./patches COPY patches ./patches
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
+40 -7
View File
@@ -1,12 +1,45 @@
import clsx from 'clsx' import { useState, HTMLProps } from 'react'
import { type HTMLProps } from 'react' import { CopyIcon, CheckIcon } from '@primer/octicons-react'
type Properties = HTMLProps<HTMLSpanElement> import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false)
export default function Code(properties: Properties) {
return ( return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}> <>
{properties.children} <code className={cn(
</code> 'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className
)}>
{props.children}
</code>
{props.isCopyable && (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center'
)}
onClick={() => {
navigator.clipboard.writeText(props.children.join(''))
toast('Copied to clipboard')
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
}}
>
{isCopied ?
<CheckIcon className="h-3 w-3" /> :
<CopyIcon className="h-3 w-3" />
}
</button>
)}
</>
) )
} }
+2 -2
View File
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
className={cn( className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]', 'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm', 'bg-ui-100 dark:bg-ui-800 shadow-sm',
'overflow-hidden z-50', 'z-50 overflow-y-auto',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95', 'entering:fade-in entering:zoom-in-95',
@@ -54,7 +54,7 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right', 'fill-mode-forwards origin-left-right',
)} )}
> >
<ListBox> <ListBox orientation="vertical">
{props.children} {props.children}
</ListBox> </ListBox>
</Popover> </Popover>
+2 -2
View File
@@ -10,6 +10,7 @@ import { loadContext } from './utils/config/headplane'
await loadContext() await loadContext()
export const streamTimeout = 5000
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
@@ -27,7 +28,6 @@ export default function handleRequest(
<RemixServer <RemixServer
context={remixContext} context={remixContext}
url={request.url} url={request.url}
abortDelay={5000}
/>, />,
{ {
[isBot ? 'onAllReady' : 'onShellReady']() { [isBot ? 'onAllReady' : 'onShellReady']() {
@@ -57,6 +57,6 @@ export default function handleRequest(
}, },
) )
setTimeout(abort, 5000) setTimeout(abort, streamTimeout + 1000)
}) })
} }
+3
View File
@@ -0,0 +1,3 @@
import { flatRoutes } from '@remix-run/fs-routes'
export default flatRoutes()
+6 -7
View File
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, useRevalidator } from '@remix-run/react' import { useLoaderData, useRevalidator } from '@remix-run/react'
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher' import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
@@ -18,6 +18,7 @@ import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale'
import { HeadscaleError, pull, put } from '~/utils/headscale' import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { send } from '~/utils/res'
import log from '~/utils/log' import log from '~/utils/log'
import { Editor, Differ } from './cm.client' import { Editor, Differ } from './cm.client'
@@ -116,9 +117,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ success: false, error: null }, { return send({ success: false, error: null }, 401)
status: 401,
})
} }
try { try {
@@ -131,18 +130,18 @@ export async function action({ request }: ActionFunctionArgs) {
} }
) )
return json({ success: true, policy, error: null }) return { success: true, policy, error: null }
} catch (error) { } catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error) log.debug('APIC', 'Failed to update ACL policy with error %s', error)
// @ts-ignore: Shut UP we know it's a string most of the time // @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message) const text = JSON.parse(error.message)
return json({ success: false, error: text.message }, { return send({ success: false, error: text.message }, {
status: error instanceof HeadscaleError ? error.status : 500, status: error instanceof HeadscaleError ? error.status : 500,
}) })
} }
return json({ success: true, error: null }) return { success: true, error: null }
} }
export default function Page() { export default function Page() {
+3 -7
View File
@@ -43,16 +43,12 @@ export async function loader() {
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ success: false }, { return send({ success: false }, 401)
status: 401,
})
} }
const context = await loadContext() const context = await loadContext()
if (!context.config.write) { if (!context.config.write) {
return json({ success: false }, { return send({ success: false }, 403)
status: 403,
})
} }
const data = await request.json() as Record<string, unknown> const data = await request.json() as Record<string, unknown>
@@ -62,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
await context.integration.onConfigChange(context.integration.context) await context.integration.onConfigChange(context.integration.context)
} }
return json({ success: true }) return { success: true }
} }
export default function Page() { export default function Page() {
+23 -24
View File
@@ -1,21 +1,20 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { ActionFunctionArgs } from '@remix-run/node'
import { ActionFunctionArgs, json } from '@remix-run/node'
import { del, post } from '~/utils/headscale' import { del, post } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { send } from '~/utils/res'
import log from '~/utils/log' import log from '~/utils/log'
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'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, { return send({ message: 'Unauthorized' }, {
status: 401, status: 401,
}) })
} }
const data = await request.formData() const data = await request.formData()
if (!data.has('_method') || !data.has('id')) { if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, { return send({ message: 'No method or ID provided' }, {
status: 400, status: 400,
}) })
} }
@@ -26,17 +25,17 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
switch (method) { switch (method) {
case 'delete': { case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!) await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' }) return { message: 'Machine removed' }
} }
case 'expire': { case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!) await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' }) return { message: 'Machine expired' }
} }
case 'rename': { case 'rename': {
if (!data.has('name')) { if (!data.has('name')) {
return json({ message: 'No name provided' }, { return send({ message: 'No name provided' }, {
status: 400, status: 400,
}) })
} }
@@ -44,12 +43,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const name = String(data.get('name')) const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!) await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' }) return { message: 'Machine renamed' }
} }
case 'routes': { case 'routes': {
if (!data.has('route') || !data.has('enabled')) { if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, { return send({ message: 'No route or enabled provided' }, {
status: 400, status: 400,
}) })
} }
@@ -59,12 +58,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const postfix = enabled ? 'enable' : 'disable' const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!) await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' }) return { message: 'Route updated' }
} }
case 'exit-node': { case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) { if (!data.has('routes') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, { return send({ message: 'No route or enabled provided' }, {
status: 400, status: 400,
}) })
} }
@@ -77,12 +76,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!) await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
})) }))
return json({ message: 'Exit node updated' }) return { message: 'Exit node updated' }
} }
case 'move': { case 'move': {
if (!data.has('to')) { if (!data.has('to')) {
return json({ message: 'No destination provided' }, { return send({ message: 'No destination provided' }, {
status: 400, status: 400,
}) })
} }
@@ -91,9 +90,9 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
try { try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!) await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved node ${id} to ${to}` }) return { message: `Moved node ${id} to ${to}` }
} catch { } catch {
return json({ message: `Failed to move node ${id} to ${to}` }, { return send({ message: `Failed to move node ${id} to ${to}` }, {
status: 500, status: 500,
}) })
} }
@@ -110,10 +109,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
tags, tags,
}) })
return json({ message: 'Tags updated' }) return { message: 'Tags updated' }
} catch (error) { } catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error) log.debug('APIC', 'Failed to update tags: %s', error)
return json({ message: 'Failed to update tags' }, { return send({ message: 'Failed to update tags' }, {
status: 500, status: 500,
}) })
} }
@@ -124,13 +123,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const user = data.get('user')?.toString() const user = data.get('user')?.toString()
if (!key) { if (!key) {
return json({ message: 'No machine key provided' }, { return send({ message: 'No machine key provided' }, {
status: 400, status: 400,
}) })
} }
if (!user) { if (!user) {
return json({ message: 'No user provided' }, { return send({ message: 'No user provided' }, {
status: 400, status: 400,
}) })
} }
@@ -145,12 +144,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
user, key, user, key,
}) })
return json({ return {
success: true, success: true,
message: 'Machine registered' message: 'Machine registered'
}) }
} catch { } catch {
return json({ return send({
success: false, success: false,
message: 'Failed to register machine' message: 'Failed to register machine'
}, { }, {
@@ -160,7 +159,7 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
} }
default: { default: {
return json({ message: 'Invalid method' }, { return send({ message: 'Invalid method' }, {
status: 400, status: 400,
}) })
} }
@@ -50,10 +50,8 @@ export default function New(data: NewProps) {
<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> <Code isCopyable>
tailscale up --login-server= tailscale up --login-server=
</Code>
<Code>
{data.server} {data.server}
</Code> </Code>
{' '} {' '}
+5 -1
View File
@@ -46,6 +46,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
users: users.users, users: users.users,
magic, magic,
server: context.headscaleUrl, server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
} }
} }
@@ -73,7 +74,10 @@ export default function Page() {
</Link> </Link>
</p> </p>
</div> </div>
<NewMachine server={data.server} users={data.users} /> <NewMachine
server={data.publicServer ?? data.server}
users={data.users}
/>
</div> </div>
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400"> <thead className="text-gray-500 dark:text-gray-400">
@@ -64,13 +64,13 @@ export default function AddPreAuthKey(data: Props) {
Key Expiration Key Expiration
</Dialog.Text> </Dialog.Text>
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Set this key to expire between 1 and 90 days. Set this key to expire after a certain number of days.
</Dialog.Text> </Dialog.Text>
<NumberField <NumberField
label="Expiry" label="Expiry"
name="expiry" name="expiry"
minValue={1} minValue={1}
maxValue={90} maxValue={365_000} // 1000 years
state={[expiry, setExpiry]} state={[expiry, setExpiry]}
formatOptions={{ formatOptions={{
style: 'unit', style: 'unit',
@@ -1,4 +1,4 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node' import { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react' import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
@@ -7,6 +7,7 @@ import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale' import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react' import { useState } from 'react'
import { send } from '~/utils/res'
import Link from '~/components/Link' import Link from '~/components/Link'
import TableList from '~/components/TableList' import TableList from '~/components/TableList'
@@ -19,7 +20,7 @@ import AuthKeyRow from './key'
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, { return send({ message: 'Unauthorized' }, {
status: 401, status: 401,
}) })
} }
@@ -32,7 +33,7 @@ export async function action({ request }: ActionFunctionArgs) {
const user = data.get('user') const user = data.get('user')
if (!key || !user) { if (!key || !user) {
return json({ message: 'Missing parameters' }, { return send({ message: 'Missing parameters' }, {
status: 400, status: 400,
}) })
} }
@@ -46,7 +47,7 @@ export async function action({ request }: ActionFunctionArgs) {
} }
) )
return json({ message: 'Pre-auth key expired' }) return { message: 'Pre-auth key expired' }
} }
// Creating a new pre-auth key // Creating a new pre-auth key
@@ -57,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
const ephemeral = data.get('ephemeral') const ephemeral = data.get('ephemeral')
if (!user || !expiry || !reusable || !ephemeral) { if (!user || !expiry || !reusable || !ephemeral) {
return json({ message: 'Missing parameters' }, { return send({ message: 'Missing parameters' }, {
status: 400, status: 400,
}) })
} }
@@ -80,7 +81,7 @@ export async function action({ request }: ActionFunctionArgs) {
} }
) )
return json({ message: 'Pre-auth key created', key }) return { message: 'Pre-auth key created', key }
} }
} }
@@ -102,7 +103,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return { return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys), keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users, users: users.users,
server: context.headscaleUrl, server: context.headscalePublicUrl ?? context.headscaleUrl,
} }
} }
+47 -1
View File
@@ -1,9 +1,10 @@
import { type LoaderFunctionArgs, redirect } from '@remix-run/node' import { LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react' import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
import { ProgressBar } from 'react-aria-components' import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header' import Header from '~/components/Header'
import Link from '~/components/Link'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale'
@@ -36,10 +37,53 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext() const context = await loadContext()
return { return {
config: context.config, config: context.config,
url: context.headscalePublicUrl ?? context.headscaleUrl,
debug: context.debug,
user: session.get('user'), user: session.get('user'),
} }
} }
interface FooterProps {
url: string
debug: boolean
}
function Footer({ url, debug, integration }: FooterProps) {
return (
<footer className={cn(
'fixed bottom-0 left-0 z-50 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-800',
)}>
<p className="container text-xs">
Headplane is entirely free to use.
{' '}
If you find it useful, consider
{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>
{' '}
to support development.
{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong>{url}</strong>
{' '}
{debug && '(Debug mode enabled)'}
</p>
</footer>
)
}
export default function Layout() { export default function Layout() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const nav = useNavigation() const nav = useNavigation()
@@ -61,6 +105,7 @@ export default function Layout() {
<main className="container mx-auto overscroll-contain mt-4 mb-24"> <main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet /> <Outlet />
</main> </main>
<Footer {...data} />
</> </>
) )
} }
@@ -70,6 +115,7 @@ export function ErrorBoundary() {
<> <>
<Header /> <Header />
<ErrorPopup type="embedded" /> <ErrorPopup type="embedded" />
<Footer url="Unknown" debug={false} />
</> </>
) )
} }
+14 -29
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core' import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react' import { PersonIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react' import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only' import { ClientOnly } from 'remix-utils/client-only'
@@ -17,6 +17,7 @@ import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale' import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData'
import { send } from '~/utils/res'
import Auth from './auth' import Auth from './auth'
import Oidc from './oidc' import Oidc from './oidc'
@@ -56,16 +57,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, { return send({ message: 'Unauthorized' }, 401)
status: 401,
})
} }
const data = await request.formData() const data = await request.formData()
if (!data.has('_method')) { if (!data.has('_method')) {
return json({ message: 'No method provided' }, { return send({ message: 'No method provided' }, 400)
status: 400,
})
} }
const method = String(data.get('_method')) const method = String(data.get('_method'))
@@ -73,9 +70,7 @@ export async function action({ request }: ActionFunctionArgs) {
switch (method) { switch (method) {
case 'create': { case 'create': {
if (!data.has('username')) { if (!data.has('username')) {
return json({ message: 'No name provided' }, { return send({ message: 'No name provided' }, 400)
status: 400,
})
} }
const username = String(data.get('username')) const username = String(data.get('username'))
@@ -83,39 +78,33 @@ export async function action({ request }: ActionFunctionArgs) {
name: username, name: username,
}) })
return json({ message: `User ${username} created` }) return { message: `User ${username} created` }
} }
case 'delete': { case 'delete': {
if (!data.has('username')) { if (!data.has('username')) {
return json({ message: 'No name provided' }, { return send({ message: 'No name provided' }, 400)
status: 400,
})
} }
const username = String(data.get('username')) const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!) await del(`v1/user/${username}`, session.get('hsApiKey')!)
return json({ message: `User ${username} deleted` }) return { message: `User ${username} deleted` }
} }
case 'rename': { case 'rename': {
if (!data.has('old') || !data.has('new')) { if (!data.has('old') || !data.has('new')) {
return json({ message: 'No old or new name provided' }, { return send({ message: 'No old or new name provided' }, 400)
status: 400,
})
} }
const old = String(data.get('old')) const old = String(data.get('old'))
const newName = String(data.get('new')) const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!) await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return json({ message: `User ${old} renamed to ${newName}` }) return { message: `User ${old} renamed to ${newName}` }
} }
case 'move': { case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) { if (!data.has('id') || !data.has('to') || !data.has('name')) {
return json({ message: 'No ID or destination provided' }, { return send({ message: 'No ID or destination provided' }, 400)
status: 400,
})
} }
const id = String(data.get('id')) const id = String(data.get('id'))
@@ -124,18 +113,14 @@ export async function action({ request }: ActionFunctionArgs) {
try { try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!) await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved ${name} to ${to}` }) return { message: `Moved ${name} to ${to}` }
} catch { } catch {
return json({ message: `Failed to move ${name} to ${to}` }, { return send({ message: `Failed to move ${name} to ${to}` }, 500)
status: 500,
})
} }
} }
default: { default: {
return json({ message: 'Invalid method' }, { return send({ message: 'Invalid method' }, 400)
status: 400,
})
} }
} }
} }
+29
View File
@@ -0,0 +1,29 @@
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
export async function loader() {
const context = await loadContext()
try {
// Doesn't matter, we just need a 401
await pull('v1/', 'wrongkey')
} catch (e) {
if (!(e instanceof HeadscaleError)) {
log.debug('Healthz', 'Headscale is not reachable')
return new Response('Headscale is not reachable', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
return new Response('OK', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react' import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -6,7 +6,7 @@ import Button from '~/components/Button'
import Card from '~/components/Card' import Card from '~/components/Card'
import Code from '~/components/Code' import Code from '~/components/Code'
import TextField from '~/components/TextField' import TextField from '~/components/TextField'
import { type Key } from '~/types' import { Key } from '~/types'
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc' import { startOidc } from '~/utils/oidc'
@@ -81,9 +81,9 @@ export async function action({ request }: ActionFunctionArgs) {
}, },
}) })
} catch { } catch {
return json({ return {
error: 'Invalid API key', error: 'Invalid API key',
}) }
} }
} }
+35
View File
@@ -10,11 +10,13 @@ import { parse } from 'yaml'
import { IntegrationFactory, loadIntegration } from '~/integration' import { IntegrationFactory, loadIntegration } from '~/integration'
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale' import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
import { testOidc } from '~/utils/oidc'
import log from '~/utils/log' import log from '~/utils/log'
export interface HeadplaneContext { export interface HeadplaneContext {
debug: boolean debug: boolean
headscaleUrl: string headscaleUrl: string
headscalePublicUrl?: string
cookieSecret: string cookieSecret: string
integration: IntegrationFactory | undefined integration: IntegrationFactory | undefined
@@ -28,6 +30,7 @@ export interface HeadplaneContext {
client: string client: string
secret: string secret: string
rootKey: string rootKey: string
method: string
disableKeyLogin: boolean disableKeyLogin: boolean
} }
} }
@@ -55,12 +58,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
const { config, contextData } = await checkConfig(path) const { config, contextData } = await checkConfig(path)
let headscaleUrl = process.env.HEADSCALE_URL let headscaleUrl = process.env.HEADSCALE_URL
let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL
if (!headscaleUrl && !config) { if (!headscaleUrl && !config) {
throw new Error('HEADSCALE_URL not set') throw new Error('HEADSCALE_URL not set')
} }
if (config) { if (config) {
headscaleUrl = headscaleUrl ?? config.server_url headscaleUrl = headscaleUrl ?? config.server_url
if (!headscalePublicUrl) {
// Fallback to the config value if the env var is not set
headscalePublicUrl = config.public_url
}
} }
if (!headscaleUrl) { if (!headscaleUrl) {
@@ -75,6 +84,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
context = { context = {
debug, debug,
headscaleUrl, headscaleUrl,
headscalePublicUrl,
cookieSecret, cookieSecret,
integration: await loadIntegration(), integration: await loadIntegration(),
config: contextData, config: contextData,
@@ -83,6 +93,10 @@ export async function loadContext(): Promise<HeadplaneContext> {
log.info('CTXT', 'Starting Headplane with Context') log.info('CTXT', 'Starting Headplane with Context')
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl) log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
if (headscalePublicUrl) {
log.info('CTXT', 'HEADSCALE_PUBLIC_URL: %s', headscalePublicUrl)
}
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None') log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
log.info('CTXT', 'Config: %s', contextData.read log.info('CTXT', 'Config: %s', contextData.read
? `Found ${contextData.write ? '' : '(Read Only)'}` ? `Found ${contextData.write ? '' : '(Read Only)'}`
@@ -143,6 +157,8 @@ async function checkOidc(config?: HeadscaleConfig) {
let issuer = process.env.OIDC_ISSUER let issuer = process.env.OIDC_ISSUER
let client = process.env.OIDC_CLIENT_ID let client = process.env.OIDC_CLIENT_ID
let secret = process.env.OIDC_CLIENT_SECRET let secret = process.env.OIDC_CLIENT_SECRET
let method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic'
let skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true'
log.debug('CTXT', 'Checking OIDC environment variables') log.debug('CTXT', 'Checking OIDC environment variables')
log.debug('CTXT', 'Issuer: %s', issuer) log.debug('CTXT', 'Issuer: %s', issuer)
@@ -157,10 +173,19 @@ async function checkOidc(config?: HeadscaleConfig) {
} }
if (issuer && client && secret) { if (issuer && client && secret) {
if (!skip) {
log.debug('CTXT', 'Validating OIDC configuration from environment variables')
testOidc(issuer, client, secret)
} else {
log.debug('CTXT', 'OIDC_SKIP_CONFIG_VALIDATION is set')
log.debug('CTXT', 'Skipping OIDC configuration validation')
}
return { return {
issuer, issuer,
client, client,
secret, secret,
method,
rootKey, rootKey,
disableKeyLogin, disableKeyLogin,
} }
@@ -199,11 +224,21 @@ async function checkOidc(config?: HeadscaleConfig) {
return return
} }
if (config.oidc.only_start_if_oidc_is_available) {
log.debug('CTXT', 'Validating OIDC configuration from headscale config')
testOidc(issuer, client, secret)
return
} else {
log.debug('CTXT', 'OIDC validation is disabled in headscale config')
log.debug('CTXT', 'Skipping OIDC configuration validation')
}
return { return {
issuer, issuer,
client, client,
secret, secret,
rootKey, rootKey,
method,
disableKeyLogin, disableKeyLogin,
} }
} }
-12
View File
@@ -20,9 +20,6 @@ export class FatalError extends Error {
export async function pull<T>(url: string, key: string) { export async function pull<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?')
} }
@@ -46,9 +43,6 @@ export async function pull<T>(url: string, key: string) {
export async function post<T>(url: string, key: string, body?: unknown) { export async function post<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?')
} }
@@ -74,9 +68,6 @@ export async function post<T>(url: string, key: string, body?: unknown) {
export async function put<T>(url: string, key: string, body?: unknown) { export async function put<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?')
} }
@@ -102,9 +93,6 @@ export async function put<T>(url: string, key: string, body?: unknown) {
export async function del<T>(url: string, key: string) { export async function del<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?')
} }
+30 -2
View File
@@ -17,6 +17,7 @@ import {
import { post } from '~/utils/headscale' import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions' import { commitSession, getSession } from '~/utils/sessions'
import log from '~/utils/log'
import { HeadplaneContext } from './config/headplane' import { HeadplaneContext } from './config/headplane'
@@ -36,7 +37,7 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
const issuerUrl = new URL(oidc.issuer) const issuerUrl = new URL(oidc.issuer)
const oidcClient = { const oidcClient = {
client_id: oidc.client, client_id: oidc.client,
token_endpoint_auth_method: 'client_secret_basic', token_endpoint_auth_method: oidc.method,
} satisfies Client } satisfies Client
const response = await discoveryRequest(issuerUrl) const response = await discoveryRequest(issuerUrl)
@@ -91,7 +92,7 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
const oidcClient = { const oidcClient = {
client_id: oidc.client, client_id: oidc.client,
client_secret: oidc.secret, client_secret: oidc.secret,
token_endpoint_auth_method: 'client_secret_basic', token_endpoint_auth_method: oidc.method,
} satisfies Client } satisfies Client
const response = await discoveryRequest(issuerUrl) const response = await discoveryRequest(issuerUrl)
@@ -169,3 +170,30 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
}, },
}) })
} }
// Runs at application startup to validate the OIDC configuration
export async function testOidc(issuer: string, client: string, secret: string) {
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_post',
} satisfies Client
const issuerUrl = new URL(issuer)
try {
log.debug('OIDC', 'Checking OIDC well-known endpoint')
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider')
return false
}
log.debug('OIDC', 'Found auth endpoint: %s', processed.authorization_endpoint)
return true
} catch (e) {
log.debug('OIDC', 'Validation failed: %s', e.message)
return false
}
}
+5
View File
@@ -0,0 +1,5 @@
import { data } from '@remix-run/node'
export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init)
}
+7
View File
@@ -69,3 +69,10 @@ Currently there are 3 integration providers that can do this for you:
- [Kubernetes Integration](/docs/integration/Kubernetes.md) - [Kubernetes Integration](/docs/integration/Kubernetes.md)
- [Native Linux Integration](/docs/integration/Native.md) - [Native Linux Integration](/docs/integration/Native.md)
Once configured, the Headplane UI will be available at the `/admin` path
of the server you deployed it on. This is currently not configurable unless
you build the Docker image yourself or run the Node.js server directly.
Additionally, if you require access to health information for either Docker
or Kubernetes, the `/admin/healthz` path will be available. This is useful for
monitoring services like Prometheus or Grafana.
+5 -1
View File
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
services: services:
headplane: headplane:
container_name: headplane container_name: headplane
image: ghcr.io/tale/headplane:0.3.2 image: ghcr.io/tale/headplane:0.3.8
restart: unless-stopped restart: unless-stopped
ports: ports:
- '3000:3000' - '3000:3000'
@@ -50,6 +50,10 @@ services:
PORT: '3000' PORT: '3000'
``` ```
Once configured, the Headplane UI will be available at the `/admin` path
of the server you deployed it on. This is currently not configurable unless
you build the Docker image yourself or run the Node.js server directly.
> For a breakdown of each configuration variable, please refer to the > For a breakdown of each configuration variable, please refer to the
[Configuration](/docs/Configuration.md) guide. [Configuration](/docs/Configuration.md) guide.
> It explains what each variable does, how to configure them, and what the > It explains what each variable does, how to configure them, and what the
+3
View File
@@ -9,6 +9,7 @@ You can configure Headplane using environment variables.
#### Optional Variables #### Optional Variables
- **`HEADSCALE_PUBLIC_URL`**: The public URL of your Headscale server (if different from `HEADSCALE_URL`).
- **`DEBUG`**: Enable debug logging (default: `false`). - **`DEBUG`**: Enable debug logging (default: `false`).
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`). - **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`PORT`**: The port to bind the server to (default: `3000`). - **`PORT`**: The port to bind the server to (default: `3000`).
@@ -34,6 +35,8 @@ If you use the Headscale configuration integration, these are not required.
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider. - **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider. - **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider. - **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
- **`OIDC_CLIENT_SECRET_METHOD`**: The method used to send the client secret (default: `client_secret_basic`).
- **`OIDC_SKIP_CONFIG_VALIDATION`**: Skip the OIDC configuration validation (default: `false`).
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long). - **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`. - **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
+1 -1
View File
@@ -50,7 +50,7 @@ services:
TZ: 'America/New_York' TZ: 'America/New_York'
headplane: headplane:
container_name: headplane container_name: headplane
image: ghcr.io/tale/headplane:0.3.2 image: ghcr.io/tale/headplane:0.3.8
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- './data:/var/lib/headscale' - './data:/var/lib/headscale'
+1 -1
View File
@@ -88,7 +88,7 @@ spec:
serviceAccountName: default serviceAccountName: default
containers: containers:
- name: headplane - name: headplane
image: ghcr.io/tale/headplane:0.3.2 image: ghcr.io/tale/headplane:0.3.8
env: env:
- name: COOKIE_SECRET - name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz' value: 'abcdefghijklmnopqrstuvwxyz'
+27 -36
View File
@@ -9,54 +9,52 @@
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@kubernetes/client-node": "^0.22.2", "@kubernetes/client-node": "^0.22.3",
"@primer/octicons-react": "^19.12.0", "@primer/octicons-react": "^19.13.0",
"@react-aria/toast": "3.0.0-beta.12", "@react-aria/toast": "3.0.0-beta.18",
"@react-stately/toast": "3.0.0-beta.4", "@react-stately/toast": "3.0.0-beta.7",
"@remix-run/node": "^2.13.1", "@remix-run/node": "^2.15.0",
"@remix-run/react": "^2.13.1", "@remix-run/react": "^2.15.0",
"@shopify/lang-jsonc": "^1.0.0", "@shopify/lang-jsonc": "^1.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@uiw/codemirror-theme-github": "^4.23.6", "@uiw/codemirror-theme-github": "^4.23.6",
"@uiw/react-codemirror": "^4.23.6", "@uiw/react-codemirror": "^4.23.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"isbot": "^5.1.17", "isbot": "^5.1.17",
"mime": "^4.0.4", "mime": "^4.0.4",
"oauth4webapi": "^2.17.0", "oauth4webapi": "^2.17.0",
"react": "19.0.0-beta-26f2496093-20240514", "react": "19.0.0",
"react-aria-components": "^1.2.1", "react-aria-components": "^1.5.0",
"react-codemirror-merge": "^4.23.6", "react-codemirror-merge": "^4.23.6",
"react-dom": "19.0.0-beta-26f2496093-20240514", "react-dom": "19.0.0",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"remix-utils": "^7.7.0", "remix-utils": "^7.7.0",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.5",
"tailwindcss-react-aria-components": "^1.1.6", "tailwindcss-react-aria-components": "^1.2.0",
"undici": "^6.20.1", "undici": "^7.1.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"yaml": "^2.6.0", "yaml": "^2.6.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "^2.13.1", "@remix-run/dev": "^2.15.0",
"@types/react": "npm:types-react@beta", "@remix-run/fs-routes": "^2.15.0",
"@types/react-dom": "npm:types-react-dom@beta", "@remix-run/route-config": "^2.15.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020", "babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"postcss": "^8.4.47", "postcss": "^8.4.49",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.16",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3", "typescript": "^5.7.2",
"vite": "^5.4.10", "vite": "^6.0.3",
"vite-plugin-babel": "^1.2.0", "vite-plugin-babel": "^1.3.0",
"vite-tsconfig-paths": "^5.1.0" "vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta"
}, },
"engines": { "engines": {
"node": ">=20", "node": ">=20",
@@ -64,14 +62,7 @@
}, },
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch",
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch" "@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch"
},
"peerDependencyRules": {
"allowAny": [
"react",
"react-dom"
]
} }
} }
} }
@@ -1,22 +0,0 @@
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
index 69b84ce2aec5b637a9f0ba8158b6a1ba4173c266..5e546a6e4fc3b7a0468c1c89d887a4392c010624 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.
+2341 -1983
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -8,6 +8,7 @@ import { access, constants } from 'node:fs/promises'
import { createReadStream, existsSync, statSync } from 'node:fs' import { createReadStream, existsSync, statSync } from 'node:fs'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path'
import { env } from 'node:process'
function log(level, message) { function log(level, message) {
const date = new Date().toISOString() const date = new Date().toISOString()
@@ -42,9 +43,9 @@ const {
} = await import('@remix-run/node') } = await import('@remix-run/node')
const { default: mime } = await import('mime') const { default: mime } = await import('mime')
const port = process.env.PORT || 3000 const port = env.PORT || 3000
const host = process.env.HOST || '0.0.0.0' const host = env.HOST || '0.0.0.0'
const buildPath = process.env.BUILD_PATH || './build' const buildPath = env.BUILD_PATH || './build'
// Because this is a dynamic import without an easily discernable path // Because this is a dynamic import without an easily discernable path
// we gain the "deoptimization" we want so that Vite doesn't bundle this // we gain the "deoptimization" we want so that Vite doesn't bundle this
+18 -3
View File
@@ -1,16 +1,20 @@
import { vitePlugin as remix } from '@remix-run/dev' import { vitePlugin as remix } from '@remix-run/dev'
import { installGlobals } from '@remix-run/node'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import babel from 'vite-plugin-babel' import babel from 'vite-plugin-babel'
import tsconfigPaths from 'vite-tsconfig-paths' import tsconfigPaths from 'vite-tsconfig-paths'
import { execSync } from 'node:child_process'
installGlobals()
const prefix = process.env.__INTERNAL_PREFIX || '/admin' const prefix = process.env.__INTERNAL_PREFIX || '/admin'
if (prefix.endsWith('/')) { if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash') throw new Error('Prefix must not end with a slash')
} }
// Load the version via git tags
const version = execSync('git describe --tags --always').toString().trim()
if (!version) {
throw new Error('Unable to execute git describe')
}
export default defineConfig(({ isSsrBuild }) => { export default defineConfig(({ isSsrBuild }) => {
// If we have the Headplane entry we build it as a single // If we have the Headplane entry we build it as a single
// server.mjs file that is built for production server bundle // server.mjs file that is built for production server bundle
@@ -45,9 +49,20 @@ export default defineConfig(({ isSsrBuild }) => {
return ({ return ({
base: `${prefix}/`, base: `${prefix}/`,
build: isSsrBuild ? { target: 'ES2022' } : {}, build: isSsrBuild ? { target: 'ES2022' } : {},
define: {
__VERSION__: JSON.stringify(version),
},
plugins: [ plugins: [
remix({ remix({
basename: `${prefix}/`, basename: `${prefix}/`,
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_lazyRouteDiscovery: true,
v3_singleFetch: true,
v3_routeConfig: true
},
}), }),
tsconfigPaths(), tsconfigPaths(),
babel({ babel({