fix: handle errors and fix api logic with acl updating
This commit is contained in:
parent
d1fa76971b
commit
a8abd37e3a
@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
|
|||||||
import Merge from 'react-codemirror-merge'
|
import Merge from 'react-codemirror-merge'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { ClientOnly } from 'remix-utils/client-only'
|
import { ClientOnly } from 'remix-utils/client-only'
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
import { jsonc } from '@shopify/lang-jsonc'
|
import { jsonc } from '@shopify/lang-jsonc'
|
||||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@ -11,12 +12,11 @@ import Fallback from './fallback'
|
|||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
isDisabled?: boolean
|
isDisabled?: boolean
|
||||||
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
defaultValue?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor(props: EditorProps) {
|
export function Editor(props: EditorProps) {
|
||||||
const [value, setValue] = useState(props.defaultValue ?? '')
|
|
||||||
const [light, setLight] = useState(false)
|
const [light, setLight] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
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',
|
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||||
)}>
|
)}>
|
||||||
<div className="overflow-y-scroll h-editor text-sm">
|
<div className="overflow-y-scroll h-editor text-sm">
|
||||||
<ClientOnly fallback={<Fallback acl={value} />}>
|
<ErrorBoundary fallback={
|
||||||
{() => (
|
<p className={cn(
|
||||||
<CodeMirror
|
'w-full h-full flex items-center justify-center',
|
||||||
value={value}
|
'text-gray-400 dark:text-gray-500 text-xl',
|
||||||
height="100%"
|
)}>
|
||||||
extensions={[jsonc()]}
|
Failed to load the editor.
|
||||||
theme={light ? githubLight : githubDark}
|
</p>
|
||||||
onChange={(value) => props.onChange(value)}
|
}>
|
||||||
/>
|
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||||
)}
|
{() => (
|
||||||
</ClientOnly>
|
<CodeMirror
|
||||||
|
value={props.value}
|
||||||
|
height="100%"
|
||||||
|
extensions={[jsonc()]}
|
||||||
|
style={{ height: "100%" }}
|
||||||
|
theme={light ? githubLight : githubDark}
|
||||||
|
onChange={(value) => props.onChange(value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -77,25 +87,34 @@ export function Differ(props: DifferProps) {
|
|||||||
No changes
|
No changes
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
<ErrorBoundary fallback={
|
||||||
{() => (
|
<p className={cn(
|
||||||
<Merge
|
'w-full h-full flex items-center justify-center',
|
||||||
orientation="a-b"
|
'text-gray-400 dark:text-gray-500 text-xl',
|
||||||
theme={light ? githubLight : githubDark}
|
)}>
|
||||||
>
|
Failed to load the editor.
|
||||||
<Merge.Original
|
</p>
|
||||||
readOnly
|
}>
|
||||||
value={props.left}
|
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||||
extensions={[jsonc()]}
|
{() => (
|
||||||
/>
|
<Merge
|
||||||
<Merge.Modified
|
orientation="a-b"
|
||||||
readOnly
|
theme={light ? githubLight : githubDark}
|
||||||
value={props.right}
|
>
|
||||||
extensions={[jsonc()]}
|
<Merge.Original
|
||||||
/>
|
readOnly
|
||||||
</Merge>
|
value={props.left}
|
||||||
)}
|
extensions={[jsonc()]}
|
||||||
</ClientOnly>
|
/>
|
||||||
|
<Merge.Modified
|
||||||
|
readOnly
|
||||||
|
value={props.right}
|
||||||
|
extensions={[jsonc()]}
|
||||||
|
/>
|
||||||
|
</Merge>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
app/routes/_data.acls._index/error.tsx
Normal file
30
app/routes/_data.acls._index/error.tsx
Normal file
@ -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 (
|
||||||
|
<Card variant="flat" className="max-w-full mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Card.Title className="text-xl mb-0">
|
||||||
|
Error
|
||||||
|
</Card.Title>
|
||||||
|
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||||
|
</div>
|
||||||
|
<Card.Text className="mt-4">
|
||||||
|
Could not apply changes to your ACL policy
|
||||||
|
due to the following error:
|
||||||
|
<br />
|
||||||
|
<Code>
|
||||||
|
{message}
|
||||||
|
</Code>
|
||||||
|
</Card.Text>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
/* 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, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
import { useLoaderData, useRevalidator } from '@remix-run/react'
|
||||||
import { useEffect, useState } from '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 { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||||
import { setTimeout } from 'node:timers/promises'
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
|
||||||
@ -14,52 +15,100 @@ import Spinner from '~/components/Spinner'
|
|||||||
import { toast } from '~/components/Toaster'
|
import { toast } from '~/components/Toaster'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
import { Editor, Differ } from './cm'
|
import { Editor, Differ } from './cm'
|
||||||
|
import { Unavailable } from './unavailable'
|
||||||
|
import { ErrorView } from './error'
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
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 {
|
try {
|
||||||
const { policy } = await pull<{ policy: string }>(
|
const { policy } = await pull<{ policy: string }>(
|
||||||
'v1/policy',
|
'v1/policy',
|
||||||
session.get('hsApiKey')!,
|
session.get('hsApiKey')!,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
let write = false // On file mode we already know it's readonly
|
||||||
// We have read access, now do we have write access?
|
if (modeGuess === 'database' && policy.length > 0) {
|
||||||
// Attempt to set the policy to what we just got
|
try {
|
||||||
await put('v1/policy', session.get('hsApiKey')!, {
|
await put('v1/policy', session.get('hsApiKey')!, {
|
||||||
policy,
|
policy: policy,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
write = true
|
||||||
hasAclWrite: true,
|
} catch (error) {
|
||||||
currentAcl: policy,
|
write = false
|
||||||
aclType: 'json',
|
log.debug(
|
||||||
} as const
|
'APIC',
|
||||||
} catch (error) {
|
'Failed to write to ACL policy with error %s',
|
||||||
if (!(error instanceof HeadscaleError)) {
|
error
|
||||||
throw error
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (error.status === 500) {
|
|
||||||
return {
|
|
||||||
hasAclWrite: false,
|
|
||||||
currentAcl: policy,
|
|
||||||
aclType: 'json',
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasAclWrite: true,
|
read: true,
|
||||||
currentAcl: '',
|
write,
|
||||||
aclType: 'json',
|
mode: modeGuess,
|
||||||
} as const
|
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) {
|
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 {
|
try {
|
||||||
await put('v1/policy', session.get('hsApiKey')!, {
|
const { acl } = await request.json() as { acl: string }
|
||||||
policy: acl,
|
const { policy } = await put<{ policy: string }>(
|
||||||
})
|
'v1/policy',
|
||||||
|
session.get('hsApiKey')!,
|
||||||
|
{
|
||||||
|
policy: acl,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
await setTimeout(250)
|
return json({ success: true, policy })
|
||||||
return json({ success: true })
|
|
||||||
} catch (error) {
|
} 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,
|
status: error instanceof HeadscaleError ? error.status : 500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -87,69 +141,12 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Notice className="mb-4">
|
|
||||||
An ACL policy is not available or an error occurred while trying to fetch it.
|
|
||||||
</Notice>
|
|
||||||
<h1 className="text-2xl font-medium mb-4">
|
|
||||||
Access Control List (ACL)
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<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
|
|
||||||
{' '}
|
|
||||||
<Link
|
|
||||||
to="https://tailscale.com/kb/1018/acls"
|
|
||||||
name="Tailscale ACL documentation"
|
|
||||||
>
|
|
||||||
Tailscale ACL guide
|
|
||||||
</Link>
|
|
||||||
{' '}
|
|
||||||
and the
|
|
||||||
{' '}
|
|
||||||
<Link
|
|
||||||
to="https://headscale.net/acls"
|
|
||||||
name="Headscale ACL documentation"
|
|
||||||
>
|
|
||||||
Headscale docs
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<div className="max-w-prose">
|
|
||||||
<p className="mb-4 text-md">
|
|
||||||
If you are running Headscale 0.23-beta1 or later, the
|
|
||||||
ACL configuration is most likely set to
|
|
||||||
{' '}
|
|
||||||
<Code>file</Code>
|
|
||||||
{' '}
|
|
||||||
mode but the ACL file is not available. In order to
|
|
||||||
resolve this you will either need to correctly set
|
|
||||||
{' '}
|
|
||||||
<Code>policy.path</Code>
|
|
||||||
{' '}
|
|
||||||
in your Headscale configuration or set the
|
|
||||||
{' '}
|
|
||||||
<Code>policy.mode</Code>
|
|
||||||
{' '}
|
|
||||||
to
|
|
||||||
{' '}
|
|
||||||
<Code>database</Code>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
const fetcher = useFetcher<typeof action>()
|
const fetcher = useDebounceFetcher<typeof action>()
|
||||||
const [acl, setAcl] = useState(data.currentAcl)
|
const revalidator = useRevalidator()
|
||||||
|
|
||||||
|
const [acl, setAcl] = useState(data.policy ?? '')
|
||||||
const [toasted, setToasted] = useState(false)
|
const [toasted, setToasted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -164,14 +161,39 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setToasted(true)
|
setToasted(true)
|
||||||
setAcl(data.currentAcl)
|
if (revalidator.state === 'idle') {
|
||||||
}, [fetcher.data, toasted, data.currentAcl])
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{data.hasAclWrite
|
{data.read && !data.write
|
||||||
? undefined
|
? (
|
||||||
: (
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Notice className="w-fit">
|
<Notice className="w-fit">
|
||||||
The ACL policy is read-only. You can view the current policy
|
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.
|
database in your Headscale configuration.
|
||||||
</Notice>
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
|
|
||||||
<h1 className="text-2xl font-medium mb-4">
|
<h1 className="text-2xl font-medium mb-4">
|
||||||
Access Control List (ACL)
|
Access Control List (ACL)
|
||||||
@ -209,106 +231,117 @@ export default function Page() {
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Tabs>
|
{fetcher.data?.success === false
|
||||||
<TabList className={cn(
|
? (
|
||||||
'flex border-t border-gray-200 dark:border-gray-700',
|
<ErrorView message={fetcher.data.error} />
|
||||||
'w-fit rounded-t-lg overflow-hidden',
|
) : undefined}
|
||||||
'text-gray-400 dark:text-gray-500',
|
|
||||||
)}
|
{data.read ? (
|
||||||
>
|
<>
|
||||||
<Tab
|
<Tabs>
|
||||||
id="edit"
|
<TabList className={cn(
|
||||||
className={({ isSelected }) => cn(
|
'flex border-t border-gray-200 dark:border-gray-700',
|
||||||
'px-4 py-2 rounded-tl-lg',
|
'w-fit rounded-t-lg overflow-hidden',
|
||||||
'focus:outline-none flex items-center gap-2',
|
'text-gray-400 dark:text-gray-500',
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
id="edit"
|
||||||
|
className={({ isSelected }) => 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' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-5 h-5" />
|
||||||
|
<p>Edit file</p>
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
id="diff"
|
||||||
|
className={({ isSelected }) => 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' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-5 h-5" />
|
||||||
|
<p>Preview changes</p>
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
id="preview"
|
||||||
|
className={({ isSelected }) => 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' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BeakerIcon className="w-5 h-5" />
|
||||||
|
<p>Preview rules</p>
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="edit">
|
||||||
|
<Editor
|
||||||
|
isDisabled={!data.write}
|
||||||
|
value={acl}
|
||||||
|
onChange={setAcl}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="diff">
|
||||||
|
<Differ
|
||||||
|
left={data.policy}
|
||||||
|
right={acl}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="preview">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border border-gray-200 dark:border-gray-700',
|
||||||
|
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||||
|
'p-16 flex flex-col items-center justify-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
<Button
|
||||||
|
variant="heavy"
|
||||||
|
className="mr-2"
|
||||||
|
isDisabled={disabled}
|
||||||
|
onPress={() => {
|
||||||
|
setToasted(false)
|
||||||
|
fetcher.submit({
|
||||||
|
acl,
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PencilIcon className="w-5 h-5" />
|
{fetcher.state === 'idle'
|
||||||
<p>Edit file</p>
|
? undefined
|
||||||
</Tab>
|
: (
|
||||||
<Tab
|
<Spinner className="w-3 h-3" />
|
||||||
id="diff"
|
)}
|
||||||
className={({ isSelected }) => cn(
|
Save
|
||||||
'px-4 py-2',
|
</Button>
|
||||||
'focus:outline-none flex items-center gap-2',
|
<Button
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
isDisabled={disabled}
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
onPress={() => {
|
||||||
)}
|
setAcl(data.policy)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<EyeIcon className="w-5 h-5" />
|
Discard Changes
|
||||||
<p>Preview changes</p>
|
</Button>
|
||||||
</Tab>
|
</>
|
||||||
<Tab
|
) : <Unavailable mode={data.mode} />}
|
||||||
id="preview"
|
|
||||||
className={({ isSelected }) => 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' : '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BeakerIcon className="w-5 h-5" />
|
|
||||||
<p>Preview rules</p>
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
<TabPanel id="edit">
|
|
||||||
<Editor
|
|
||||||
isDisabled={!data.hasAclWrite}
|
|
||||||
defaultValue={data.currentAcl}
|
|
||||||
onChange={setAcl}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel id="diff">
|
|
||||||
<Differ
|
|
||||||
left={data.currentAcl}
|
|
||||||
right={acl}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel id="preview">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'border border-gray-200 dark:border-gray-700',
|
|
||||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
|
||||||
'p-16 flex flex-col items-center justify-center',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
</Tabs>
|
|
||||||
<Button
|
|
||||||
variant="heavy"
|
|
||||||
className="mr-2"
|
|
||||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
|
||||||
onPress={() => {
|
|
||||||
setToasted(false)
|
|
||||||
fetcher.submit({
|
|
||||||
acl,
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle'
|
|
||||||
? undefined
|
|
||||||
: (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
|
|
||||||
onPress={() => { setAcl(data.currentAcl) }}
|
|
||||||
>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/routes/_data.acls._index/unavailable.tsx
Normal file
43
app/routes/_data.acls._index/unavailable.tsx
Normal file
@ -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 (
|
||||||
|
<Card variant="flat" className="max-w-prose mt-12">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Card.Title className="text-xl mb-0">
|
||||||
|
ACL Policy Unavailable
|
||||||
|
</Card.Title>
|
||||||
|
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||||
|
</div>
|
||||||
|
<Card.Text className="mt-4">
|
||||||
|
Unable to load a valid ACL policy configuration.
|
||||||
|
This is most likely due to a misconfiguration in your
|
||||||
|
Headscale configuration file.
|
||||||
|
</Card.Text>
|
||||||
|
|
||||||
|
{mode !== 'file' ? (
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
According to your configuration, the ACL policy mode
|
||||||
|
is set to <Code>file</Code> but the ACL file is not
|
||||||
|
available. Ensure that the <Code>policy.path</Code> is
|
||||||
|
set to a valid path in your Headscale configuration.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
In order to fully utilize the ACL management features of
|
||||||
|
Headplane, please set <Code>policy.mode</Code> to either
|
||||||
|
{' '}<Code>file</Code> or <Code>database</Code> in your
|
||||||
|
Headscale configuration.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -7,3 +7,24 @@
|
|||||||
scrollbar-gutter: stable
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
"react-aria-components": "^1.2.1",
|
"react-aria-components": "^1.2.1",
|
||||||
"react-codemirror-merge": "^4.23.5",
|
"react-codemirror-merge": "^4.23.5",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-error-boundary": "^4.1.2",
|
||||||
"remix-utils": "^7.6.0",
|
"remix-utils": "^7.6.0",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-react-aria-components": "^1.1.3",
|
"tailwindcss-react-aria-components": "^1.1.3",
|
||||||
|
|||||||
@ -82,6 +82,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@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:
|
remix-utils:
|
||||||
specifier: ^7.6.0
|
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)
|
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==}
|
resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
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':
|
'@babel/runtime@7.25.7':
|
||||||
resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==}
|
resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@ -3915,6 +3914,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@ -5019,10 +5023,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime: 0.14.1
|
regenerator-runtime: 0.14.1
|
||||||
|
|
||||||
'@babel/runtime@7.24.7':
|
|
||||||
dependencies:
|
|
||||||
regenerator-runtime: 0.14.1
|
|
||||||
|
|
||||||
'@babel/runtime@7.25.7':
|
'@babel/runtime@7.25.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime: 0.14.1
|
regenerator-runtime: 0.14.1
|
||||||
@ -8739,7 +8739,7 @@ snapshots:
|
|||||||
|
|
||||||
media-query-parser@2.0.2:
|
media-query-parser@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.24.7
|
'@babel/runtime': 7.25.7
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
|
|
||||||
@ -9579,6 +9579,11 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
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-is@16.13.1: {}
|
||||||
|
|
||||||
react-refresh@0.14.2: {}
|
react-refresh@0.14.2: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user