diff --git a/app/routes/_data.acls._index/cm.tsx b/app/routes/_data.acls._index/cm.tsx index d432335..2400fa1 100644 --- a/app/routes/_data.acls._index/cm.tsx +++ b/app/routes/_data.acls._index/cm.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react' import Merge from 'react-codemirror-merge' import CodeMirror from '@uiw/react-codemirror' import { ClientOnly } from 'remix-utils/client-only' +import { ErrorBoundary } from 'react-error-boundary' import { jsonc } from '@shopify/lang-jsonc' import { githubDark, githubLight } from '@uiw/codemirror-theme-github' import { useState } from 'react' @@ -11,12 +12,11 @@ import Fallback from './fallback' interface EditorProps { isDisabled?: boolean + value: string onChange: (value: string) => void - defaultValue?: string } export function Editor(props: EditorProps) { - const [value, setValue] = useState(props.defaultValue ?? '') const [light, setLight] = useState(false) useEffect(() => { const theme = window.matchMedia('(prefers-color-scheme: light)') @@ -32,17 +32,27 @@ export function Editor(props: EditorProps) { 'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden', )}>
- }> - {() => ( - props.onChange(value)} - /> - )} - + + Failed to load the editor. +

+ }> + }> + {() => ( + props.onChange(value)} + /> + )} + +
) @@ -77,25 +87,34 @@ export function Differ(props: DifferProps) { No changes

) : ( - }> - {() => ( - - - - - )} - + + Failed to load the editor. +

+ }> + }> + {() => ( + + + + + )} + +
)} diff --git a/app/routes/_data.acls._index/error.tsx b/app/routes/_data.acls._index/error.tsx new file mode 100644 index 0000000..853a948 --- /dev/null +++ b/app/routes/_data.acls._index/error.tsx @@ -0,0 +1,30 @@ +import { cn } from '~/utils/cn' +import { AlertIcon } from '@primer/octicons-react' + +import Card from '~/components/Card' +import Code from '~/components/Code' + +interface Props { + message: string +} + +export function ErrorView({ message }: Props) { + return ( + +
+ + Error + + +
+ + Could not apply changes to your ACL policy + due to the following error: +
+ + {message} + +
+
+ ) +} diff --git a/app/routes/_data.acls._index/route.tsx b/app/routes/_data.acls._index/route.tsx index c2725ff..e3972a6 100644 --- a/app/routes/_data.acls._index/route.tsx +++ b/app/routes/_data.acls._index/route.tsx @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' -import { useFetcher, useLoaderData } from '@remix-run/react' -import { useEffect, useState } from 'react' +import { useLoaderData, useRevalidator } from '@remix-run/react' +import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher' +import { useEffect, useState, useMemo } from 'react' import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components' import { setTimeout } from 'node:timers/promises' @@ -14,52 +15,100 @@ import Spinner from '~/components/Spinner' import { toast } from '~/components/Toaster' import { cn } from '~/utils/cn' import { loadContext } from '~/utils/config/headplane' +import { loadConfig } from '~/utils/config/headscale' import { HeadscaleError, pull, put } from '~/utils/headscale' import { getSession } from '~/utils/sessions' +import log from '~/utils/log' import { Editor, Differ } from './cm' +import { Unavailable } from './unavailable' +import { ErrorView } from './error' export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')) + + // The way policy is handled in 0.23 of Headscale and later is verbose. + // The 2 ACL policy modes are either the database one or file one + // + // File: The ACL policy is readonly to the API and manually edited + // Database: The ACL policy is read/write to the API + // + // To determine if we first have an ACL policy available we need to check + // if fetching the v1/policy route gives us a 500 status code or a 200. + // + // 500 can mean many different things here unfortunately: + // - In file based that means the file is not accessible + // - In database mode this can mean that we have never set an ACL policy + // - In database mode this can mean that the ACL policy is not available + // - A general server error may have occurred + // + // Unfortunately the server errors are not very descriptive so we have to + // do some silly guesswork here. If we are running in an integration mode + // and have the Headscale configuration available to us, our assumptions + // can be more accurate, otherwise we just HAVE to assume that the ACL + // policy has never been set. + // + // We can do damage control by checking for write access and if we are not + // able to PUT an ACL policy on the v1/policy route, we can already know + // that the policy is at the very-least readonly or not available. + const context = await loadContext() + let modeGuess = 'database' // Assume database mode + if (context.config.read) { + const config = await loadConfig() + modeGuess = config.policy.mode + } + // Attempt to load the policy, for both the frontend and for checking + // if we are able to write to the policy for write access try { const { policy } = await pull<{ policy: string }>( 'v1/policy', session.get('hsApiKey')!, ) - try { - // We have read access, now do we have write access? - // Attempt to set the policy to what we just got - await put('v1/policy', session.get('hsApiKey')!, { - policy, - }) + let write = false // On file mode we already know it's readonly + if (modeGuess === 'database' && policy.length > 0) { + try { + await put('v1/policy', session.get('hsApiKey')!, { + policy: policy, + }) - return { - hasAclWrite: true, - currentAcl: policy, - aclType: 'json', - } as const - } catch (error) { - if (!(error instanceof HeadscaleError)) { - throw error - } - - if (error.status === 500) { - return { - hasAclWrite: false, - currentAcl: policy, - aclType: 'json', - } as const + write = true + } catch (error) { + write = false + log.debug( + 'APIC', + 'Failed to write to ACL policy with error %s', + error + ) } } - } catch {} - return { - hasAclWrite: true, - currentAcl: '', - aclType: 'json', - } as const + return { + read: true, + write, + mode: modeGuess, + policy + } + } catch { + // If we are explicit on file mode then this is the end of the road + if (modeGuess === 'file') { + return { + read: false, + write: false, + mode: modeGuess + } + } + + // Assume that we have write access otherwise? + // This is sort of a brittle assumption to make but we don't want + // to create a default policy if we don't have to. + return { + read: true, + write: true, + mode: modeGuess + } + } } export async function action({ request }: ActionFunctionArgs) { @@ -70,16 +119,21 @@ export async function action({ request }: ActionFunctionArgs) { }) } - const { acl } = await request.json() as { acl: string, api: boolean } try { - await put('v1/policy', session.get('hsApiKey')!, { - policy: acl, - }) + const { acl } = await request.json() as { acl: string } + const { policy } = await put<{ policy: string }>( + 'v1/policy', + session.get('hsApiKey')!, + { + policy: acl, + } + ) - await setTimeout(250) - return json({ success: true }) + return json({ success: true, policy }) } catch (error) { - return json({ success: false }, { + log.debug('APIC', 'Failed to update ACL policy with error %s', error) + const text = JSON.parse(error.message) + return json({ success: false, error: text.message }, { status: error instanceof HeadscaleError ? error.status : 500, }) } @@ -87,69 +141,12 @@ export async function action({ request }: ActionFunctionArgs) { return json({ success: true }) } -export function ErrorBoundary() { - return ( -
- - An ACL policy is not available or an error occurred while trying to fetch it. - -

