feat: unify all colors
This commit is contained in:
parent
d1f6c450c0
commit
287ac2dff0
@ -54,7 +54,7 @@ export default function Attribute({
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
<p className="truncate">{value}</p>
|
||||||
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
||||||
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
|
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
import { LinkExternalIcon } from '@primer/octicons-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
interface Props {
|
export interface LinkProps {
|
||||||
to: string;
|
to: string;
|
||||||
name: string;
|
name: string;
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Link({ to, name: alt, children, className }: Props) {
|
export default function Link({
|
||||||
|
to,
|
||||||
|
name: alt,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: LinkProps) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={to}
|
href={to}
|
||||||
@ -16,14 +21,14 @@ export default function Link({ to, name: alt, children, className }: Props) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-x-1',
|
'inline-flex items-center gap-x-0.5',
|
||||||
'text-blue-500 hover:text-blue-700',
|
'text-blue-500 hover:text-blue-700',
|
||||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<LinkExternalIcon className="h-3 w-3" />
|
<ExternalLink className="w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,7 +97,10 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
|||||||
{section.key !== state.collection.getFirstKey() ? (
|
{section.key !== state.collection.getFirstKey() ? (
|
||||||
<li
|
<li
|
||||||
{...separatorProps}
|
{...separatorProps}
|
||||||
className="border-t border-gray-300 mx-2 mt-1 mb-1"
|
className={cn(
|
||||||
|
'mx-2 mt-1 mb-1 border-t',
|
||||||
|
'border-headplane-200 dark:border-headplane-800',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<li {...itemProps}>
|
<li {...itemProps}>
|
||||||
|
|||||||
@ -1,23 +1,16 @@
|
|||||||
import { InfoIcon } from '@primer/octicons-react';
|
import { CircleSlash2 } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import React from 'react';
|
||||||
import cn from '~/utils/cn';
|
import Card from '~/components/Card';
|
||||||
|
|
||||||
interface Props {
|
export interface NoticeProps {
|
||||||
className?: string;
|
children: React.ReactNode;
|
||||||
children: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Notice({ children, className }: Props) {
|
export default function Notice({ children }: NoticeProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Card className="flex w-full max-w-full gap-4 font-semibold">
|
||||||
className={cn(
|
<CircleSlash2 />
|
||||||
'p-4 rounded-md w-full flex items-center gap-3',
|
|
||||||
'bg-ui-200 dark:bg-ui-800',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,9 @@ import { Item, ListState, Node, useComboBoxState } from 'react-stately';
|
|||||||
import Popover from '~/components/Popover';
|
import Popover from '~/components/Popover';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
export interface SelectProps extends AriaComboBoxProps<object> {}
|
export interface SelectProps extends AriaComboBoxProps<object> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function Select(props: SelectProps) {
|
function Select(props: SelectProps) {
|
||||||
const { contains } = useFilter({ sensitivity: 'base' });
|
const { contains } = useFilter({ sensitivity: 'base' });
|
||||||
@ -45,7 +47,7 @@ function Select(props: SelectProps) {
|
|||||||
|
|
||||||
const { buttonProps } = useButton(triggerProps, buttonRef);
|
const { buttonProps } = useButton(triggerProps, buttonRef);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className={cn('flex flex-col', props.className)}>
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
|
|||||||
@ -6,14 +6,13 @@ interface Props {
|
|||||||
|
|
||||||
export default function Spinner({ className }: Props) {
|
export default function Spinner({ className }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
|
<div className={clsx('inline-block align-middle mb-0.5', className)}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'animate-spin rounded-full w-full h-full',
|
'animate-spin rounded-full w-full h-full',
|
||||||
'border-2 border-current border-t-transparent',
|
'border-2 border-current border-t-transparent',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="status"
|
|
||||||
>
|
>
|
||||||
<span className="sr-only">Loading...</span>
|
<span className="sr-only">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,23 +1,26 @@
|
|||||||
import clsx from 'clsx';
|
import cn from '~/utils/cn';
|
||||||
import type { HTMLProps } from 'react';
|
|
||||||
|
|
||||||
type Props = HTMLProps<SVGElement> & {
|
export interface StatusCircleProps {
|
||||||
readonly isOnline: boolean;
|
isOnline: boolean;
|
||||||
};
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
export default function StatusCircle({
|
||||||
export default function StatusCircle({ isOnline, className }: Props) {
|
isOnline,
|
||||||
|
className,
|
||||||
|
}: StatusCircleProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={clsx(
|
className={cn(
|
||||||
className,
|
|
||||||
isOnline
|
isOnline
|
||||||
? 'text-green-700 dark:text-green-400'
|
? 'text-green-600 dark:text-green-500'
|
||||||
: 'text-gray-300 dark:text-gray-500',
|
: 'text-headplane-200 dark:text-headplane-800',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
|
<title>{isOnline ? 'Online' : 'Offline'}</title>
|
||||||
<circle cx="12" cy="12" r="8" />
|
<circle cx="12" cy="12" r="8" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import type { HTMLProps } from 'react';
|
import type { HTMLProps } from 'react';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'rounded-xl',
|
'rounded-xl',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
props.className,
|
props.className,
|
||||||
@ -20,7 +20,7 @@ function Item(props: HTMLProps<HTMLDivElement>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'flex items-center justify-between p-2 last:border-b-0',
|
'flex items-center justify-between p-2 last:border-b-0',
|
||||||
'border-b border-headplane-100 dark:border-headplane-800',
|
'border-b border-headplane-100 dark:border-headplane-800',
|
||||||
props.className,
|
props.className,
|
||||||
|
|||||||
@ -8,6 +8,6 @@ export interface TitleProps {
|
|||||||
|
|
||||||
export default function Title({ children, className }: TitleProps) {
|
export default function Title({ children, className }: TitleProps) {
|
||||||
return (
|
return (
|
||||||
<h3 className={cn('text-2xl font-bold mb-6', className)}>{children}</h3>
|
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export interface TooltipProps extends AriaTooltipProps {
|
|||||||
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
|
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Fix Button accessibility outline + invoke
|
||||||
function Tooltip(props: TooltipProps) {
|
function Tooltip(props: TooltipProps) {
|
||||||
const state = useTooltipTriggerState({
|
const state = useTooltipTriggerState({
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@ -7,15 +7,28 @@ interface Props {
|
|||||||
|
|
||||||
export default function Fallback({ acl }: Props) {
|
export default function Fallback({ acl }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-block relative w-full h-editor">
|
<div className="relative w-full h-editor flex">
|
||||||
<Spinner className="w-4 h-4 absolute p-2" />
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-8 flex justify-center p-1',
|
||||||
|
'border-r border-headscale-400 dark:border-headscale-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5 animate-spin rounded-full',
|
||||||
|
'border-headplane-900 dark:border-headplane-100',
|
||||||
|
'border-2 border-t-transparent dark:border-t-transparent',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-editor font-mono resize-none',
|
'w-full h-editor font-mono resize-none text-sm',
|
||||||
'text-sm text-gray-600 dark:text-gray-300',
|
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
|
||||||
'bg-ui-100 dark:bg-ui-800',
|
'pl-1 pt-1 leading-snug',
|
||||||
'pl-10 pt-1 leading-snug',
|
|
||||||
)}
|
)}
|
||||||
value={acl}
|
value={acl}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -90,7 +90,6 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
export default function Page() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const showOr = useMemo(() => data.oidc && data.apiKey, [data]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
@ -98,7 +97,7 @@ export default function Page() {
|
|||||||
<Card.Title>Welcome to Headplane</Card.Title>
|
<Card.Title>Welcome to Headplane</Card.Title>
|
||||||
{data.apiKey ? (
|
{data.apiKey ? (
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
<Card.Text className="mb-8 text-sm">
|
<Card.Text>
|
||||||
Enter an API key to authenticate with Headplane. You can generate
|
Enter an API key to authenticate with Headplane. You can generate
|
||||||
one by running <Code>headscale apikeys create</Code> in your
|
one by running <Code>headscale apikeys create</Code> in your
|
||||||
terminal.
|
terminal.
|
||||||
@ -109,27 +108,33 @@ export default function Page() {
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
<Input
|
<Input
|
||||||
isRequired
|
isRequired
|
||||||
|
labelHidden
|
||||||
label="API Key"
|
label="API Key"
|
||||||
name="api-key"
|
name="api-key"
|
||||||
placeholder="API Key"
|
placeholder="API Key"
|
||||||
type="password"
|
type="password"
|
||||||
|
className="mt-4 mb-2"
|
||||||
/>
|
/>
|
||||||
<Button className="w-full mt-2.5" variant="heavy" type="submit">
|
<Button className="w-full" variant="heavy" type="submit">
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{showOr ? (
|
|
||||||
<div className="flex items-center gap-x-1.5 py-1">
|
|
||||||
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
|
||||||
<span className="text-gray-500 text-sm">or</span>
|
|
||||||
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
{data.oidc ? (
|
{data.oidc ? (
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
|
{!data.apiKey ? (
|
||||||
|
<Card.Text className="mb-6">
|
||||||
|
Sign in with your authentication provider to continue. Your
|
||||||
|
administrator has disabled API key login.
|
||||||
|
</Card.Text>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
<input type="hidden" name="oidc-start" value="true" />
|
<input type="hidden" name="oidc-start" value="true" />
|
||||||
<Button className="w-full" type="submit">
|
<Button
|
||||||
|
className="w-full mt-2"
|
||||||
|
variant={data.apiKey ? 'light' : 'heavy'}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
Single Sign-On
|
Single Sign-On
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function DNS({ records, isDisabled }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p>
|
||||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||||
only <Code>A</Code> records are supported.{' '}
|
only <Code>A</Code> records are supported.{' '}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -24,8 +24,7 @@ import cn from '~/utils/cn';
|
|||||||
type Properties = {
|
type Properties = {
|
||||||
readonly baseDomain?: string;
|
readonly baseDomain?: string;
|
||||||
readonly searchDomains: string[];
|
readonly searchDomains: string[];
|
||||||
// eslint-disable-next-line react/boolean-prop-naming
|
readonly disabled?: boolean; // TODO: isDisabled
|
||||||
readonly disabled?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Domains({
|
export default function Domains({
|
||||||
@ -33,7 +32,6 @@ export default function Domains({
|
|||||||
searchDomains,
|
searchDomains,
|
||||||
disabled,
|
disabled,
|
||||||
}: Properties) {
|
}: Properties) {
|
||||||
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
|
|
||||||
const [activeId, setActiveId] = useState<number | string | null>(null);
|
const [activeId, setActiveId] = useState<number | string | null>(null);
|
||||||
const [localDomains, setLocalDomains] = useState(searchDomains);
|
const [localDomains, setLocalDomains] = useState(searchDomains);
|
||||||
const [newDomain, setNewDomain] = useState('');
|
const [newDomain, setNewDomain] = useState('');
|
||||||
@ -46,7 +44,7 @@ export default function Domains({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-2">
|
<p className="mb-4">
|
||||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||||
your tailnet domain is used as the first search domain.
|
your tailnet domain is used as the first search domain.
|
||||||
</p>
|
</p>
|
||||||
@ -57,7 +55,6 @@ export default function Domains({
|
|||||||
setActiveId(event.active.id);
|
setActiveId(event.active.id);
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
// eslint-disable-next-line unicorn/no-null
|
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) {
|
if (!over) {
|
||||||
@ -82,7 +79,12 @@ export default function Domains({
|
|||||||
<TableList>
|
<TableList>
|
||||||
{baseDomain ? (
|
{baseDomain ? (
|
||||||
<TableList.Item key="magic-dns-sd">
|
<TableList.Item key="magic-dns-sd">
|
||||||
<div className="flex items-center gap-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-4',
|
||||||
|
disabled ? 'flex-row-reverse justify-between w-full' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Lock className="p-0.5" />
|
<Lock className="p-0.5" />
|
||||||
<p className="font-mono text-sm py-0.5">{baseDomain}</p>
|
<p className="font-mono text-sm py-0.5">{baseDomain}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -138,7 +140,6 @@ export default function Domains({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit(
|
fetcher.submit(
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns.search_domains': [...localDomains, newDomain],
|
'dns.search_domains': [...localDomains, newDomain],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -204,11 +205,6 @@ function Domain({
|
|||||||
<p className="font-mono text-sm flex items-center gap-4">
|
<p className="font-mono text-sm flex items-center gap-4">
|
||||||
{disabled ? undefined : (
|
{disabled ? undefined : (
|
||||||
<GripVertical {...attributes} {...listeners} className="p-0.5" />
|
<GripVertical {...attributes} {...listeners} className="p-0.5" />
|
||||||
// <ThreeBarsIcon
|
|
||||||
// className="h-4 w-4 text-gray-400 focus:outline-none"
|
|
||||||
// {...attributes}
|
|
||||||
// {...listeners}
|
|
||||||
// />
|
|
||||||
)}
|
)}
|
||||||
{domain}
|
{domain}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p>
|
||||||
Set the nameservers used by devices on the Tailnet to resolve DNS
|
Set the nameservers used by devices on the Tailnet to resolve DNS
|
||||||
queries.{' '}
|
queries.{' '}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
<p className="mb-4">
|
||||||
Automatically register domain names for each device on the tailnet.
|
Automatically register domain names for each device on the tailnet.
|
||||||
Devices will be accessible at{' '}
|
Devices will be accessible at{' '}
|
||||||
<Code>
|
<Code>
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export default function MachineRow({
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={machine.id}
|
key={machine.id}
|
||||||
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
|
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||||
>
|
>
|
||||||
<td className="pl-0.5 py-2">
|
<td className="pl-0.5 py-2">
|
||||||
<Link to={`/machines/${machine.id}`} className="group/link h-full">
|
<Link to={`/machines/${machine.id}`} className="group/link h-full">
|
||||||
@ -103,9 +103,7 @@ export default function MachineRow({
|
|||||||
>
|
>
|
||||||
{machine.givenName}
|
{machine.givenName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
|
<p className="text-sm font-mono opacity-50">{machine.name}</p>
|
||||||
{machine.name}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Chip key={tag} text={tag} />
|
<Chip key={tag} text={tag} />
|
||||||
@ -152,12 +150,12 @@ export default function MachineRow({
|
|||||||
<p className="leading-snug">
|
<p className="leading-snug">
|
||||||
{hinfo.getTSVersion(stats)}
|
{hinfo.getTSVersion(stats)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 max-w-48 truncate">
|
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||||
{hinfo.getOSInfo(stats)}
|
{hinfo.getOSInfo(stats)}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
<p className="text-sm opacity-50">
|
||||||
Unknown
|
Unknown
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -167,7 +165,7 @@ export default function MachineRow({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-x-1 text-sm',
|
'flex items-center gap-x-1 text-sm',
|
||||||
'text-gray-500 dark:text-gray-400',
|
'text-headplane-600 dark:text-headplane-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusCircle
|
<StatusCircle
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default function Rename({
|
|||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
|
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text className="mb-6">
|
||||||
This name is shown in the admin panel, in Tailscale clients, and used
|
This name is shown in the admin panel, in Tailscale clients, and used
|
||||||
when generating MagicDNS names.
|
when generating MagicDNS names.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
@ -38,7 +38,7 @@ export default function Rename({
|
|||||||
/>
|
/>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
name.length > 0 && name !== machine.givenName ? (
|
name.length > 0 && name !== machine.givenName ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
|
||||||
This machine will be accessible by the hostname{' '}
|
This machine will be accessible by the hostname{' '}
|
||||||
<Code className="text-sm">
|
<Code className="text-sm">
|
||||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||||
@ -48,7 +48,7 @@ export default function Rename({
|
|||||||
will no longer point to this machine.
|
will no longer point to this machine.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
|
||||||
This machine is accessible by the hostname{' '}
|
This machine is accessible by the hostname{' '}
|
||||||
<Code className="text-sm">{machine.givenName}</Code>.
|
<Code className="text-sm">{machine.givenName}</Code>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import { GlobeLock, RouteOff } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFetcher } from 'react-router';
|
import { useFetcher } from 'react-router';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Switch from '~/components/Switch';
|
import Switch from '~/components/Switch';
|
||||||
|
import TableList from '~/components/TableList';
|
||||||
import type { Machine, Route } from '~/types';
|
import type { Machine, Route } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
@ -59,32 +61,17 @@ export default function Routes({
|
|||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<div
|
<TableList className="mt-4">
|
||||||
className={cn(
|
|
||||||
'rounded-lg overflow-y-auto my-2',
|
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
|
||||||
'border border-zinc-200 dark:border-zinc-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subnet.length === 0 ? (
|
{subnet.length === 0 ? (
|
||||||
<div
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
className={cn(
|
<RouteOff />
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
<p className="font-semibold">
|
||||||
'items-center justify-center',
|
No routes are advertised by this machine
|
||||||
'text-ui-600 dark:text-ui-300',
|
</p>
|
||||||
)}
|
</TableList.Item>
|
||||||
>
|
|
||||||
<p>No routes are advertised on this machine.</p>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{subnet.map((route) => (
|
{subnet.map((route) => (
|
||||||
<div
|
<TableList.Item key={route.id}>
|
||||||
key={route.id}
|
|
||||||
className={cn(
|
|
||||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-between',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>{route.prefix}</p>
|
<p>{route.prefix}</p>
|
||||||
<Switch
|
<Switch
|
||||||
defaultSelected={route.enabled}
|
defaultSelected={route.enabled}
|
||||||
@ -101,9 +88,9 @@ export default function Routes({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableList.Item>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TableList>
|
||||||
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
|
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Allow your network to route internet traffic through this machine.{' '}
|
Allow your network to route internet traffic through this machine.{' '}
|
||||||
@ -114,30 +101,14 @@ export default function Routes({
|
|||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<div
|
<TableList className="mt-4">
|
||||||
className={cn(
|
|
||||||
'rounded-lg overflow-y-auto my-2',
|
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
|
||||||
'border border-zinc-200 dark:border-zinc-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{exit.length === 0 ? (
|
{exit.length === 0 ? (
|
||||||
<div
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
className={cn(
|
<GlobeLock />
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
<p className="font-semibold">This machine is not an exit node</p>
|
||||||
'items-center justify-center',
|
</TableList.Item>
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>This machine is not an exit node.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<TableList.Item>
|
||||||
className={cn(
|
|
||||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-between',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>Use as exit node</p>
|
<p>Use as exit node</p>
|
||||||
<Switch
|
<Switch
|
||||||
defaultSelected={exitEnabled}
|
defaultSelected={exitEnabled}
|
||||||
@ -154,9 +125,9 @@ export default function Routes({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TableList.Item>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableList>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, TagsIcon, X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
@ -38,15 +38,10 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
|||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||||
<TableList className="mt-4">
|
<TableList className="mt-4">
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<div
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
className={cn(
|
<TagsIcon />
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
<p className="font-semibold">No tags are set on this machine</p>
|
||||||
'items-center justify-center rounded-t-lg',
|
</TableList.Item>
|
||||||
'text-ui-600 dark:text-ui-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>No tags are set on this machine.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
tags.map((item) => (
|
tags.map((item) => (
|
||||||
<TableList.Item className="font-mono" key={item} id={item}>
|
<TableList.Item className="font-mono" key={item} id={item}>
|
||||||
|
|||||||
@ -1,20 +1,12 @@
|
|||||||
import {
|
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||||
CheckCircleIcon,
|
|
||||||
GearIcon,
|
|
||||||
InfoIcon,
|
|
||||||
PersonIcon,
|
|
||||||
SkipIcon,
|
|
||||||
} from '@primer/octicons-react';
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute';
|
import Attribute from '~/components/Attribute';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
import Chip from '~/components/Chip';
|
import Chip from '~/components/Chip';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Menu from '~/components/Menu';
|
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
import StatusCircle from '~/components/StatusCircle';
|
||||||
import Tooltip from '~/components/Tooltip';
|
import Tooltip from '~/components/Tooltip';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { Machine, Route, User } from '~/types';
|
||||||
@ -23,7 +15,6 @@ import { loadContext } from '~/utils/config/headplane';
|
|||||||
import { loadConfig } from '~/utils/config/headscale';
|
import { loadConfig } from '~/utils/config/headscale';
|
||||||
import { pull } from '~/utils/headscale';
|
import { pull } from '~/utils/headscale';
|
||||||
import { getSession } from '~/utils/sessions.server';
|
import { getSession } from '~/utils/sessions.server';
|
||||||
|
|
||||||
import { menuAction } from './action';
|
import { menuAction } from './action';
|
||||||
import MenuOptions from './components/menu';
|
import MenuOptions from './components/menu';
|
||||||
import Routes from './dialogs/routes';
|
import Routes from './dialogs/routes';
|
||||||
@ -126,11 +117,11 @@ export default function Page() {
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-between items-center',
|
'flex justify-between items-center pb-2',
|
||||||
'border-b border-ui-100 dark:border-ui-800',
|
'border-b border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-x-4 text-sm mb-4">
|
<span className="flex items-baseline gap-x-4 text-sm">
|
||||||
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
|
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
|
||||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
@ -144,30 +135,25 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="flex gap-1 mb-4">
|
||||||
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
|
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||||
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
Managed by
|
Managed by
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<Info className="p-1" />
|
||||||
<Tooltip.Body>
|
<Tooltip.Body>
|
||||||
By default, a machine’s permissions match its creator’s.
|
By default, a machine’s permissions match its creator’s.
|
||||||
</Tooltip.Body>
|
</Tooltip.Body>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-x-2.5 mt-1">
|
<div className="flex items-center gap-x-2.5 mt-1">
|
||||||
<div
|
<UserCircle />
|
||||||
className={cn(
|
|
||||||
'rounded-full h-7 w-7 flex items-center justify-center',
|
|
||||||
'border border-ui-200 dark:border-ui-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PersonIcon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
{machine.user.name}
|
{machine.user.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 pl-4">
|
<div className="p-2 pl-4">
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
|
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
<div className="flex gap-1 mt-1 mb-8">
|
<div className="flex gap-1 mt-1 mb-8">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Chip key={tag} text={tag} />
|
<Chip key={tag} text={tag} />
|
||||||
@ -202,10 +188,10 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
Approved
|
Approved
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<Info className="w-3.5 h-3.5" />
|
||||||
<Tooltip.Body>
|
<Tooltip.Body>
|
||||||
Traffic to these routes are being routed through this machine.
|
Traffic to these routes are being routed through this machine.
|
||||||
</Tooltip.Body>
|
</Tooltip.Body>
|
||||||
@ -213,7 +199,7 @@ export default function Page() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{subnetApproved.length === 0 ? (
|
{subnetApproved.length === 0 ? (
|
||||||
<span className="text-ui-400 dark:text-ui-300">—</span>
|
<span className="opacity-50">—</span>
|
||||||
) : (
|
) : (
|
||||||
<ul className="leading-normal">
|
<ul className="leading-normal">
|
||||||
{subnetApproved.map((route) => (
|
{subnetApproved.map((route) => (
|
||||||
@ -233,10 +219,10 @@ export default function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
Awaiting Approval
|
Awaiting Approval
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<Info className="w-3.5 h-3.5" />
|
||||||
<Tooltip.Body>
|
<Tooltip.Body>
|
||||||
This machine is advertising these routes, but they must be
|
This machine is advertising these routes, but they must be
|
||||||
approved before traffic will be routed to them.
|
approved before traffic will be routed to them.
|
||||||
@ -245,7 +231,7 @@ export default function Page() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{subnet.length === 0 ? (
|
{subnet.length === 0 ? (
|
||||||
<span className="text-ui-400 dark:text-ui-300">—</span>
|
<span className="opacity-50">—</span>
|
||||||
) : (
|
) : (
|
||||||
<ul className="leading-normal">
|
<ul className="leading-normal">
|
||||||
{subnet.map((route) => (
|
{subnet.map((route) => (
|
||||||
@ -265,10 +251,10 @@ export default function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
Exit Node
|
Exit Node
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<Info className="w-3.5 h-3.5" />
|
||||||
<Tooltip.Body>
|
<Tooltip.Body>
|
||||||
Whether this machine can act as an exit node for your tailnet.
|
Whether this machine can act as an exit node for your tailnet.
|
||||||
</Tooltip.Body>
|
</Tooltip.Body>
|
||||||
@ -276,15 +262,15 @@ export default function Page() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{exit.length === 0 ? (
|
{exit.length === 0 ? (
|
||||||
<span className="text-ui-400 dark:text-ui-300">—</span>
|
<span className="opacity-50">—</span>
|
||||||
) : exitEnabled ? (
|
) : exitEnabled ? (
|
||||||
<span className="flex items-center gap-x-1">
|
<span className="flex items-center gap-x-1">
|
||||||
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
|
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||||||
Allowed
|
Allowed
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-x-1">
|
<span className="flex items-center gap-x-1">
|
||||||
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
|
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
|
||||||
Awaiting Approval
|
Awaiting Approval
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -59,10 +59,10 @@ export default function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
<h1 className="text-2xl font-medium mb-2">Machines</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p>
|
||||||
Manage the devices connected to your Tailnet.{' '}
|
Manage the devices connected to your Tailnet.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1372/manage-devices"
|
to="https://tailscale.com/kb/1372/manage-devices"
|
||||||
@ -78,9 +78,9 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</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-headplane-600 dark:text-headplane-300">
|
||||||
<tr className="text-left px-0.5">
|
<tr className="text-left px-0.5">
|
||||||
<th className="pb-2">Name</th>
|
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
||||||
<th className="pb-2 w-1/4">
|
<th className="pb-2 w-1/4">
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||||
@ -99,14 +99,14 @@ export default function Page() {
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
{/**<th className="pb-2">Version</th>**/}
|
{/**<th className="uppercase text-xs font-bold pb-2">Version</th>**/}
|
||||||
<th className="pb-2">Last Seen</th>
|
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
className={cn(
|
className={cn(
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||||
'border-t border-zinc-200 dark:border-zinc-700',
|
'border-t border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.nodes.map((machine) => (
|
{data.nodes.map((machine) => (
|
||||||
|
|||||||
@ -119,31 +119,31 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { keys, users, server } = useLoaderData<typeof loader>();
|
const { keys, users, server } = useLoaderData<typeof loader>();
|
||||||
const [user, setUser] = useState('All');
|
const [user, setUser] = useState('__headplane_all');
|
||||||
const [status, setStatus] = useState('Active');
|
const [status, setStatus] = useState('active');
|
||||||
|
|
||||||
const filteredKeys = keys.filter((key) => {
|
const filteredKeys = keys.filter((key) => {
|
||||||
if (user !== 'All' && key.user !== user) {
|
if (user !== '__headplane_all' && key.user !== user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status !== 'All') {
|
if (status !== 'all') {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiry = new Date(key.expiration);
|
const expiry = new Date(key.expiration);
|
||||||
|
|
||||||
if (status === 'Active') {
|
if (status === 'active') {
|
||||||
return !(expiry < now) && (!key.used || key.reusable);
|
return !(expiry < now) && (!key.used || key.reusable);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'Used/Expired') {
|
if (status === 'expired') {
|
||||||
return key.used || expiry < now;
|
return key.used || expiry < now;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'Reusable') {
|
if (status === 'reusable') {
|
||||||
return key.reusable;
|
return key.reusable;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'Ephemeral') {
|
if (status === 'ephemeral') {
|
||||||
return key.ephemeral;
|
return key.ephemeral;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -160,8 +160,8 @@ export default function Page() {
|
|||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span> Pre-Auth Keys
|
<span className="mx-2">/</span> Pre-Auth Keys
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
<p className="mb-4">
|
||||||
Headscale fully supports pre-authentication keys in order to easily add
|
Headscale fully supports pre-authentication keys in order to easily add
|
||||||
devices to your Tailnet. To learn more about using pre-authentication
|
devices to your Tailnet. To learn more about using pre-authentication
|
||||||
keys, visit the{' '}
|
keys, visit the{' '}
|
||||||
@ -173,40 +173,34 @@ export default function Page() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<AddPreAuthKey users={users} />
|
<AddPreAuthKey users={users} />
|
||||||
<div className="flex justify-between gap-4 mt-4">
|
<div className="flex items-center gap-4 mt-4">
|
||||||
<div className="w-full">
|
<Select
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
label="Filter by User"
|
||||||
Filter by user
|
placeholder="Select a user"
|
||||||
</p>
|
className="w-full"
|
||||||
<Select
|
defaultSelectedKey="__headplane_all"
|
||||||
label="Filter by User"
|
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
|
||||||
placeholder="Select a user"
|
>
|
||||||
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
|
{[
|
||||||
>
|
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||||
{[
|
...users.map((user) => (
|
||||||
<Select.Item key="All">All</Select.Item>,
|
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||||
...users.map((user) => (
|
)),
|
||||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
]}
|
||||||
)),
|
</Select>
|
||||||
]}
|
<Select
|
||||||
</Select>
|
label="Filter by status"
|
||||||
</div>
|
placeholder="Select a status"
|
||||||
<div className="w-full">
|
className="w-full"
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
defaultSelectedKey="active"
|
||||||
Filter by status
|
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
|
||||||
</p>
|
>
|
||||||
<Select
|
<Select.Item key="all">All</Select.Item>
|
||||||
label="Filter by status"
|
<Select.Item key="active">Active</Select.Item>
|
||||||
placeholder="Select a status"
|
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||||
defaultSelectedKey="Active"
|
<Select.Item key="reusable">Reusable</Select.Item>
|
||||||
>
|
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||||
<Select.Item key="All">All</Select.Item>
|
</Select>
|
||||||
<Select.Item>Active</Select.Item>
|
|
||||||
<Select.Item>Used/Expired</Select.Item>
|
|
||||||
<Select.Item>Reusable</Select.Item>
|
|
||||||
<Select.Item>Ephemeral</Select.Item>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<TableList className="mt-4">
|
<TableList className="mt-4">
|
||||||
{filteredKeys.length === 0 ? (
|
{filteredKeys.length === 0 ? (
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default function AgentSection() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
|
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p>
|
||||||
Headplane provides a local agent that can be installed on a server to
|
Headplane provides a local agent that can be installed on a server to
|
||||||
provide additional features including viewing device information and
|
provide additional features including viewing device information and
|
||||||
SSH access via the web interface (soon). To learn more about the agent
|
SSH access via the web interface (soon). To learn more about the agent
|
||||||
@ -23,12 +23,7 @@ export default function AgentSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<RemixLink to="/settings/local-agent">
|
<RemixLink to="/settings/local-agent">
|
||||||
<div
|
<div className={cn('text-lg font-medium flex items-center')}>
|
||||||
className={cn(
|
|
||||||
'text-lg font-medium flex items-center',
|
|
||||||
'text-gray-700 dark:text-gray-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Manage Agent
|
Manage Agent
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Card from '~/components/Card'
|
import Card from '~/components/Card';
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
import StatusCircle from '~/components/StatusCircle';
|
||||||
import type { HostInfo } from '~/types';
|
import type { HostInfo } from '~/types';
|
||||||
import * as hinfo from '~/utils/host-info';
|
import * as hinfo from '~/utils/host-info';
|
||||||
|
|
||||||
@ -12,26 +12,21 @@ export default function AgentManagement({ reachable, hostInfo }: Props) {
|
|||||||
console.log('hostInfo:', hostInfo);
|
console.log('hostInfo:', hostInfo);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">
|
<h1 className="text-2xl font-medium mb-4">Local Agent Configuration</h1>
|
||||||
Local Agent Configuration
|
<p className="mb-8">
|
||||||
</h1>
|
A local agent has already been configured for this Headplane instance.
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-8">
|
You can manage the agent settings here.
|
||||||
A local agent has already been configured for this
|
|
||||||
Headplane instance. You can manage the agent settings here.
|
|
||||||
</p>
|
</p>
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusCircle
|
<StatusCircle isOnline={reachable} className="w-4 h-4 px-1" />
|
||||||
isOnline={reachable}
|
|
||||||
className="w-4 h-4 px-1 w-fit"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold">
|
<p className="text-lg font-bold">
|
||||||
{hostInfo.Hostname ?? 'Unknown'}
|
{hostInfo.Hostname ?? 'Unknown'}
|
||||||
</p>
|
</p>
|
||||||
<p className="leading-snug">
|
<p className="leading-snug">
|
||||||
{hinfo.getTSVersion(hostInfo)}
|
{hinfo.getTSVersion(hostInfo)}
|
||||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-300">
|
<span className="ml-2 text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
{hinfo.getOSInfo(hostInfo)}
|
{hinfo.getOSInfo(hostInfo)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@ -40,5 +35,5 @@ export default function AgentManagement({ reachable, hostInfo }: Props) {
|
|||||||
{JSON.stringify(hostInfo)}
|
{JSON.stringify(hostInfo)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export default function Page() {
|
|||||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p>
|
||||||
The settings page is still under construction. As I'm able to add more
|
The settings page is still under construction. As I'm able to add more
|
||||||
features, I'll be adding them here. If you require any features, feel
|
features, I'll be adding them here. If you require any features, feel
|
||||||
free to open an issue on the GitHub repository.
|
free to open an issue on the GitHub repository.
|
||||||
@ -19,7 +19,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p>
|
||||||
Headscale fully supports pre-authentication keys in order to easily
|
Headscale fully supports pre-authentication keys in order to easily
|
||||||
add devices to your Tailnet. To learn more about using
|
add devices to your Tailnet. To learn more about using
|
||||||
pre-authentication keys, visit the{' '}
|
pre-authentication keys, visit the{' '}
|
||||||
@ -32,12 +32,7 @@ export default function Page() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<RemixLink to="/settings/auth-keys">
|
<RemixLink to="/settings/auth-keys">
|
||||||
<div
|
<div className="text-lg font-medium flex items-center">
|
||||||
className={cn(
|
|
||||||
'text-lg font-medium flex items-center',
|
|
||||||
'text-gray-700 dark:text-gray-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Manage Auth Keys
|
Manage Auth Keys
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,10 +13,10 @@ export default function Auth({ magic }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||||
<div className="flex flex-col md:flex-row">
|
<div className="flex flex-col md:flex-row">
|
||||||
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
||||||
<HomeIcon className="w-5 h-5 mb-2" />
|
<HomeIcon className="w-5 h-5 mb-2" />
|
||||||
<h2 className="font-medium mb-1">Basic Authentication</h2>
|
<h2 className="font-medium mb-1">Basic Authentication</h2>
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
Users are not managed externally. Using OpenID Connect can create a
|
Users are not managed externally. Using OpenID Connect can create a
|
||||||
better experience when using Headscale.{' '}
|
better experience when using Headscale.{' '}
|
||||||
<Link
|
<Link
|
||||||
@ -27,10 +27,10 @@ export default function Auth({ magic }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
|
||||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||||
<h2 className="font-medium mb-1">User Management</h2>
|
<h2 className="font-medium mb-1">User Management</h2>
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
You can add, remove, and rename users here.
|
You can add, remove, and rename users here.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
|||||||
@ -15,10 +15,10 @@ export default function Oidc({ oidc, magic }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||||
<div className="flex flex-col md:flex-row">
|
<div className="flex flex-col md:flex-row">
|
||||||
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
||||||
<OrganizationIcon className="w-5 h-5 mb-2" />
|
<OrganizationIcon className="w-5 h-5 mb-2" />
|
||||||
<h2 className="font-medium mb-1">OpenID Connect</h2>
|
<h2 className="font-medium mb-1">OpenID Connect</h2>
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
Users are managed through your{' '}
|
Users are managed through your{' '}
|
||||||
<Link to={oidc.issuer} name="OIDC Provider">
|
<Link to={oidc.issuer} name="OIDC Provider">
|
||||||
OpenID Connect provider
|
OpenID Connect provider
|
||||||
@ -33,10 +33,10 @@ export default function Oidc({ oidc, magic }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
|
||||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||||
<h2 className="font-medium mb-1">User Management</h2>
|
<h2 className="font-medium mb-1">User Management</h2>
|
||||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
You can still add users manually, however it is recommended that you
|
You can still add users manually, however it is recommended that you
|
||||||
manage users through your OIDC provider.
|
manage users through your OIDC provider.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -253,7 +253,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center w-full gap-2 py-1',
|
'flex items-center w-full gap-2 py-1',
|
||||||
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
|
'hover:bg-headplane-50 dark:hover:bg-headplane-950 rounded-xl',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: transform
|
transform: transform
|
||||||
@ -263,7 +263,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
|||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
<StatusCircle isOnline={machine.online} className="px-1 h-4 w-fit" />
|
||||||
<Attribute
|
<Attribute
|
||||||
name={machine.givenName}
|
name={machine.givenName}
|
||||||
link={`machines/${machine.id}`}
|
link={`machines/${machine.id}`}
|
||||||
@ -289,7 +289,7 @@ function UserCard({ user, magic }: CardProps) {
|
|||||||
variant="flat"
|
variant="flat"
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-full w-full overflow-visible h-full',
|
'max-w-full w-full overflow-visible h-full',
|
||||||
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
|
isOver ? 'bg-headplane-100 dark:bg-headplane-800' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -2,18 +2,17 @@
|
|||||||
// Functionally only used for all sorts of sanity checks across headplane.
|
// Functionally only used for all sorts of sanity checks across headplane.
|
||||||
//
|
//
|
||||||
// Around the codebase, this is referred to as the context
|
// Around the codebase, this is referred to as the context
|
||||||
|
// TODO: Fix the TRASH that is this env var mess
|
||||||
|
// - Zod needs to be used for the config
|
||||||
|
// - Switch to YAML for the config file
|
||||||
|
|
||||||
import { access, constants, readFile, writeFile } from 'node:fs/promises';
|
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
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';
|
||||||
|
import { testOidc } from '~/utils/oidc';
|
||||||
import { initSessionManager } from '~/utils/sessions.server';
|
import { initSessionManager } from '~/utils/sessions.server';
|
||||||
import { initAgentCache } from '~/utils/ws-agent';
|
|
||||||
|
|
||||||
export interface HeadplaneContext {
|
export interface HeadplaneContext {
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
@ -26,7 +25,7 @@ export interface HeadplaneContext {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
defaultTTL: number;
|
defaultTTL: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
read: boolean;
|
read: boolean;
|
||||||
@ -107,7 +106,8 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
initSessionManager();
|
initSessionManager();
|
||||||
|
|
||||||
const cacheEnabled = process.env.AGENT_CACHE_DISABLED !== 'true';
|
const cacheEnabled = process.env.AGENT_CACHE_DISABLED !== 'true';
|
||||||
const cachePath = process.env.AGENT_CACHE_PATH ?? '/etc/headplane/agent.cache';
|
const cachePath =
|
||||||
|
process.env.AGENT_CACHE_PATH ?? '/etc/headplane/agent.cache';
|
||||||
const cacheTTL = 300 * 1000; // 5 minutes
|
const cacheTTL = 300 * 1000; // 5 minutes
|
||||||
|
|
||||||
// Load agent cache
|
// Load agent cache
|
||||||
@ -237,9 +237,9 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
clientId: client,
|
clientId: client,
|
||||||
clientSecret: secret,
|
clientSecret: secret,
|
||||||
tokenEndpointAuthMethod: method,
|
tokenEndpointAuthMethod: method,
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await testOidc(oidcConfig)
|
const result = await testOidc(oidcConfig);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -301,9 +301,9 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
clientId: client,
|
clientId: client,
|
||||||
clientSecret: secret,
|
clientSecret: secret,
|
||||||
tokenEndpointAuthMethod: method,
|
tokenEndpointAuthMethod: method,
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await testOidc(oidcConfig)
|
const result = await testOidc(oidcConfig);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user