Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
.DS_Store
.vscode
node_modules
package-lock.json
/.react-router
/.cache
/build
Expand Down
16 changes: 9 additions & 7 deletions app/components/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface DialogPanelProps extends AriaDialogProps {
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
confirmLabel?: string;

// Anonymous (passed by parent)
close?: () => void;
Expand All @@ -94,6 +95,7 @@ function Panel(props: DialogPanelProps) {
close,
variant,
method = 'POST',
confirmLabel,
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
Expand All @@ -107,19 +109,19 @@ function Panel(props: DialogPanelProps) {
return (
<Form
{...dialogProps}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}

close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
Expand All @@ -130,11 +132,11 @@ function Panel(props: DialogPanelProps) {
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
{confirmLabel ?? 'Confirm'}
</Button>
</>
)}
Expand Down
14 changes: 13 additions & 1 deletion app/components/Notice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,34 @@ import {
} from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
import cn from '~/utils/cn';

export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: 'default' | 'error' | 'warning';
icon?: React.ReactElement<LucideProps>;
className?: string;
fullWidth?: boolean;
}

export default function Notice({
children,
title,
variant,
icon,
className,
fullWidth,
}: NoticeProps) {
return (
<Card variant="flat" className="max-w-2xl my-6">
<Card
className={cn(
'my-6',
fullWidth ? 'w-full max-w-none' : 'max-w-2xl',
className,
)}
variant="flat"
>
<div className="flex items-center justify-between">
{title ? (
<Card.Title className="text-xl mb-0">{title}</Card.Title>
Expand Down
57 changes: 42 additions & 15 deletions app/components/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import cn from '~/utils/cn';
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
variant?: 'default' | 'pill';
}

function Tabs({ label, className, ...props }: TabsProps) {
function Tabs({ label, className, variant = 'default', ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);

Expand All @@ -23,15 +24,20 @@ function Tabs({ label, className, ...props }: TabsProps) {
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
'flex items-center w-fit',
variant === 'pill'
? 'rounded-full border border-headplane-500/60 bg-transparent h-9 px-1 py-0.5'
: [
'rounded-t-xl',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
],
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
<Tab item={item} key={item.key} state={state} variant={variant} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
Expand All @@ -42,24 +48,37 @@ function Tabs({ label, className, ...props }: TabsProps) {
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
variant: 'default' | 'pill';
}

function Tab({ item, state }: TabsTabProps) {
function Tab({ item, state, variant }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);

const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
variant === 'pill'
? [
'px-4 py-1.5 text-sm font-medium cursor-pointer select-none',
'rounded-full',
'focus:outline-hidden focus:ring-3',
'transition-colors duration-150 ease-in-out',
'text-headplane-400 dark:text-headplane-500',
'hover:text-headplane-200 dark:hover:text-headplane-200',
'aria-selected:bg-headplane-50 aria-selected:text-headplane-900',
]
: [
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
],
)}
ref={ref}
>
{rendered}
</div>
Expand All @@ -73,16 +92,24 @@ export interface TabsPanelProps extends AriaTabPanelProps {
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
const content = state.selectedItem?.props.children;

// If there is no panel content for the selected tab (e.g. header toggles),
// don't render the bordered panel container at all.
if (!content) {
return null;
}

return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
{content}
</div>
);
}
Expand Down
35 changes: 30 additions & 5 deletions app/components/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,51 @@ function Toast({ state, ...props }: ToastProps) {
ref,
);

const content = props.toast.content;
let variant: 'default' | 'error' | 'warning' = 'default';

const contentElement = React.isValidElement(content)
? (content as React.ReactElement<{ 'data-variant'?: string }>)
: null;

if (
contentElement &&
typeof contentElement.props['data-variant'] === 'string'
) {
if (contentElement.props['data-variant'] === 'error') {
variant = 'error';
} else if (contentElement.props['data-variant'] === 'warning') {
variant = 'warning';
}
}

const bgClass =
variant === 'error'
? 'bg-red-600 dark:bg-red-700'
: variant === 'warning'
? 'bg-amber-500 dark:bg-amber-600'
: 'bg-headplane-900 dark:bg-headplane-950';

return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
bgClass,
)}
ref={ref}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
label="Close"
>
<X className="p-1" />
</IconButton>
Expand All @@ -60,11 +85,11 @@ function ToastRegion({ state, ...props }: ToastRegionProps) {
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
ref={ref}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
<Toast key={toast.key} state={state} toast={toast} />
))}
</div>
);
Expand Down
1 change: 1 addition & 0 deletions app/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) {
'border border-headplane-100 dark:border-headplane-800',
className,
)}
style={{ width: 'max-content', maxWidth: 600 }}
>
{props.children}
</span>
Expand Down
56 changes: 47 additions & 9 deletions app/routes/acls/acl-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,30 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
Capabilities.write_policy,
);
if (!check) {
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
return data(
{
success: false,
error: 'You do not have permission to write to the ACL policy',
policy: undefined,
updatedAt: undefined,
},
403,
);
}

// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data('Missing `policy` in the form data.', {
status: 400,
});
return data(
{
success: false,
error: 'Missing `policy` in the form data.',
policy: undefined,
updatedAt: undefined,
},
400,
);
}

const api = context.hsApi.getRuntimeClient(session.api_key);
Expand All @@ -41,7 +53,15 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes('update is disabled')) {
throw data('Policy is not writable', { status: 403 });
return data(
{
success: false,
error: 'Policy is not writable',
policy: undefined,
updatedAt: undefined,
},
403,
);
}

const message =
Expand Down Expand Up @@ -133,7 +153,25 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
}
}

// Otherwise, this is a Headscale error that we can just propagate.
throw error;
// Otherwise, this is a Headscale or generic error. Don't crash the route;
// instead, surface a generic error payload that the UI can show in a toast.
console.error(error);

const message =
error instanceof ResponseError && error.responseObject?.message
? (error.responseObject.message as string)
: error instanceof Error
? error.message
: 'An unexpected error occurred while updating the ACL policy.';

return data(
{
success: false,
error: `Policy error: ${message}`,
policy: undefined,
updatedAt: undefined,
},
500,
);
}
}
Loading