- Access Control List (ACL) -

- -

- 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 ACL guide - - {' '} - and the - {' '} - - Headscale docs - - . -

-
-
-

- If you are running Headscale 0.23-beta1 or later, the - ACL configuration is most likely set to - {' '} - file - {' '} - mode but the ACL file is not available. In order to - resolve this you will either need to correctly set - {' '} - policy.path - {' '} - in your Headscale configuration or set the - {' '} - policy.mode - {' '} - to - {' '} - database - . -

-
-
-
- ) -} - export default function Page() { const data = useLoaderData() - const fetcher = useFetcher() - const [acl, setAcl] = useState(data.currentAcl) + const fetcher = useDebounceFetcher() + const revalidator = useRevalidator() + + const [acl, setAcl] = useState(data.policy ?? '') const [toasted, setToasted] = useState(false) useEffect(() => { @@ -164,14 +161,39 @@ export default function Page() { } setToasted(true) - setAcl(data.currentAcl) - }, [fetcher.data, toasted, data.currentAcl]) + if (revalidator.state === 'idle') { + revalidator.revalidate() + } + }, [fetcher.data, toasted, data.policy]) + + // The state for if the save and discard buttons should be disabled + // is pretty complicated to calculate and varies on different states. + const disabled = useMemo(() => { + if (!data.read || !data.write) { + return true + } + + // First check our fetcher states + if (fetcher.state === 'loading') { + return true + } + + if (revalidator.state === 'loading') { + return true + } + + // If we have a failed fetcher state allow the user to try again + if (fetcher.data?.success === false) { + return false + } + + return data.policy === acl + }, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]) return (
- {data.hasAclWrite - ? undefined - : ( + {data.read && !data.write + ? (
The ACL policy is read-only. You can view the current policy @@ -181,7 +203,7 @@ export default function Page() { database in your Headscale configuration.
- )} + ) : undefined}

Access Control List (ACL) @@ -209,106 +231,117 @@ export default function Page() { .

- - - cn( - 'px-4 py-2 rounded-tl-lg', - 'focus:outline-none flex items-center gap-2', - 'border-x border-gray-200 dark:border-gray-700', - isSelected ? 'text-gray-900 dark:text-gray-100' : '', + {fetcher.data?.success === false + ? ( + + ) : undefined} + + {data.read ? ( + <> + + + cn( + 'px-4 py-2 rounded-tl-lg', + 'focus:outline-none flex items-center gap-2', + 'border-x border-gray-200 dark:border-gray-700', + isSelected ? 'text-gray-900 dark:text-gray-100' : '', + )} + > + +

Edit file

+
+ cn( + 'px-4 py-2', + 'focus:outline-none flex items-center gap-2', + 'border-x border-gray-200 dark:border-gray-700', + isSelected ? 'text-gray-900 dark:text-gray-100' : '', + )} + > + +

Preview changes

+
+ cn( + 'px-4 py-2 rounded-tr-lg', + 'focus:outline-none flex items-center gap-2', + 'border-x border-gray-200 dark:border-gray-700', + isSelected ? 'text-gray-900 dark:text-gray-100' : '', + )} + > + +

Preview rules

+
+
+ + + + + + + +
+ +

+ 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. +

+
+
+
+ + - + Discard Changes + + + ) : }

) } diff --git a/app/routes/_data.acls._index/unavailable.tsx b/app/routes/_data.acls._index/unavailable.tsx new file mode 100644 index 0000000..be605a7 --- /dev/null +++ b/app/routes/_data.acls._index/unavailable.tsx @@ -0,0 +1,43 @@ +import { cn } from '~/utils/cn' +import { AlertIcon } from '@primer/octicons-react' + +import Code from '~/components/Code' +import Card from '~/components/Card' + +interface Props { + mode: 'file' | 'database' +} + +export function Unavailable({ mode }: Props) { + return ( + +
+ + ACL Policy Unavailable + + +
+ + Unable to load a valid ACL policy configuration. + This is most likely due to a misconfiguration in your + Headscale configuration file. + + + {mode !== 'file' ? ( +

+ According to your configuration, the ACL policy mode + is set to file but the ACL file is not + available. Ensure that the policy.path is + set to a valid path in your Headscale configuration. +

+ ) : ( +

+ In order to fully utilize the ACL management features of + Headplane, please set policy.mode to either + {' '}file or database in your + Headscale configuration. +

+ )} +
+ ) +} diff --git a/app/tailwind.css b/app/tailwind.css index 14b94d2..f2e8171 100644 --- a/app/tailwind.css +++ b/app/tailwind.css @@ -7,3 +7,24 @@ scrollbar-gutter: stable } } + +.cm-merge-theme { + height: 100% !important; +} + +.cm-mergeView { + height: 100% !important; +} + +.cm-mergeViewEditors { + height: 100%; +} + +.cm-mergeViewEditor { + height: 100% !important; +} + +/* Weirdest class name characters but ok */ +.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 { + height: 100% !important; +} diff --git a/package.json b/package.json index dfe3e39..aaba583 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-aria-components": "^1.2.1", "react-codemirror-merge": "^4.23.5", "react-dom": "^18.3.1", + "react-error-boundary": "^4.1.2", "remix-utils": "^7.6.0", "tailwind-merge": "^2.3.0", "tailwindcss-react-aria-components": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b38ea2e..655c2e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-error-boundary: + specifier: ^4.1.2 + version: 4.1.2(react@18.3.1) remix-utils: specifier: ^7.6.0 version: 7.6.0(@remix-run/node@2.10.2(typescript@5.5.3))(@remix-run/react@2.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3))(@remix-run/router@1.20.0)(react@18.3.1)(zod@3.23.8) @@ -331,10 +334,6 @@ packages: resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.24.7': - resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.25.7': resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} engines: {node: '>=6.9.0'} @@ -3915,6 +3914,11 @@ packages: peerDependencies: react: ^18.3.1 + react-error-boundary@4.1.2: + resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5019,10 +5023,6 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@babel/runtime@7.24.7': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.25.7': dependencies: regenerator-runtime: 0.14.1 @@ -8739,7 +8739,7 @@ snapshots: media-query-parser@2.0.2: dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.25.7 media-typer@0.3.0: {} @@ -9579,6 +9579,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-error-boundary@4.1.2(react@18.3.1): + dependencies: + '@babel/runtime': 7.25.7 + react: 18.3.1 + react-is@16.13.1: {} react-refresh@0.14.2: {}