mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e4f3e4d2 | |||
| 40ddb69bc9 | |||
| 6aa6e77611 | |||
| c5c2f8a93b | |||
| acd042b4de | |||
| a563d4c0b3 | |||
| 7117060863 | |||
| 30a1153fa5 | |||
| 870680b6c5 | |||
| 25e6410c65 | |||
| f982217dd0 | |||
| f5436f5ee3 | |||
| 06049169a2 | |||
| 774fdb7be2 | |||
| e6580eed2c | |||
| 56b660b30c | |||
| 85c31ca5bf | |||
| 44d08169a9 | |||
| 76d263b7e6 | |||
| 5be3cb345e | |||
| 2a1c795d46 | |||
| 316e5b501f | |||
| 287ac2dff0 | |||
| d1f6c450c0 | |||
| 0a14533756 | |||
| 347c6698ee | |||
| 771b87ae41 | |||
| 31ac25d510 | |||
| 68babd7fe9 | |||
| c9d8052e39 | |||
| 843cfc4d4f | |||
| 6922548317 | |||
| 07cee17501 | |||
| 2c8880c84d | |||
| a19eb6bcda | |||
| 28e40eecbf | |||
| 741f9aa6b5 | |||
| 0f75636342 | |||
| 665509e710 | |||
| ac937f9014 | |||
| 8d3f31e7f9 | |||
| 2d47f1b952 | |||
| c24cd34925 | |||
| af248df648 | |||
| b9a708b2e9 | |||
| ec36876b9f | |||
| ed0cdbdf4d | |||
| fd506ba474 | |||
| 9734f0c704 | |||
| 156d7c9ee7 | |||
| bd11453593 |
@@ -1,42 +1,46 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
+20
-1
@@ -1,4 +1,23 @@
|
||||
### 0.4.0 (January 6, 2025)
|
||||
### 0.5.1 (February 28, 2025)
|
||||
- Fixed an issue that caused the entire server to crash on start
|
||||
- Fixed the published semver tags from Docker
|
||||
- Fixed the Kubernetes integration not reading the config
|
||||
|
||||
### 0.5 (February 27, 2025)
|
||||
- Completely redesigned the UI from the ground up for accessibility and performance.
|
||||
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
|
||||
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
|
||||
- Added support for Headscale 0.25.0 (this drops support for any older versions).
|
||||
- Fixed issues where renaming, deleting, and changing node owners via users was not possible (fixes [#91](https://github.com/tale/headplane/issues/91))
|
||||
- Operations now have significantly less moving parts and better error handling.
|
||||
- Updated to `pnpm` 10 and Node.js 22.
|
||||
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
|
||||
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
|
||||
|
||||
### 0.4.1 (January 18, 2025)
|
||||
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||
|
||||
### 0.4.0 (January 18, 2025)
|
||||
- Switched from Remix.run to React-Router
|
||||
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN npm install -g pnpm@10
|
||||
RUN apk add --no-cache git
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
@@ -11,7 +11,7 @@ COPY . .
|
||||
RUN pnpm run build
|
||||
RUN pnpm prune --prod
|
||||
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Headplane
|
||||
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
||||
> A feature-complete web UI for [Headscale](https://headscale.net)
|
||||
|
||||
<picture>
|
||||
<source
|
||||
@@ -16,30 +16,62 @@
|
||||
>
|
||||
</picture>
|
||||
|
||||
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
|
||||
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
|
||||
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including:
|
||||
Headscale is the de-facto self-hosted version of Tailscale, a popular Wireguard
|
||||
based VPN service. By default, it does not ship with a web UI, which is where
|
||||
Headplane comes in. Headplane is a feature-complete web UI for Headscale, allowing
|
||||
you to manage your nodes, networks, and ACLs with ease.
|
||||
|
||||
- Machine/Node expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration
|
||||
Headplane aims to replicate the functionality offered by the official Tailscale
|
||||
product and dashboard, being one of the most feature complete Headscale UIs available.
|
||||
These are some of the features that Headplane offers:
|
||||
|
||||
- Machine management, including expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration for ACL enforcement
|
||||
- Support for OpenID Connect (OIDC) as a login provider
|
||||
- DNS and *safe* Headscale configuration management
|
||||
- The ability to edit DNS settings and automatically provision Headscale
|
||||
- Configurability for Headscale's settings
|
||||
|
||||
## Deployment
|
||||
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
|
||||
It's available as a Docker image (recommended) or through a manual installation.
|
||||
There are 2 ways to deploy Headplane:
|
||||
|
||||
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
|
||||
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
|
||||
*If you aren't sure which one to pick, we recommend this.*
|
||||
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
|
||||
Integrated mode unlocks all the features of Headplane and is the most
|
||||
feature-complete deployment method. It communicates with Headscale directly.
|
||||
|
||||
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
|
||||
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
|
||||
instead requiring manual editing and reloading when making changes.
|
||||
- ### [Simple Mode](/docs/Simple-Mode.md)
|
||||
Simple mode does not include the automatic management of DNS and Headplane
|
||||
settings, requiring manual editing and reloading when making changes.
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
||||
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server.
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/acls-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/acls-light.png"
|
||||
>
|
||||
<img
|
||||
alt="ACLs"
|
||||
src="./assets/acls-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
> Copyright (c) 2024 Aarnav Tale
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/machine-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/machine-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Machine Management"
|
||||
src="./assets/machine-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
> Copyright (c) 2025 Aarnav Tale
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { CopyIcon } from '@primer/octicons-react';
|
||||
import { toast } from './Toaster';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
interface Props {
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
value: string;
|
||||
isCopyable?: boolean;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||
const canCopy = isCopyable ?? false;
|
||||
export default function Attribute({
|
||||
name,
|
||||
value,
|
||||
link,
|
||||
isCopyable,
|
||||
}: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex gap-1 text-sm w-full">
|
||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||
<dl className="flex items-center w-full gap-x-1">
|
||||
<dt className="font-semibold w-1/4 shrink-0 text-sm">
|
||||
{link ? (
|
||||
<a className="hover:underline" href={link}>
|
||||
{name}
|
||||
@@ -21,22 +26,42 @@ export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||
name
|
||||
)}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
|
||||
'flex items-center gap-x-1',
|
||||
'focus-within:outline-none focus-within:ring-2',
|
||||
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-x-1 outline-none"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', true);
|
||||
}
|
||||
|
||||
{canCopy ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast(`Copied ${name}`);
|
||||
}}
|
||||
>
|
||||
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
|
||||
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||
</button>
|
||||
) : (
|
||||
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
|
||||
)}
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast('Copied to clipboard');
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<p className="truncate">{value}</p>
|
||||
<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" />
|
||||
</button>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-30
@@ -1,38 +1,43 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { Button as AriaButton } from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
import React, { useRef } from 'react';
|
||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type Props = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly variant?: 'heavy' | 'light';
|
||||
};
|
||||
export interface ButtonProps extends AriaButtonOptions<'button'> {
|
||||
variant?: 'heavy' | 'light' | 'danger';
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export default function Button({ variant = 'light', ...props }: ButtonProps) {
|
||||
// In case the button is used as a trigger ref
|
||||
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
||||
const { buttonProps } = useButton(props, ref);
|
||||
|
||||
export default function Button(props: Props) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...props}
|
||||
<button
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
props.variant === 'heavy'
|
||||
? 'bg-main-700 dark:bg-main-800'
|
||||
: 'bg-main-200 dark:bg-main-700/30',
|
||||
props.variant === 'heavy'
|
||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
props.variant === 'heavy'
|
||||
? 'text-white'
|
||||
: 'text-ui-700 dark:text-ui-300',
|
||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
'w-fit text-sm rounded-xl px-3 py-2',
|
||||
'focus:outline-none focus:ring',
|
||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
...(variant === 'heavy'
|
||||
? [
|
||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||
'text-headplane-200 dark:text-headplane-800',
|
||||
]
|
||||
: variant === 'danger'
|
||||
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
|
||||
: [
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
]),
|
||||
props.className,
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={
|
||||
props.control
|
||||
? () => {
|
||||
props.control?.[1](true);
|
||||
}
|
||||
: props.onPress
|
||||
}
|
||||
/>
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-26
@@ -1,37 +1,22 @@
|
||||
import type { HTMLProps } from 'react';
|
||||
import { Heading as AriaHeading } from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
import React from 'react';
|
||||
import Text from '~/components/Text';
|
||||
import Title from '~/components/Title';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
function Title(props: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...props}
|
||||
slot="title"
|
||||
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
|
||||
);
|
||||
}
|
||||
|
||||
type Props = HTMLProps<HTMLDivElement> & {
|
||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||
variant?: 'raised' | 'flat';
|
||||
};
|
||||
}
|
||||
|
||||
function Card(props: Props) {
|
||||
function Card({ variant = 'raised', ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
props.variant === 'flat'
|
||||
'w-full max-w-md rounded-3xl p-5',
|
||||
variant === 'flat'
|
||||
? 'bg-transparent shadow-none'
|
||||
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
||||
'border border-ui-200 dark:border-ui-700',
|
||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface ChipProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Chip({
|
||||
text,
|
||||
className,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
'bg-headplane-100 dark:bg-headplane-700',
|
||||
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
{text}
|
||||
{rightIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+38
-37
@@ -1,51 +1,52 @@
|
||||
import { useState, HTMLProps } from 'react';
|
||||
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
interface Props extends HTMLProps<HTMLSpanElement> {
|
||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||
isCopyable?: boolean;
|
||||
children: string | string[];
|
||||
}
|
||||
|
||||
export default function Code(props: Props) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
return (
|
||||
<>
|
||||
<code
|
||||
className={cn(
|
||||
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</code>
|
||||
{props.isCopyable ? (
|
||||
<code
|
||||
className={cn(
|
||||
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
|
||||
'rounded-lg focus-within:outline-none focus-within:ring-2',
|
||||
isCopyable && 'relative pr-7',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{isCopyable && (
|
||||
<button
|
||||
className={cn(
|
||||
'ml-1 p-1 rounded-md',
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
'text-ui-500 dark:text-ui-400',
|
||||
'inline-flex items-center justify-center',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!props.children) {
|
||||
throw new Error('Made copyable without children');
|
||||
type="button"
|
||||
className="bottom-0 right-0 absolute"
|
||||
onClick={async (event) => {
|
||||
const text = Array.isArray(children)
|
||||
? children.join(' ')
|
||||
: children;
|
||||
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', true);
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(props.children.join(''));
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast('Copied to clipboard');
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
svg.toggleAttribute('data-copied', false);
|
||||
}
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
)}
|
||||
<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" />
|
||||
</button>
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
+176
-122
@@ -1,132 +1,186 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import React, { cloneElement, useRef } from 'react';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
Heading as AriaHeading,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
type AriaDialogProps,
|
||||
type AriaModalOverlayProps,
|
||||
Overlay,
|
||||
useDialog,
|
||||
useModalOverlay,
|
||||
useOverlayTrigger,
|
||||
} from 'react-aria';
|
||||
import { Form, type HTMLFormMethod } from 'react-router';
|
||||
import {
|
||||
type OverlayTriggerProps,
|
||||
type OverlayTriggerState,
|
||||
useOverlayTriggerState,
|
||||
} from 'react-stately';
|
||||
import Button, { ButtonProps } from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import IconButton, { IconButtonProps } from '~/components/IconButton';
|
||||
import Text from '~/components/Text';
|
||||
import Title from '~/components/Title';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type ButtonProps = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
};
|
||||
export interface DialogProps extends OverlayTriggerProps {
|
||||
children:
|
||||
| [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<DialogPanelProps>,
|
||||
]
|
||||
| React.ReactElement<DialogPanelProps>;
|
||||
}
|
||||
|
||||
function Button(props: ButtonProps) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...props}
|
||||
aria-label="Dialog"
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
props.className,
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={
|
||||
props.control
|
||||
? () => {
|
||||
props.control?.[1](true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
function Dialog(props: DialogProps) {
|
||||
const state = useOverlayTriggerState(props);
|
||||
const { triggerProps, overlayProps } = useOverlayTrigger(
|
||||
{
|
||||
type: 'dialog',
|
||||
},
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
type ActionProps = Parameters<typeof AriaButton>[0] & {
|
||||
readonly variant: 'cancel' | 'confirm';
|
||||
};
|
||||
|
||||
function Action(props: ActionProps) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...props}
|
||||
type={props.variant === 'confirm' ? 'submit' : 'button'}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg',
|
||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
props.variant === 'cancel'
|
||||
? 'text-ui-700 dark:text-ui-300'
|
||||
: 'text-ui-300 dark:text-ui-300',
|
||||
props.variant === 'confirm'
|
||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Title(props: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...props}
|
||||
slot="title"
|
||||
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
children: (close: () => void) => ReactNode;
|
||||
control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Panel({ children, control, className }: PanelProps) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
|
||||
'flex items-center justify-center dark:bg-black/70',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:duration-200 entering:ease-out',
|
||||
'exiting:fade-out exiting:duration-100 exiting:ease-in',
|
||||
className,
|
||||
)}
|
||||
isOpen={control ? control[0] : undefined}
|
||||
onOpenChange={control ? control[1] : undefined}
|
||||
>
|
||||
<Modal
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
'bg-ui-50 dark:bg-ui-900 shadow-lg',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'dark:border dark:border-ui-700',
|
||||
'entering:zoom-in-95 entering:ease-out entering:duration-200',
|
||||
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
|
||||
if (Array.isArray(props.children)) {
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
<>
|
||||
{cloneElement(button, triggerProps)}
|
||||
{state.isOpen && (
|
||||
<DModal state={state}>
|
||||
{cloneElement(panel, {
|
||||
...overlayProps,
|
||||
close: () => state.close(),
|
||||
})}
|
||||
</DModal>
|
||||
)}
|
||||
>
|
||||
<AriaDialog role="alertdialog" className="outline-none relative">
|
||||
{({ close }) => children(close)}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
children: ReactNode;
|
||||
control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Dialog({ children, control }: DialogProps) {
|
||||
if (control) {
|
||||
return children;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <DialogTrigger>{children}</DialogTrigger>;
|
||||
return (
|
||||
<DModal state={state}>
|
||||
{cloneElement(props.children, {
|
||||
...overlayProps,
|
||||
close: () => state.close(),
|
||||
})}
|
||||
</DModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });
|
||||
export interface DialogPanelProps extends AriaDialogProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'normal' | 'destructive' | 'unactionable';
|
||||
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
||||
method?: HTMLFormMethod;
|
||||
isDisabled?: boolean;
|
||||
|
||||
// Anonymous (passed by parent)
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
function Panel(props: DialogPanelProps) {
|
||||
const {
|
||||
children,
|
||||
onSubmit,
|
||||
isDisabled,
|
||||
close,
|
||||
variant,
|
||||
method = 'POST',
|
||||
} = props;
|
||||
const ref = useRef<HTMLFormElement | null>(null);
|
||||
const { dialogProps } = useDialog(
|
||||
{
|
||||
...props,
|
||||
role: 'alertdialog',
|
||||
},
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
{...dialogProps}
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
}
|
||||
|
||||
close?.();
|
||||
}}
|
||||
method={method ?? 'POST'}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'outline-none rounded-3xl w-full max-w-lg',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
)}
|
||||
>
|
||||
<Card className="w-full max-w-lg" variant="flat">
|
||||
{children}
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
{variant === 'unactionable' ? (
|
||||
<Button onPress={close}>Close</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onPress={close}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||
isDisabled={
|
||||
isDisabled || !(ref.current?.checkValidity() ?? true)
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface DModalProps extends AriaModalOverlayProps {
|
||||
children: React.ReactNode;
|
||||
state: OverlayTriggerState;
|
||||
}
|
||||
|
||||
function DModal(props: DModalProps) {
|
||||
const { children, state } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
|
||||
|
||||
if (!state.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div
|
||||
{...underlayProps}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-20',
|
||||
'flex items-center justify-center',
|
||||
'bg-headplane-900/15 dark:bg-headplane-900/30',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:duration-100 entering:ease-out',
|
||||
'exiting:fade-out exiting:duration-50 exiting:ease-in',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
{...modalProps}
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-20',
|
||||
'flex items-center justify-center',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, {
|
||||
Button,
|
||||
IconButton,
|
||||
Panel,
|
||||
Title,
|
||||
Text,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
import { cn } from '~/utils/cn';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
|
||||
function getMessage(error: Error | unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return "An unknown error occurred";
|
||||
return 'An unknown error occurred';
|
||||
}
|
||||
|
||||
let rootError = error;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cn } from '~/utils/cn';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
declare global {
|
||||
const __VERSION__: string;
|
||||
@@ -16,9 +15,10 @@ export default function Footer({ url, debug }: FooterProps) {
|
||||
<footer
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 z-40 w-full h-14',
|
||||
'bg-ui-100 dark:bg-ui-900 text-ui-500',
|
||||
'flex flex-col justify-center gap-1',
|
||||
'border-t border-ui-200 dark:border-ui-800',
|
||||
'flex flex-col justify-center gap-1 shadow-inner',
|
||||
'bg-headplane-100 dark:bg-headplane-950',
|
||||
'text-headplane-800 dark:text-headplane-200',
|
||||
'dark:border-t dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<p className="container text-xs">
|
||||
@@ -33,12 +33,8 @@ export default function Footer({ url, debug }: FooterProps) {
|
||||
</p>
|
||||
<p className="container text-xs opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' | '}
|
||||
Connecting to
|
||||
{' '}
|
||||
<strong className="blur-xs hover:blur-none">
|
||||
{url}
|
||||
</strong>
|
||||
{' — '}
|
||||
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
+92
-81
@@ -1,41 +1,66 @@
|
||||
import {
|
||||
GearIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
PaperAirplaneIcon,
|
||||
PeopleIcon,
|
||||
PersonIcon,
|
||||
ServerIcon,
|
||||
} from '@primer/octicons-react';
|
||||
import { Form } from 'react-router';
|
||||
|
||||
import { cn } from '~/utils/cn';
|
||||
import type { HeadplaneContext } from '~/utils/config/headplane';
|
||||
CircleUser,
|
||||
Globe2,
|
||||
Lock,
|
||||
PlaneTakeoff,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink, useSubmit } from 'react-router';
|
||||
import Menu from '~/components/Menu';
|
||||
import cn from '~/utils/cn';
|
||||
import type { SessionData } from '~/utils/sessions.server';
|
||||
|
||||
import Menu from './Menu';
|
||||
import TabLink from './TabLink';
|
||||
|
||||
interface Props {
|
||||
config: HeadplaneContext['config'];
|
||||
configAvailable: boolean;
|
||||
user?: SessionData['user'];
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
text: string;
|
||||
isMenu?: boolean;
|
||||
}
|
||||
|
||||
function Link({ href, text, isMenu }: LinkProps) {
|
||||
interface TabLinkProps {
|
||||
name: string;
|
||||
to: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
function TabLink({ name, to, icon }: TabLinkProps) {
|
||||
return (
|
||||
<div className="relative py-2">
|
||||
<NavLink
|
||||
to={to}
|
||||
prefetch="intent"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
||||
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
||||
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
||||
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
||||
'focus:outline-none focus:ring',
|
||||
isActive ? 'after:visible' : 'after:invisible',
|
||||
)
|
||||
}
|
||||
>
|
||||
{icon} {name}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Link({ href, text }: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
!isMenu &&
|
||||
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
||||
'hidden sm:block hover:underline text-sm',
|
||||
'focus:outline-none focus:ring rounded-md',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
@@ -44,12 +69,21 @@ function Link({ href, text, isMenu }: LinkProps) {
|
||||
}
|
||||
|
||||
export default function Header(data: Props) {
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
||||
<header
|
||||
className={cn(
|
||||
'bg-headplane-100 dark:bg-headplane-950',
|
||||
'text-headplane-800 dark:text-headplane-200',
|
||||
'dark:border-b dark:border-headplane-800',
|
||||
'shadow-inner',
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PaperAirplaneIcon className="w-6 h-6" />
|
||||
<h1 className="text-2xl">Headplane</h1>
|
||||
<PlaneTakeoff />
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Link href="https://tailscale.com/download" text="Download" />
|
||||
@@ -57,81 +91,58 @@ export default function Header(data: Props) {
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data.user ? (
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||
'flex items-center justify-center',
|
||||
)}
|
||||
<Menu.IconButton label="User">
|
||||
<CircleUser />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
if (key === 'logout') {
|
||||
submit(
|
||||
{},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/logout',
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabledKeys={['profile']}
|
||||
>
|
||||
<PersonIcon className="h-5 w-5 mt-0.5" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item className="text-right">
|
||||
<p className="font-bold">{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://tailscale.com/download"
|
||||
text="Download"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/tale/headplane"
|
||||
text="GitHub"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/juanfont/headscale"
|
||||
text="Headscale"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-red-500 dark:text-red-400">
|
||||
<Form method="POST" action="/logout">
|
||||
<button type="submit" className="w-full text-right">
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="profile" textValue="Profile">
|
||||
<div className="text-black dark:text-headplane-50">
|
||||
<p className="font-bold">{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="logout" textValue="Logout">
|
||||
<p className="text-red-500 dark:text-red-400">Logout</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
<TabLink
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<ServerIcon className="w-4 h-4" />}
|
||||
/>
|
||||
<TabLink
|
||||
to="/users"
|
||||
name="Users"
|
||||
icon={<PeopleIcon className="w-4 h-4" />}
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<LockIcon className="w-4 h-4" />}
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
{data.config.read ? (
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
<TabLink
|
||||
to="/dns"
|
||||
name="DNS"
|
||||
icon={<GlobeIcon className="w-4 h-4" />}
|
||||
/>
|
||||
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<GearIcon className="w-4 h-4" />}
|
||||
icon={<Settings className="w-5" />}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface IconButtonProps extends AriaButtonOptions<'button'> {
|
||||
variant?: 'heavy' | 'light';
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export default function IconButton({
|
||||
variant = 'light',
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
// In case the button is used as a trigger ref
|
||||
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
||||
const { buttonProps } = useButton(props, ref);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
aria-label={props.label}
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center p-1',
|
||||
'focus:outline-none focus:ring',
|
||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
...(variant === 'heavy'
|
||||
? [
|
||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||
'text-headplane-200 dark:text-headplane-800',
|
||||
]
|
||||
: [
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
]),
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useRef } from 'react';
|
||||
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
const { label, labelHidden, className } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const id = useId(props.id);
|
||||
|
||||
const {
|
||||
labelProps,
|
||||
inputProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid,
|
||||
validationErrors,
|
||||
} = useTextField(
|
||||
{
|
||||
...props,
|
||||
label,
|
||||
'aria-label': label,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full" aria-label={label}>
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
labelHidden && 'sr-only',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
required={props.isRequired}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl px-3 py-2',
|
||||
'focus:outline-none focus:ring',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn(
|
||||
'text-xs px-3 mt-1',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
)}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{isInvalid && (
|
||||
<div
|
||||
{...errorMessageProps}
|
||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-6
@@ -1,14 +1,19 @@
|
||||
import { LinkExternalIcon } from '@primer/octicons-react';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
export interface LinkProps {
|
||||
to: string;
|
||||
name: string;
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Link({ to, name: alt, children, className }: Props) {
|
||||
export default function Link({
|
||||
to,
|
||||
name: alt,
|
||||
children,
|
||||
className,
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
@@ -16,14 +21,15 @@ export default function Link({ to, name: alt, children, className }: Props) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-1',
|
||||
'inline-flex items-center gap-x-0.5',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
'focus:outline-none focus:ring rounded-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className="h-3 w-3" />
|
||||
<ExternalLink className="w-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
+140
-78
@@ -1,91 +1,153 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import React, { useRef, cloneElement } from 'react';
|
||||
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
|
||||
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
|
||||
import { Item, Section } from 'react-stately';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Menu as AriaMenu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Popover,
|
||||
} from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
type MenuTriggerProps,
|
||||
Node,
|
||||
TreeState,
|
||||
useMenuTriggerState,
|
||||
useTreeState,
|
||||
} from 'react-stately';
|
||||
import Button, { ButtonProps } from '~/components/Button';
|
||||
import IconButton, { IconButtonProps } from '~/components/IconButton';
|
||||
import Popover from '~/components/Popover';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: onAction is called twice for some reason?
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = 'bottom' } = props;
|
||||
const state = useMenuTriggerState(props);
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||
{},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
// cloneElement is necessary because the button is a union type
|
||||
// of multiple things and we need to join props from our hooks
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
<AriaButton
|
||||
{...props}
|
||||
className={cn('outline-none', props.className)}
|
||||
aria-label="Menu"
|
||||
/>
|
||||
<div>
|
||||
{cloneElement(button, {
|
||||
...menuTriggerProps,
|
||||
ref,
|
||||
})}
|
||||
{state.isOpen && (
|
||||
<Popover state={state} triggerRef={ref} placement={placement}>
|
||||
{cloneElement(panel, {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Items(props: Parameters<typeof AriaMenu>[0]) {
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
const state = useTreeState(props);
|
||||
const ref = useRef(null);
|
||||
|
||||
const { menuProps } = useMenu(props, state, ref);
|
||||
return (
|
||||
<Popover
|
||||
<ul
|
||||
{...menuProps}
|
||||
ref={ref}
|
||||
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection key={item.key} section={item} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuSectionProps<T> {
|
||||
section: Node<T>;
|
||||
state: TreeState<T>;
|
||||
}
|
||||
|
||||
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
const { itemProps, groupProps } = useMenuSection({
|
||||
heading: section.rendered,
|
||||
'aria-label': section['aria-label'],
|
||||
});
|
||||
|
||||
const { separatorProps } = useSeparator({
|
||||
elementType: 'li',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{section.key !== state.collection.getFirstKey() ? (
|
||||
<li
|
||||
{...separatorProps}
|
||||
className={cn(
|
||||
'mx-2 mt-1 mb-1 border-t',
|
||||
'border-headplane-200 dark:border-headplane-800',
|
||||
)}
|
||||
/>
|
||||
) : undefined}
|
||||
<li {...itemProps}>
|
||||
<ul {...groupProps}>
|
||||
{[...section.childNodes].map((item) => (
|
||||
<MenuItem key={item.key} item={item} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||
|
||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||
const isDisabled = state.selectionManager.isDisabled(item.key);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...menuItemProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 rounded-md',
|
||||
'bg-ui-50 dark:bg-ui-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right',
|
||||
'py-2 px-3 mx-1 rounded-lg',
|
||||
'focus:outline-none select-none',
|
||||
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
||||
isDisabled
|
||||
? 'text-headplane-400 dark:text-headplane-600'
|
||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<AriaMenu
|
||||
{...props}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</AriaMenu>
|
||||
</Popover>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonProps = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
};
|
||||
|
||||
function ItemButton(props: ButtonProps) {
|
||||
return (
|
||||
<MenuItem className="outline-none">
|
||||
<AriaButton
|
||||
{...props}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none text-left',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
)}
|
||||
aria-label="Menu Dialog"
|
||||
// If control is passed, set the state value
|
||||
onPress={(event) => {
|
||||
props.onPress?.(event);
|
||||
props.control?.[1](true);
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function Item(props: Parameters<typeof MenuItem>[0]) {
|
||||
return (
|
||||
<MenuItem
|
||||
{...props}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Menu({ children }: { children: ReactNode }) {
|
||||
return <MenuTrigger>{children}</MenuTrigger>;
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items });
|
||||
export default Object.assign(Menu, {
|
||||
Button,
|
||||
IconButton,
|
||||
Panel,
|
||||
Section,
|
||||
Item,
|
||||
});
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { InfoIcon } from '@primer/octicons-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { CircleSlash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Notice({ children, className }: Props) {
|
||||
export default function Notice({ children }: NoticeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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" />
|
||||
<Card className="flex w-full max-w-full gap-4 font-semibold">
|
||||
<CircleSlash2 />
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { PlusIcon, DashIcon } from '@primer/octicons-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
NumberField as AriaNumberField,
|
||||
} from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
||||
label: string;
|
||||
state?: [number, Dispatch<SetStateAction<number>>];
|
||||
};
|
||||
|
||||
export default function NumberField(props: NumberFieldProps) {
|
||||
return (
|
||||
<AriaNumberField
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
className="w-full"
|
||||
value={props.state?.[0]}
|
||||
onChange={(value) => {
|
||||
props.state?.[1](value);
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
className={cn(
|
||||
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Input className="w-full bg-transparent focus:outline-none" />
|
||||
<Button slot="decrement">
|
||||
<DashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button slot="increment">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Group>
|
||||
</AriaNumberField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
type AriaNumberFieldProps,
|
||||
useId,
|
||||
useLocale,
|
||||
useNumberField,
|
||||
} from 'react-aria';
|
||||
import { useNumberFieldState } from 'react-stately';
|
||||
import IconButton from '~/components/IconButton';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface InputProps extends AriaNumberFieldProps {
|
||||
isRequired?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function NumberInput(props: InputProps) {
|
||||
const { label, name } = props;
|
||||
const { locale } = useLocale();
|
||||
const state = useNumberFieldState({ ...props, locale });
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const id = useId(props.id);
|
||||
|
||||
const {
|
||||
labelProps,
|
||||
inputProps,
|
||||
groupProps,
|
||||
incrementButtonProps,
|
||||
decrementButtonProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid,
|
||||
validationErrors,
|
||||
} = useNumberField(props, state, ref);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div
|
||||
{...groupProps}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-xl pr-1',
|
||||
'focus-within:outline-none focus-within:ring',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={id}
|
||||
required={props.isRequired}
|
||||
name={name}
|
||||
ref={ref}
|
||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
|
||||
/>
|
||||
<IconButton
|
||||
{...decrementButtonProps}
|
||||
label="Decrement"
|
||||
className="w-7.5 h-7.5 rounded-lg"
|
||||
>
|
||||
<Minus className="p-1" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
{...incrementButtonProps}
|
||||
label="Increment"
|
||||
className="w-7.5 h-7.5 rounded-lg"
|
||||
>
|
||||
<Plus className="p-1" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn(
|
||||
'text-xs px-3 mt-1',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
)}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{isInvalid && (
|
||||
<div
|
||||
{...errorMessageProps}
|
||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
type AriaPopoverProps,
|
||||
DismissButton,
|
||||
Overlay,
|
||||
usePopover,
|
||||
} from 'react-aria';
|
||||
import type { OverlayTriggerState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
|
||||
children: React.ReactNode;
|
||||
state: OverlayTriggerState;
|
||||
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Popover(props: PopoverProps) {
|
||||
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
|
||||
const { state, children, className } = props;
|
||||
const { popoverProps, underlayProps } = usePopover(
|
||||
{
|
||||
...props,
|
||||
popoverRef: ref,
|
||||
offset: 8,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div {...underlayProps} className="fixed inset-0" />
|
||||
<div
|
||||
{...popoverProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-10 shadow-sm rounded-xl',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-200 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DismissButton onDismiss={state.close} />
|
||||
{children}
|
||||
<DismissButton onDismiss={state.close} />
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useProgressBar } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { isVisible } = props;
|
||||
const { progressBarProps } = useProgressBar({
|
||||
label: 'Loading...',
|
||||
isIndeterminate: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...progressBarProps}
|
||||
aria-hidden={!isVisible}
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
|
||||
'bg-headplane-950 dark:bg-headplane-50',
|
||||
isVisible && 'animate-loading opacity-100',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+154
-62
@@ -1,79 +1,171 @@
|
||||
import { ChevronDownIcon } from '@primer/octicons-react';
|
||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
Select as AriaSelect,
|
||||
SelectValue,
|
||||
} from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
AriaComboBoxProps,
|
||||
AriaListBoxOptions,
|
||||
useButton,
|
||||
useComboBox,
|
||||
useFilter,
|
||||
useId,
|
||||
useListBox,
|
||||
useOption,
|
||||
} from 'react-aria';
|
||||
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
|
||||
import Popover from '~/components/Popover';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
||||
readonly label: string;
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
export interface SelectProps extends AriaComboBoxProps<object> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Select(props: SelectProps) {
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
const state = useComboBoxState({ ...props, defaultFilter: contains });
|
||||
const id = useId(props.id);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const listBoxRef = useRef<HTMLUListElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
buttonProps: triggerProps,
|
||||
inputProps,
|
||||
listBoxProps,
|
||||
labelProps,
|
||||
descriptionProps,
|
||||
} = useComboBox(
|
||||
{
|
||||
...props,
|
||||
inputRef,
|
||||
buttonRef,
|
||||
listBoxRef,
|
||||
popoverRef,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const { buttonProps } = useButton(triggerProps, buttonRef);
|
||||
return (
|
||||
<AriaSelect
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
selectedKey={props.state?.[0]}
|
||||
onSelectionChange={(key) => {
|
||||
props.state?.[1](key.toString());
|
||||
}}
|
||||
className={cn(
|
||||
'block w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'bg-white dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:outline-6',
|
||||
props.className,
|
||||
<div className={cn('flex flex-col', props.className)}>
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-xl focus:outline-none focus-within:ring',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||
data-1p-ignore
|
||||
/>
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={buttonRef}
|
||||
className={cn(
|
||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="p-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn(
|
||||
'text-xs px-3 mt-1',
|
||||
'text-headplane-500 dark:text-headplane-400',
|
||||
)}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between',
|
||||
'px-2.5 py-1.5 rounded-lg',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Popover
|
||||
className={cn(
|
||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
||||
'z-50 overflow-y-auto',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right',
|
||||
)}
|
||||
>
|
||||
<ListBox orientation="vertical">{props.children}</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
{state.isOpen && (
|
||||
<Popover
|
||||
popoverRef={popoverRef}
|
||||
triggerRef={inputRef}
|
||||
state={state}
|
||||
isNonModal
|
||||
placement="bottom start"
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ItemProps = Parameters<typeof ListBoxItem>[0];
|
||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||
listBoxRef?: React.RefObject<HTMLUListElement | null>;
|
||||
state: ListState<object>;
|
||||
}
|
||||
|
||||
function ListBox(props: ListBoxProps) {
|
||||
const { listBoxRef, state } = props;
|
||||
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
|
||||
const { listBoxProps } = useListBox(props, state, ref);
|
||||
|
||||
function Item(props: ItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
{...props}
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
ref={listBoxRef}
|
||||
className="w-full max-h-72 overflow-auto outline-none pt-1"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option key={item.key} item={item} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionProps {
|
||||
item: Node<unknown>;
|
||||
state: ListState<unknown>;
|
||||
}
|
||||
|
||||
function Option({ item, state }: OptionProps) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
|
||||
{
|
||||
key: item.key,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...optionProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none w-full',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
'flex items-center justify-between',
|
||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||
'focus:outline-none select-none',
|
||||
isFocused || isSelected
|
||||
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</ListBoxItem>
|
||||
{item.rendered}
|
||||
{isSelected && <Check className="p-0.5" />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@ interface Props {
|
||||
|
||||
export default function Spinner({ className }: Props) {
|
||||
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
|
||||
className={clsx(
|
||||
'animate-spin rounded-full w-full h-full',
|
||||
'border-2 border-current border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type Props = HTMLProps<SVGElement> & {
|
||||
readonly isOnline: boolean;
|
||||
};
|
||||
export interface StatusCircleProps {
|
||||
isOnline: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
export default function StatusCircle({ isOnline, className }: Props) {
|
||||
export default function StatusCircle({
|
||||
isOnline,
|
||||
className,
|
||||
}: StatusCircleProps) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(
|
||||
className,
|
||||
className={cn(
|
||||
isOnline
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-gray-300 dark:text-gray-500',
|
||||
? 'text-green-600 dark:text-green-500'
|
||||
: 'text-headplane-200 dark:text-headplane-800',
|
||||
className,
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>{isOnline ? 'Online' : 'Offline'}</title>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
+44
-17
@@ -1,34 +1,61 @@
|
||||
import { Switch as AriaSwitch } from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AriaSwitchProps,
|
||||
VisuallyHidden,
|
||||
useFocusRing,
|
||||
useSwitch,
|
||||
} from 'react-aria';
|
||||
import { useToggleState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
|
||||
readonly label: string;
|
||||
};
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const state = useToggleState(props);
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const { focusProps, isFocusVisible } = useFocusRing();
|
||||
const { inputProps } = useSwitch(
|
||||
{
|
||||
...props,
|
||||
'aria-label': props.label,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<AriaSwitch
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
className="group flex gap-2 items-center"
|
||||
>
|
||||
<label className="flex items-center gap-x-2">
|
||||
<VisuallyHidden elementType="span">
|
||||
<input
|
||||
{...inputProps}
|
||||
{...focusProps}
|
||||
aria-label={props.label}
|
||||
ref={ref}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex h-[26px] w-[44px] p-[4px] shrink-0',
|
||||
'rounded-full outline-none group-focus-visible:ring-2',
|
||||
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
props.className,
|
||||
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
|
||||
'bg-headplane-300 dark:bg-headplane-700',
|
||||
'border border-transparent dark:border-headplane-800',
|
||||
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
|
||||
isFocusVisible && 'ring-2',
|
||||
props.isDisabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-100 ease-in-out',
|
||||
'bg-white transition duration-50 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-[100%]',
|
||||
state.isSelected && 'translate-x-[100%]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AriaSwitch>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NavLink } from 'react-router';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
to: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export default function TabLink({ name, to, icon }: Props) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
prefetch="intent"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'border-b-2 py-1.5',
|
||||
isActive ? 'border-white' : 'border-transparent',
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
||||
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
|
||||
)}
|
||||
>
|
||||
{icon} {name}
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLProps } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
function TableList(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
'border border-gray-300 rounded-lg overflow-clip',
|
||||
'dark:border-zinc-700 dark:text-gray-300',
|
||||
// 'dark:bg-zinc-800',
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
@@ -21,10 +20,9 @@ function Item(props: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-3 py-2',
|
||||
'border-b border-gray-200 last:border-b-0',
|
||||
'dark:border-zinc-800',
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 last:border-b-0',
|
||||
'border-b border-headplane-100 dark:border-headplane-800',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AriaTabListProps,
|
||||
AriaTabPanelProps,
|
||||
useTab,
|
||||
useTabList,
|
||||
useTabPanel,
|
||||
} from 'react-aria';
|
||||
import { Item, Node, TabListState, useTabListState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface TabsProps extends AriaTabListProps<object> {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Tabs({ label, className, ...props }: TabsProps) {
|
||||
const state = useTabListState(props);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabListProps } = useTabList(props, state, ref);
|
||||
return (
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Tab key={item.key} item={item} state={state} />
|
||||
))}
|
||||
</div>
|
||||
<TabsPanel key={state.selectedItem?.key} state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsTabProps {
|
||||
item: Node<object>;
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function Tab({ item, state }: 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-none focus:ring z-10',
|
||||
'border-r border-headplane-100 dark:border-headplane-800',
|
||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||
)}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsPanelProps extends AriaTabPanelProps {
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function TabsPanel({ state, ...props }: TabsPanelProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Tabs, { Item });
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface TextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Text({ children, className }: TextProps) {
|
||||
return <p className={cn('text-md my-0', className)}>{children}</p>;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { Input, TextField as AriaTextField } from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
|
||||
readonly label: string;
|
||||
readonly placeholder: string;
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||
};
|
||||
|
||||
export default function TextField(props: TextFieldProps) {
|
||||
return (
|
||||
<AriaTextField {...props} aria-label={props.label} className="w-full">
|
||||
<Input
|
||||
placeholder={props.placeholder}
|
||||
value={props.state?.[0]}
|
||||
name={props.name}
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
props.className,
|
||||
)}
|
||||
onChange={(event) => {
|
||||
props.state?.[1](event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AriaTextField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface TitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Title({ children, className }: TitleProps) {
|
||||
return (
|
||||
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
AriaToastProps,
|
||||
AriaToastRegionProps,
|
||||
useToast,
|
||||
useToastRegion,
|
||||
} from '@react-aria/toast';
|
||||
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
|
||||
import { X } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import IconButton from '~/components/IconButton';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface ToastProps extends AriaToastProps<React.ReactNode> {
|
||||
state: ToastState<React.ReactNode>;
|
||||
}
|
||||
|
||||
function Toast({ state, ...props }: ToastProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||
props,
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<React.ReactNode>;
|
||||
}
|
||||
|
||||
function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { regionProps } = useToastRegion(props, state, ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...regionProps}
|
||||
ref={ref}
|
||||
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
|
||||
>
|
||||
{state.visibleToasts.map((toast) => (
|
||||
<Toast key={toast.key} toast={toast} state={state} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToastProviderProps extends AriaToastRegionProps {
|
||||
queue: ToastQueue<React.ReactNode>;
|
||||
}
|
||||
|
||||
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
|
||||
const state = useToastQueue(queue);
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.visibleToasts.length > 0 && (
|
||||
<ToastRegion {...props} state={state} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { XIcon } from '@primer/octicons-react';
|
||||
import {
|
||||
AriaToastProps,
|
||||
useToast,
|
||||
useToastRegion,
|
||||
} from '@react-aria/toast';
|
||||
import {
|
||||
ToastQueue,
|
||||
ToastState,
|
||||
useToastQueue,
|
||||
} from '@react-stately/toast';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
type ToastProps = AriaToastProps<ReactNode> & {
|
||||
readonly state: ToastState<ReactNode>;
|
||||
};
|
||||
|
||||
function Toast({ state, ...properties }: ToastProps) {
|
||||
const reference = useRef(null);
|
||||
|
||||
const { toastProps, titleProps, closeButtonProps } = useToast(
|
||||
properties,
|
||||
state,
|
||||
reference,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...toastProps}
|
||||
ref={reference}
|
||||
className={cn(
|
||||
'bg-main-700 dark:bg-main-800 rounded-lg',
|
||||
'text-main-100 dark:text-main-200 z-50',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'flex items-center justify-between p-3 pl-4 w-80',
|
||||
)}
|
||||
>
|
||||
<div {...titleProps}>{properties.toast.content}</div>
|
||||
<Button
|
||||
{...closeButtonProps}
|
||||
className={cn(
|
||||
'outline-none rounded-full p-1',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toasts = new ToastQueue<ReactNode>({
|
||||
maxVisibleToasts: 5,
|
||||
});
|
||||
|
||||
export function toast(text: string) {
|
||||
return toasts.add(text, { timeout: 5000 });
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const reference = useRef(null);
|
||||
const state = useToastQueue(toasts);
|
||||
|
||||
const { regionProps } = useToastRegion({}, state, reference);
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
{
|
||||
() =>
|
||||
createPortal(
|
||||
state.visibleToasts.length >= 0 ? (
|
||||
<div
|
||||
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
|
||||
{...regionProps}
|
||||
ref={reference}
|
||||
>
|
||||
{state.visibleToasts.map((toast) => (
|
||||
<Toast key={toast.key} toast={toast} state={state} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
+73
-27
@@ -1,37 +1,83 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { cloneElement, useRef } from 'react';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components';
|
||||
import { cn } from '~/utils/cn';
|
||||
AriaTooltipProps,
|
||||
mergeProps,
|
||||
useTooltip,
|
||||
useTooltipTrigger,
|
||||
} from 'react-aria';
|
||||
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
export interface TooltipProps extends AriaTooltipProps {
|
||||
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
|
||||
}
|
||||
|
||||
function Tooltip({ children }: Props) {
|
||||
return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
|
||||
}
|
||||
function Tooltip(props: TooltipProps) {
|
||||
const state = useTooltipTriggerState({
|
||||
...props,
|
||||
delay: 0,
|
||||
closeDelay: 0,
|
||||
});
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
return <AriaButton {...props} />;
|
||||
}
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
||||
{
|
||||
...props,
|
||||
delay: 0,
|
||||
closeDelay: 0,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
function Body({ children, className }: Props) {
|
||||
const [component, body] = props.children;
|
||||
return (
|
||||
<AriaTooltip
|
||||
className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AriaTooltip>
|
||||
<span className="relative">
|
||||
<button
|
||||
ref={ref}
|
||||
{...triggerProps}
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'focus:outline-none focus:ring rounded-xl',
|
||||
)}
|
||||
>
|
||||
{component}
|
||||
</button>
|
||||
{state.isOpen &&
|
||||
cloneElement(body, {
|
||||
...tooltipProps,
|
||||
state,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, { Button, Body });
|
||||
interface TooltipBodyProps extends AriaTooltipProps {
|
||||
children: React.ReactNode;
|
||||
state?: TooltipTriggerState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Body({ state, className, ...props }: TooltipBodyProps) {
|
||||
const { tooltipProps } = useTooltip(props, state);
|
||||
return (
|
||||
<span
|
||||
{...mergeProps(props, tooltipProps)}
|
||||
className={cn(
|
||||
'absolute z-50 p-3 top-full mt-1',
|
||||
'outline-none rounded-3xl text-sm w-48',
|
||||
'bg-white dark:bg-headplane-950',
|
||||
'text-black dark:text-white',
|
||||
'shadow-lg dark:shadow-md rounded-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, {
|
||||
Body,
|
||||
});
|
||||
|
||||
+15
-9
@@ -1,12 +1,18 @@
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
import('react-scan').then(({ scan }) => {
|
||||
scan({ enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>
|
||||
);
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
+62
-60
@@ -1,71 +1,73 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { loadContext } from "~/utils/config/headplane";
|
||||
|
||||
loadContext();
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import { EntryContext, ServerRouter } from 'react-router';
|
||||
import { hp_loadLogger } from '~/utils/log';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppLoadContext
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
const { context } = loadContext;
|
||||
return new Promise((resolve, reject) => {
|
||||
// This is a promise but we don't need to wait for it to finish
|
||||
// before we start rendering the shell since it only loads once.
|
||||
hp_loadLogger(context.debug);
|
||||
|
||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
||||
? "onAllReady"
|
||||
: "onShellReady";
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
||||
? 'onAllReady'
|
||||
: 'onShellReady';
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
// biome-ignore lint/style/noParameterAssign: Lazy
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import log from '~/utils/log';
|
||||
|
||||
import dockerIntegration from './docker';
|
||||
import type { IntegrationFactory } from './integration';
|
||||
import kubernetesIntegration from './kubernetes';
|
||||
import procIntegration from './proc';
|
||||
|
||||
export * from './integration';
|
||||
|
||||
export async function loadIntegration() {
|
||||
let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
|
||||
|
||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||
// This ensures that when people upgrade from older versions of Headplane
|
||||
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
||||
// variable that is needed to configure docker
|
||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||
integration = 'docker';
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
|
||||
return;
|
||||
}
|
||||
|
||||
let integrationFactory: IntegrationFactory | undefined;
|
||||
switch (integration.toLowerCase().trim()) {
|
||||
case 'docker': {
|
||||
integrationFactory = dockerIntegration;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'proc':
|
||||
case 'native':
|
||||
case 'linux': {
|
||||
integrationFactory = procIntegration;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'kubernetes':
|
||||
case 'k8s': {
|
||||
integrationFactory = kubernetesIntegration;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
log.error('INTG', 'Unknown integration: %s', integration);
|
||||
throw new Error(`Unknown integration: ${integration}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loading integration: %s', integration);
|
||||
try {
|
||||
const res = await integrationFactory.isAvailable(
|
||||
integrationFactory.context,
|
||||
);
|
||||
if (!res) {
|
||||
log.error('INTG', 'Integration %s is not available', integration);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loaded integration: %s', integration);
|
||||
return integrationFactory;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface IntegrationFactory<T = any> {
|
||||
name: string;
|
||||
context: T;
|
||||
isAvailable: (context: T) => Promise<boolean> | boolean;
|
||||
onConfigChange?: (context: T) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function createIntegration<T>(options: IntegrationFactory<T>) {
|
||||
return options;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
|
||||
import log from '~/utils/log';
|
||||
|
||||
import { createIntegration } from './integration';
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined;
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Native Linux (/proc)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', '/proc is only available on Linux');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking /proc for Headscale process');
|
||||
const dir = resolve('/proc');
|
||||
try {
|
||||
const subdirs = await readdir(dir);
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10);
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline');
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (data.includes('headscale')) {
|
||||
return pid;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const pids = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids);
|
||||
if (pids.length > 1) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process');
|
||||
return false;
|
||||
}
|
||||
|
||||
context.pid = pids[0];
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
|
||||
return true;
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
+20
-24
@@ -1,19 +1,11 @@
|
||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import Header from '~/components/Header';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import Footer from '~/components/Footer';
|
||||
import Link from '~/components/Link';
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import { cn } from '~/utils/cn';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { HeadscaleError, pull, healthcheck } from '~/utils/headscale';
|
||||
import { destroySession, getSession } from '~/utils/sessions.server';
|
||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
|
||||
import log from '~/utils/log';
|
||||
import { destroySession, getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
let healthy = false;
|
||||
@@ -45,25 +37,29 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
return {
|
||||
healthy,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
useLiveData({ interval: 3000 });
|
||||
const { healthy } = useLoaderData<typeof loader>()
|
||||
const { healthy } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!healthy ? (
|
||||
<div className={cn(
|
||||
'fixed bottom-0 right-0 z-50 w-fit h-14',
|
||||
'flex flex-col justify-center gap-1',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
|
||||
'border rounded-lg text-white bg-red-500',
|
||||
'border-red-600 dark:border-red-400 shadow-sm',
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 right-0 z-50 w-fit h-14',
|
||||
'flex flex-col justify-center gap-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
|
||||
'border rounded-lg text-white bg-red-500',
|
||||
'border-red-600 dark:border-red-400 shadow-sm',
|
||||
)}
|
||||
>
|
||||
<XCircleFillIcon className="w-4 h-4 text-white" />
|
||||
Headscale is unreachable
|
||||
</div>
|
||||
|
||||
+26
-9
@@ -1,22 +1,39 @@
|
||||
import Header from '~/components/Header';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { useLoaderData, LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const context = await loadContext();
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const ctx = context.context;
|
||||
const { mode, config } = hs_getConfig();
|
||||
|
||||
return {
|
||||
config: context.config,
|
||||
url: context.headscalePublicUrl ?? context.headscaleUrl,
|
||||
debug: context.debug,
|
||||
config,
|
||||
url: ctx.headscale.public_url ?? ctx.headscale.url,
|
||||
configAvailable: mode !== 'no',
|
||||
debug: ctx.debug,
|
||||
user: session.get('user'),
|
||||
};
|
||||
}
|
||||
@@ -29,5 +46,5 @@ export default function Shell() {
|
||||
<Outlet />
|
||||
<Footer {...data} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+19
-20
@@ -1,13 +1,18 @@
|
||||
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
|
||||
import { ProgressBar } from 'react-aria-components';
|
||||
import type { LinksFunction, MetaFunction } from 'react-router';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useNavigation,
|
||||
} from 'react-router';
|
||||
import '@fontsource-variable/inter';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
// TODO: Make this a default export
|
||||
import { Toaster } from '~/components/Toaster';
|
||||
import ProgressBar from '~/components/ProgressBar';
|
||||
import ToastProvider from '~/components/ToastProvider';
|
||||
import stylesheet from '~/tailwind.css?url';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { useToastQueue } from '~/utils/toast';
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: 'Headplane' },
|
||||
@@ -22,6 +27,8 @@ export const links: LinksFunction = () => [
|
||||
];
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
const toastQueue = useToastQueue();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -30,9 +37,9 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
@@ -49,16 +56,8 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar aria-label="Loading...">
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-50 w-1/2 h-1',
|
||||
'bg-blue-500 dark:bg-blue-400 opacity-0',
|
||||
nav.state === 'loading' && 'animate-loading opacity-100',
|
||||
)}
|
||||
/>
|
||||
</ProgressBar>
|
||||
<ProgressBar isVisible={nav.state === 'loading'} />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Merge from 'react-codemirror-merge';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import * as shopify from '@shopify/lang-jsonc';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { BookCopy, CircleX } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Merge from 'react-codemirror-merge';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import Fallback from './fallback';
|
||||
|
||||
interface EditorProps {
|
||||
@@ -27,39 +25,28 @@ export function Editor(props: EditorProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<p
|
||||
className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}
|
||||
>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
height="100%"
|
||||
extensions={[shopify.jsonc()]}
|
||||
style={{ height: '100%' }}
|
||||
theme={light ? githubLight : githubDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
height="100%"
|
||||
extensions={[shopify.jsonc()]}
|
||||
style={{ height: '100%' }}
|
||||
theme={light ? xcodeLight : xcodeDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,41 +67,27 @@ export function Differ(props: DifferProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
{props.left === props.right ? (
|
||||
<p
|
||||
className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}
|
||||
>
|
||||
No changes
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
{props.left === props.right ? (
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<BookCopy />
|
||||
<p className="text-lg font-semibold">No changes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-editor overflow-y-scroll">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<p
|
||||
className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}
|
||||
>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge
|
||||
orientation="a-b"
|
||||
theme={light ? githubLight : githubDark}
|
||||
>
|
||||
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
|
||||
<Merge.Original
|
||||
readOnly
|
||||
value={props.left}
|
||||
@@ -129,8 +102,8 @@ export function Differ(props: DifferProps) {
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '~/utils/cn';
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Spinner from '~/components/Spinner';
|
||||
import { cn } from '~/utils/cn';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
readonly acl: string;
|
||||
@@ -7,15 +7,28 @@ interface Props {
|
||||
|
||||
export default function Fallback({ acl }: Props) {
|
||||
return (
|
||||
<div className="inline-block relative w-full h-editor">
|
||||
<Spinner className="w-4 h-4 absolute p-2" />
|
||||
<div className="relative w-full h-editor flex">
|
||||
<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
|
||||
readOnly
|
||||
className={cn(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
'pl-10 pt-1 leading-snug',
|
||||
'w-full h-editor font-mono resize-none text-sm',
|
||||
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
|
||||
'pl-1 pt-1 leading-snug',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cn } from '~/utils/cn';
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
interface Props {
|
||||
mode: 'file' | 'database';
|
||||
|
||||
+54
-106
@@ -1,35 +1,24 @@
|
||||
import {
|
||||
BeakerIcon,
|
||||
EyeIcon,
|
||||
IssueDraftIcon,
|
||||
PencilIcon,
|
||||
} from '@primer/octicons-react';
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData, useRevalidator, useFetcher } from 'react-router';
|
||||
//import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { useFetcher, useLoaderData, useRevalidator } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Spinner from '~/components/Spinner';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { loadConfig } from '~/utils/config/headscale';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { send } from '~/utils/res';
|
||||
import log from '~/utils/log';
|
||||
|
||||
import { Editor, Differ } from './components/cm.client';
|
||||
import { Unavailable } from './components/unavailable';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import toast from '~/utils/toast';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
import { ErrorView } from './components/error';
|
||||
import { Unavailable } from './components/unavailable';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
|
||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
||||
@@ -56,10 +45,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 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();
|
||||
const { mode, config } = hs_getConfig();
|
||||
let modeGuess = 'database'; // Assume database mode
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig();
|
||||
if (mode !== 'no') {
|
||||
modeGuess = config.policy?.mode ?? 'database';
|
||||
}
|
||||
|
||||
@@ -134,7 +122,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
|
||||
|
||||
// @ts-ignore: Shut UP we know it's a string most of the time
|
||||
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
|
||||
const text = JSON.parse(error.message);
|
||||
return send(
|
||||
{ success: false, error: text.message },
|
||||
@@ -143,8 +131,6 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true, error: null };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
@@ -160,7 +146,6 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
if (fetcher.data.success) {
|
||||
toast('Updated tailnet ACL policy');
|
||||
} else {
|
||||
@@ -190,7 +175,6 @@ export default function Page() {
|
||||
}
|
||||
|
||||
// If we have a failed fetcher state allow the user to try again
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
if (fetcher.data?.success === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -202,7 +186,7 @@ export default function Page() {
|
||||
<div>
|
||||
{data.read && !data.write ? (
|
||||
<div className="mb-4">
|
||||
<Notice className="w-fit">
|
||||
<Notice>
|
||||
The ACL policy is read-only. You can view the current policy but you
|
||||
cannot make changes to it.
|
||||
<br />
|
||||
@@ -230,88 +214,52 @@ export default function Page() {
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
fetcher.data?.success === false ? (
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined
|
||||
}
|
||||
{fetcher.data?.success === false ? (
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs>
|
||||
<TabList
|
||||
className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs.Item
|
||||
key="edit"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="p-1" />
|
||||
<span>Edit file</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="diff"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="p-1" />
|
||||
<span>Preview changes</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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" />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="preview"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="p-1" />
|
||||
<span>Preview rules</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Construction />
|
||||
<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.
|
||||
Previewing rules is not available yet. This feature is still
|
||||
in development and is pretty complicated to implement.
|
||||
Hopefully I will be able to get to it soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
|
||||
+43
-28
@@ -4,19 +4,20 @@ import {
|
||||
redirect,
|
||||
} from 'react-router';
|
||||
import { Form, useActionData, useLoaderData } from 'react-router';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import TextField from '~/components/TextField';
|
||||
import Input from '~/components/Input';
|
||||
import type { Key } from '~/types';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines', {
|
||||
@@ -26,28 +27,37 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const context = await loadContext();
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
// Only set if OIDC is properly enabled anyways
|
||||
if (context.oidc?.disableKeyLogin) {
|
||||
const ctx = context.context;
|
||||
if (ctx.oidc?.disable_api_key_login) {
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc?.issuer,
|
||||
apiKey: !context.oidc?.disableKeyLogin,
|
||||
oidc: ctx.oidc?.issuer,
|
||||
apiKey: !ctx.oidc?.disable_api_key_login,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<AppContext>) {
|
||||
const formData = await request.formData();
|
||||
const oidcStart = formData.get('oidc-start');
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
|
||||
if (oidcStart) {
|
||||
const context = await loadContext();
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
if (!context.oidc) {
|
||||
const ctx = context.context;
|
||||
if (!ctx.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
}
|
||||
|
||||
@@ -92,15 +102,14 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const showOr = useMemo(() => data.oidc && data.apiKey, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
|
||||
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
{data.apiKey ? (
|
||||
<Form method="post">
|
||||
<Card.Text className="mb-8 text-sm">
|
||||
<Card.Text>
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running <Code>headscale apikeys create</Code> in your
|
||||
terminal.
|
||||
@@ -109,30 +118,36 @@ export default function Page() {
|
||||
{actionData?.error ? (
|
||||
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
|
||||
) : undefined}
|
||||
<TextField
|
||||
<Input
|
||||
isRequired
|
||||
labelHidden
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
className="mt-4 mb-2"
|
||||
/>
|
||||
<Button className="w-full mt-2.5" variant="heavy" type="submit">
|
||||
Login
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
) : 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 ? (
|
||||
<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" />
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
Login with SSO
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
variant={data.apiKey ? 'light' : 'heavy'}
|
||||
type="submit"
|
||||
>
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { getSession, commitSession } from '~/utils/sessions.server';
|
||||
import { finishAuthFlow, getRedirectUri, formatError } from '~/utils/oidc';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { finishAuthFlow, formatError } from '~/utils/oidc';
|
||||
import { send } from '~/utils/res';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
// Check if we have 0 query parameters
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
@@ -13,41 +17,41 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines')
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
// This is a hold-over from the old code
|
||||
// TODO: Rewrite checkOIDC in the context loader
|
||||
const { oidc } = await loadContext();
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const { oidc } = context.context;
|
||||
if (!oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
}
|
||||
|
||||
const oidcConfig = {
|
||||
issuer: oidc.issuer,
|
||||
clientId: oidc.client,
|
||||
clientSecret: oidc.secret,
|
||||
redirectUri: oidc.redirectUri,
|
||||
tokenEndpointAuthMethod: oidc.method,
|
||||
}
|
||||
|
||||
const codeVerifier = session.get('oidc_code_verif');
|
||||
const state = session.get('oidc_state');
|
||||
const nonce = session.get('oidc_nonce');
|
||||
const redirectUri = session.get('oidc_redirect_uri');
|
||||
|
||||
if (!codeVerifier || !state || !nonce) {
|
||||
if (!codeVerifier || !state || !nonce || !redirectUri) {
|
||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Reconstruct the redirect URI using the query parameters
|
||||
// and the one we saved in the session
|
||||
const flowRedirectUri = new URL(redirectUri);
|
||||
flowRedirectUri.search = url.search;
|
||||
|
||||
const flowOptions = {
|
||||
redirect_uri: request.url,
|
||||
redirect_uri: flowRedirectUri.toString(),
|
||||
codeVerifier,
|
||||
state,
|
||||
nonce: nonce === '<none>' ? undefined : nonce,
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const user = await finishAuthFlow(oidcConfig, flowOptions);
|
||||
const user = await finishAuthFlow(oidc, flowOptions);
|
||||
session.set('user', user);
|
||||
session.unset('oidc_code_verif');
|
||||
session.unset('oidc_state');
|
||||
@@ -57,21 +61,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// keys because they are currently non-deletable in the headscale
|
||||
// database. Look at this in the future once we have a solution
|
||||
// or we have permissioned API keys.
|
||||
session.set('hsApiKey', oidc.rootKey);
|
||||
session.set('hsApiKey', oidc.headscale_api_key);
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify(formatError(error)),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify(formatError(error)), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
import { type LoaderFunctionArgs, data, redirect } from 'react-router';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import { send } from '~/utils/res';
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines')
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
// This is a hold-over from the old code
|
||||
// TODO: Rewrite checkOIDC in the context loader
|
||||
const { oidc } = await loadContext();
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const { oidc } = context.context;
|
||||
if (!oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
}
|
||||
|
||||
const oidcConfig = {
|
||||
issuer: oidc.issuer,
|
||||
clientId: oidc.client,
|
||||
clientSecret: oidc.secret,
|
||||
redirectUri: oidc.redirectUri,
|
||||
tokenEndpointAuthMethod: oidc.method,
|
||||
}
|
||||
|
||||
const redirectUri = oidcConfig.redirectUri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(oidcConfig, redirectUri);
|
||||
const redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(oidc, redirectUri);
|
||||
session.set('oidc_code_verif', data.codeVerifier);
|
||||
session.set('oidc_state', data.state);
|
||||
session.set('oidc_nonce', data.nonce);
|
||||
session.set('oidc_redirect_uri', redirectUri);
|
||||
|
||||
return redirect(data.url, {
|
||||
status: 302,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useSubmit } from 'react-router';
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import TableList from '~/components/TableList';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
import AddDNS from '../dialogs/dns';
|
||||
|
||||
interface Props {
|
||||
records: { name: string; type: 'A'; value: string }[];
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function DNS({ records, isDisabled }: Props) {
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||
only <Code>A</Code> records are supported.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
name="Headscale DNS Records documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TableList className="mb-8">
|
||||
{records.length === 0 ? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">No DNS records found</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
<TableList.Item key={index}>
|
||||
<div className="flex gap-24">
|
||||
<div className="flex gap-2">
|
||||
<p className="font-mono text-sm font-bold">{record.type}</p>
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
submit(
|
||||
{
|
||||
'dns.extra_records': records.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
|
||||
{isDisabled ? undefined : <AddDNS records={records} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
|
||||
import { type FetcherWithComponents, useFetcher } from 'react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input } from 'react-aria-components';
|
||||
|
||||
import Spinner from '~/components/Spinner';
|
||||
import TableList from '~/components/TableList';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
type Properties = {
|
||||
readonly baseDomain?: string;
|
||||
readonly searchDomains: string[];
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function Domains({
|
||||
baseDomain,
|
||||
searchDomains,
|
||||
disabled,
|
||||
}: Properties) {
|
||||
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
|
||||
const [activeId, setActiveId] = useState<number | string | null>(null);
|
||||
const [localDomains, setLocalDomains] = useState(searchDomains);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const fetcher = useFetcher();
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDomains(searchDomains);
|
||||
}, [searchDomains]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-2">
|
||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||
your tailnet domain is used as the first search domain.
|
||||
</p>
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={(event) => {
|
||||
setActiveId(event.active.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeItem = localDomains[(active.id as number) - 1];
|
||||
const overItem = localDomains[(over.id as number) - 1];
|
||||
|
||||
if (!activeItem || !overItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = localDomains.indexOf(activeItem);
|
||||
const newIndex = localDomains.indexOf(overItem);
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableList>
|
||||
{baseDomain ? (
|
||||
<TableList.Item key="magic-dns-sd">
|
||||
<p className="font-mono text-sm">{baseDomain}</p>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
<SortableContext
|
||||
items={localDomains}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{localDomains.map((sd, index) => (
|
||||
<Domain
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
domain={sd}
|
||||
id={index + 1}
|
||||
localDomains={localDomains}
|
||||
disabled={disabled}
|
||||
fetcher={fetcher}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay adjustScale>
|
||||
{activeId ? (
|
||||
<Domain
|
||||
isDrag
|
||||
domain={localDomains[(activeId as number) - 1]}
|
||||
localDomains={localDomains}
|
||||
id={(activeId as number) - 1}
|
||||
disabled={disabled}
|
||||
fetcher={fetcher}
|
||||
/>
|
||||
) : undefined}
|
||||
</DragOverlay>
|
||||
</SortableContext>
|
||||
{disabled ? undefined : (
|
||||
<TableList.Item key="add-sd">
|
||||
<Input
|
||||
type="text"
|
||||
className="font-mono text-sm bg-transparent w-full mr-2"
|
||||
placeholder="Search Domain"
|
||||
value={newDomain}
|
||||
onChange={(event) => {
|
||||
setNewDomain(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{fetcher.state === 'idle' ? (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
'text-blue-600 dark:text-blue-400',
|
||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||
newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={newDomain.length === 0}
|
||||
onPress={() => {
|
||||
fetcher.submit(
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns.search_domains': [...localDomains, newDomain],
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
setNewDomain('');
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
) : (
|
||||
<Spinner className="w-3 h-3 mr-0" />
|
||||
)}
|
||||
</TableList.Item>
|
||||
)}
|
||||
</TableList>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DomainProperties = {
|
||||
readonly domain: string;
|
||||
readonly id: number;
|
||||
readonly isDrag?: boolean;
|
||||
readonly localDomains: string[];
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
};
|
||||
|
||||
function Domain({
|
||||
domain,
|
||||
id,
|
||||
localDomains,
|
||||
isDrag,
|
||||
disabled,
|
||||
fetcher,
|
||||
}: DomainProperties) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
// TODO: Figure out why TableList.Item breaks dndkit
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2',
|
||||
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
||||
isDragging ? 'text-gray-400' : '',
|
||||
isDrag
|
||||
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
|
||||
: '',
|
||||
)}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
>
|
||||
<p className="font-mono text-sm flex items-center gap-4">
|
||||
{disabled ? undefined : (
|
||||
<ThreeBarsIcon
|
||||
className="h-4 w-4 text-gray-400 focus:outline-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
)}
|
||||
{domain}
|
||||
</p>
|
||||
{isDrag ? undefined : (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
fetcher.submit(
|
||||
{
|
||||
'dns.search_domains': localDomains.filter(
|
||||
(_, index) => index !== id - 1,
|
||||
),
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useFetcher } from 'react-router';
|
||||
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Spinner from '~/components/Spinner';
|
||||
|
||||
type Properties = {
|
||||
readonly isEnabled: boolean;
|
||||
readonly disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Devices will no longer be accessible via your tailnet domain. The
|
||||
search domain will also be disabled.
|
||||
</Dialog.Text>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={() => {
|
||||
fetcher.submit(
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns.magic_dns': !isEnabled,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Lock } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Input from '~/components/Input';
|
||||
import TableList from '~/components/TableList';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
searchDomains: string[];
|
||||
isDisabled: boolean;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
export default function ManageDomains({
|
||||
searchDomains,
|
||||
isDisabled,
|
||||
magic,
|
||||
}: Props) {
|
||||
const [activeId, setActiveId] = useState<number | string | null>(null);
|
||||
const [localDomains, setLocalDomains] = useState(searchDomains);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDomains(searchDomains);
|
||||
}, [searchDomains]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
||||
<p className="mb-4">
|
||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||
your tailnet domain is used as the first search domain.
|
||||
</p>
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={(event) => {
|
||||
setActiveId(event.active.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeItem = localDomains[(active.id as number) - 1];
|
||||
const overItem = localDomains[(over.id as number) - 1];
|
||||
|
||||
if (!activeItem || !overItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = localDomains.indexOf(activeItem);
|
||||
const newIndex = localDomains.indexOf(overItem);
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableList>
|
||||
{magic ? (
|
||||
<TableList.Item key="magic-dns-sd">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-4',
|
||||
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
|
||||
)}
|
||||
>
|
||||
<Lock className="p-0.5" />
|
||||
<p className="font-mono text-sm py-0.5">{magic}</p>
|
||||
</div>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
<SortableContext
|
||||
items={localDomains}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{localDomains.map((sd, index) => (
|
||||
<Domain
|
||||
key={sd}
|
||||
domain={sd}
|
||||
id={index + 1}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay adjustScale>
|
||||
{activeId ? (
|
||||
<Domain
|
||||
isDragging
|
||||
domain={localDomains[(activeId as number) - 1]}
|
||||
id={(activeId as number) - 1}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
) : undefined}
|
||||
</DragOverlay>
|
||||
</SortableContext>
|
||||
{isDisabled ? undefined : (
|
||||
<TableList.Item key="add-sd">
|
||||
<Form
|
||||
method="POST"
|
||||
className="flex items-center justify-between w-full"
|
||||
>
|
||||
<input type="hidden" name="action_id" value="add_domain" />
|
||||
<Input
|
||||
type="text"
|
||||
className={cn(
|
||||
'border-none font-mono p-0 text-sm',
|
||||
'rounded-none focus:ring-0 w-full ml-1',
|
||||
)}
|
||||
placeholder="Search Domain"
|
||||
label="Search Domain"
|
||||
name="domain"
|
||||
labelHidden
|
||||
isRequired
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Form>
|
||||
</TableList.Item>
|
||||
)}
|
||||
</TableList>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainProps {
|
||||
domain: string;
|
||||
id: number;
|
||||
isDragging?: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<TableList.Item
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isSortableDragging ? 'opacity-50' : '',
|
||||
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
|
||||
)}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
>
|
||||
<p className="font-mono text-sm flex items-center gap-4">
|
||||
{isDisabled ? undefined : (
|
||||
<GripVertical
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-0.5 focus:ring outline-none rounded-md"
|
||||
/>
|
||||
)}
|
||||
{domain}
|
||||
</p>
|
||||
{isDragging ? undefined : (
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_domain" />
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isDisabled}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</TableList.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Form } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import TableList from '~/components/TableList';
|
||||
import cn from '~/utils/cn';
|
||||
import AddNS from '../dialogs/add-ns';
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function ManageNS({ nameservers, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p>
|
||||
Set the nameservers used by devices on the Tailnet to resolve DNS
|
||||
queries.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map((key) => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean;
|
||||
isDisabled: boolean;
|
||||
nameservers: Record<string, string[]>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function NameserverList({
|
||||
isGlobal,
|
||||
isDisabled,
|
||||
nameservers,
|
||||
name,
|
||||
}: ListProps) {
|
||||
const list = isGlobal ? nameservers.global : nameservers[name];
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
</div>
|
||||
<TableList>
|
||||
{list.length > 0
|
||||
? list.map((ns) => (
|
||||
<TableList.Item key={ns}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_ns" />
|
||||
<input type="hidden" name="ns" value={ns} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="split_name"
|
||||
value={isGlobal ? 'global' : name}
|
||||
/>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</TableList.Item>
|
||||
))
|
||||
: undefined}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Form } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import TableList from '~/components/TableList';
|
||||
import cn from '~/utils/cn';
|
||||
import AddRecord from '../dialogs/add-record';
|
||||
|
||||
interface Props {
|
||||
records: { name: string; type: 'A' | string; value: string }[];
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p>
|
||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||
only <Code>A</Code> records are supported.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
name="Headscale DNS Records documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TableList className="mb-8">
|
||||
{records.length === 0 ? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 mx-auto">No DNS records found</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
<TableList.Item key={`${record.name}-${record.value}`}>
|
||||
<div className="flex gap-24 items-center">
|
||||
<div className="flex gap-4 items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'font-mono text-sm font-bold py-1 px-2 rounded-md',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30',
|
||||
)}
|
||||
>
|
||||
{record.type}
|
||||
</p>
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_record" />
|
||||
<input type="hidden" name="record_name" value={record.name} />
|
||||
<input type="hidden" name="record_type" value={record.type} />
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isDisabled}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
|
||||
{isDisabled ? undefined : <AddRecord records={records} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
import Link from '~/components/Link';
|
||||
import Switch from '~/components/Switch';
|
||||
import TableList from '~/components/TableList';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
import AddNameserver from '../dialogs/nameserver';
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet to resolve DNS
|
||||
queries.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map((key) => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled ? undefined : <AddNameserver nameservers={nameservers} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean;
|
||||
isDisabled: boolean;
|
||||
nameservers: Record<string, string[]>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function NameserverList({
|
||||
isGlobal,
|
||||
isDisabled,
|
||||
nameservers,
|
||||
name,
|
||||
}: ListProps) {
|
||||
const submit = useSubmit();
|
||||
const list = isGlobal ? nameservers['global'] : nameservers[name];
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
</div>
|
||||
<TableList>
|
||||
{list.length > 0
|
||||
? list.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isGlobal) {
|
||||
submit(
|
||||
{
|
||||
'dns.nameservers.global': list.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
submit(
|
||||
{
|
||||
'dns.nameservers.split': {
|
||||
...nameservers,
|
||||
[name]: list.filter((_, i) => i !== index),
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
: undefined}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3 gap-y-4">
|
||||
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
|
||||
<p>
|
||||
This is the base domain name of your Tailnet. Devices are accessible at{' '}
|
||||
<Code>[device].{name}</Code> when Magic DNS is enabled.
|
||||
</p>
|
||||
<Input
|
||||
isReadOnly
|
||||
labelHidden
|
||||
className="w-3/5 font-medium text-sm"
|
||||
label="Tailnet name"
|
||||
value={name}
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
}}
|
||||
/>
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
|
||||
<Dialog.Panel isDisabled={isDisabled}>
|
||||
<Dialog.Title>Rename Tailnet</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Keep in mind that changing this can lead to all sorts of unexpected
|
||||
behavior and may break existing devices in your tailnet.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename_tailnet" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Tailnet name"
|
||||
placeholder="ts.net"
|
||||
defaultValue={name}
|
||||
name="new_name"
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useFetcher } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { Input } from 'react-aria-components';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Spinner from '~/components/Spinner';
|
||||
import TextField from '~/components/TextField';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
readonly disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function Modal({ name, disabled }: Properties) {
|
||||
const [newName, setNewName] = useState(name);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Tailnet Name</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
This is the base domain name of your Tailnet. Devices are accessible at{' '}
|
||||
<Code>[device].{name}</Code> when Magic DNS is enabled.
|
||||
</p>
|
||||
<Input
|
||||
readOnly
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 text-sm',
|
||||
'outline-none',
|
||||
)}
|
||||
type="text"
|
||||
value={name}
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
}}
|
||||
/>
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Rename Tailnet
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Rename Tailnet</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Keep in mind that changing this can lead to all sorts of
|
||||
unexpected behavior and may break existing devices in your
|
||||
tailnet.
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Tailnet name"
|
||||
placeholder="ts.net"
|
||||
state={[newName, setNewName]}
|
||||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={() => {
|
||||
fetcher.submit(
|
||||
{
|
||||
'dns.base_domain': newName,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
|
||||
interface Props {
|
||||
isEnabled: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ isEnabled, isDisabled }: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel isDisabled={isDisabled}>
|
||||
<Dialog.Title>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Devices will no longer be accessible via your tailnet domain. The
|
||||
search domain will also be disabled.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="toggle_magic" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="new_state"
|
||||
value={isEnabled ? 'disabled' : 'enabled'}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Chip from '~/components/Chip';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import Switch from '~/components/Switch';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export default function AddNameserver({ nameservers }: Props) {
|
||||
const [split, setSplit] = useState(false);
|
||||
const [ns, setNs] = useState('');
|
||||
const [domain, setDomain] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (ns === '') return false;
|
||||
// Test if it's a valid IPv4 or IPv6 address
|
||||
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
||||
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
|
||||
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
|
||||
|
||||
if (split) {
|
||||
return nameservers[domain]?.includes(ns);
|
||||
}
|
||||
|
||||
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
|
||||
}, [nameservers, ns]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add nameserver</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
|
||||
<input type="hidden" name="action_id" value="add_ns" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Nameserver"
|
||||
description="Use this IPv4 or IPv6 address to resolve names."
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
onChange={setNs}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Split DNS"
|
||||
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
|
||||
className={cn('inline-flex items-center')}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS (Tailscale v1.8 or later
|
||||
for most platforms) will use this nameserver. Older clients
|
||||
will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch label="Split DNS" onChange={setSplit} />
|
||||
</div>
|
||||
{split ? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
|
||||
<Input
|
||||
isRequired={split === true}
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="split_name"
|
||||
onChange={setDomain}
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries matching this suffix
|
||||
should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
) : (
|
||||
<input type="hidden" name="split_name" value="global" />
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface Props {
|
||||
records: { name: string; type: 'A' | string; value: string }[];
|
||||
}
|
||||
|
||||
export default function AddRecord({ records }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [ip, setIp] = useState('');
|
||||
|
||||
const isDuplicate = useMemo(() => {
|
||||
if (name.length === 0 || ip.length === 0) return false;
|
||||
const lookup = records.find((record) => record.name === name);
|
||||
if (!lookup) return false;
|
||||
|
||||
return lookup.value === ip;
|
||||
}, [records, name, ip]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add DNS record</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add DNS record</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<input type="hidden" name="action_id" value="add_record" />
|
||||
<input type="hidden" name="record_type" value="A" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="record_name"
|
||||
onChange={setName}
|
||||
isInvalid={isDuplicate}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
name="record_value"
|
||||
onChange={setIp}
|
||||
isInvalid={isDuplicate}
|
||||
/>
|
||||
{isDuplicate ? (
|
||||
<p className="text-sm opacity-50">
|
||||
A record with the domain name <Code>{name}</Code> and IP address{' '}
|
||||
<Code>{ip}</Code> already exists.
|
||||
</p>
|
||||
) : undefined}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
records: { name: string; type: 'A'; value: string }[];
|
||||
}
|
||||
|
||||
export default function AddDNS({ records }: Props) {
|
||||
const submit = useSubmit();
|
||||
const [name, setName] = useState('');
|
||||
const [ip, setIp] = useState('');
|
||||
|
||||
const isDuplicate = useMemo(() => {
|
||||
if (name.length === 0 || ip.length === 0) return false;
|
||||
const lookup = records.find((record) => record.name === name);
|
||||
if (!lookup) return false;
|
||||
|
||||
return lookup.value === ip;
|
||||
}, [records, name, ip]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add DNS record</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Add DNS record</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!name || !ip) return;
|
||||
|
||||
setName('');
|
||||
setIp('');
|
||||
|
||||
submit(
|
||||
{
|
||||
'dns.extra_records': [
|
||||
...records,
|
||||
{
|
||||
name,
|
||||
type: 'A',
|
||||
value: ip,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="domain"
|
||||
state={[name, setName]}
|
||||
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
|
||||
/>
|
||||
<TextField
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
name="ip"
|
||||
state={[ip, setIp]}
|
||||
className={cn(isDuplicate && 'outline outline-red-500')}
|
||||
/>
|
||||
{isDuplicate ? (
|
||||
<p className="text-sm opacity-50">
|
||||
A record with the domain name <Code>{name}</Code> and IP
|
||||
address <Code>{ip}</Code> already exists.
|
||||
</p>
|
||||
) : undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={isDuplicate}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Switch from '~/components/Switch';
|
||||
import TextField from '~/components/TextField';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export default function AddNameserver({ nameservers }: Props) {
|
||||
const submit = useSubmit();
|
||||
const [split, setSplit] = useState(false);
|
||||
const [ns, setNs] = useState('');
|
||||
const [domain, setDomain] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add nameserver</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Add nameserver</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this IPv4 or IPv6 address to resolve names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!ns) return;
|
||||
|
||||
if (split) {
|
||||
const splitNs: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(nameservers)) {
|
||||
if (key === 'global') continue;
|
||||
splitNs[key] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(splitNs).includes(domain)) {
|
||||
splitNs[domain].push(ns);
|
||||
} else {
|
||||
splitNs[domain] = [ns];
|
||||
}
|
||||
|
||||
submit(
|
||||
{
|
||||
'dns.nameservers.split': splitNs,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const globalNs = nameservers.global;
|
||||
globalNs.push(ns);
|
||||
|
||||
submit(
|
||||
{
|
||||
'dns.nameservers.global': globalNs,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setNs('');
|
||||
setDomain('');
|
||||
setSplit(false);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="DNS Server"
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
state={[ns, setNs]}
|
||||
className="mt-2 mb-8"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Tooltip.Button
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||
Split DNS
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS (Tailscale v1.8 or
|
||||
later for most platforms) will use this nameserver.
|
||||
Older clients will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
defaultSelected={split}
|
||||
onChange={() => {
|
||||
setSplit(!split);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{split ? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">
|
||||
Domain
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
state={[domain, setDomain]}
|
||||
className="my-2"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries matching this
|
||||
suffix should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
) : undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader';
|
||||
import { hp_getIntegration } from '~/utils/integration/loader';
|
||||
import { auth } from '~/utils/sessions.server';
|
||||
|
||||
export async function dnsAction({ request }: ActionFunctionArgs) {
|
||||
const session = await auth(request);
|
||||
if (!session) {
|
||||
return data({ success: false }, 401);
|
||||
}
|
||||
|
||||
const { mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'rename_tailnet':
|
||||
return renameTailnet(formData);
|
||||
case 'toggle_magic':
|
||||
return toggleMagic(formData);
|
||||
case 'remove_ns':
|
||||
return removeNs(formData);
|
||||
case 'add_ns':
|
||||
return addNs(formData);
|
||||
case 'remove_domain':
|
||||
return removeDomain(formData);
|
||||
case 'add_domain':
|
||||
return addDomain(formData);
|
||||
case 'remove_record':
|
||||
return removeRecord(formData);
|
||||
case 'add_record':
|
||||
return addRecord(formData);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTailnet(formData: FormData) {
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.base_domain',
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function toggleMagic(formData: FormData) {
|
||||
const newState = formData.get('new_state')?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.magic_dns',
|
||||
value: newState === 'enabled',
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function removeNs(formData: FormData) {
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function addNs(formData: FormData) {
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function removeDomain(formData: FormData) {
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function addDomain(formData: FormData) {
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function removeRecord(formData: FormData) {
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
|
||||
if (!recordName || !recordType) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records.filter(
|
||||
(i) => i.name !== recordName || i.type !== recordType,
|
||||
);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
|
||||
async function addRecord(formData: FormData) {
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
const recordValue = formData.get('record_value')?.toString();
|
||||
|
||||
if (!recordName || !recordType || !recordValue) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records;
|
||||
records.push({ name: recordName, type: recordType, value: recordValue });
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
}
|
||||
+24
-50
@@ -1,33 +1,26 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import { data, useLoaderData } from 'react-router';
|
||||
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Notice from '~/components/Notice';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
|
||||
import DNS from './components/dns';
|
||||
import Domains from './components/domains';
|
||||
import MagicModal from './components/magic';
|
||||
import Nameservers from './components/nameservers';
|
||||
import RenameModal from './components/rename';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import ManageDomains from './components/manage-domains';
|
||||
import ManageNS from './components/manage-ns';
|
||||
import ManageRecords from './components/manage-records';
|
||||
import RenameTailnet from './components/rename-tailnet';
|
||||
import ToggleMagic from './components/toggle-magic';
|
||||
import { dnsAction } from './dns-actions';
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const context = await loadContext();
|
||||
if (!context.config.read) {
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode === 'no') {
|
||||
throw new Error('No configuration is available');
|
||||
}
|
||||
|
||||
const config = await loadConfig();
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.use_username_in_magic_dns
|
||||
? `[user].${config.dns.base_domain}`
|
||||
: config.dns.base_domain,
|
||||
baseDomain: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
@@ -36,33 +29,15 @@ export async function loader() {
|
||||
|
||||
return {
|
||||
...dns,
|
||||
...context,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return data({ success: false }, { status: 401 });
|
||||
}
|
||||
|
||||
const context = await loadContext();
|
||||
if (!context.config.write) {
|
||||
return data({ success: false }, { status: 403 });
|
||||
}
|
||||
|
||||
const patch = (await request.json()) as Record<string, unknown>;
|
||||
await patchConfig(patch);
|
||||
|
||||
if (context.integration?.onConfigChange) {
|
||||
await context.integration.onConfigChange(context.integration.context);
|
||||
}
|
||||
|
||||
return data({ success: true });
|
||||
export async function action(data: ActionFunctionArgs) {
|
||||
return dnsAction(data);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 5000 });
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const allNs: Record<string, string[]> = {};
|
||||
@@ -71,29 +46,28 @@ export default function Page() {
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers;
|
||||
const isDisabled = data.mode !== 'rw';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.config.write ? undefined : (
|
||||
{data.mode === 'rw' ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to
|
||||
the configuration
|
||||
</Notice>
|
||||
)}
|
||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||
<Nameservers nameservers={allNs} isDisabled={!data.config.write} />
|
||||
|
||||
<DNS records={data.extraRecords} isDisabled={!data.config.write} />
|
||||
|
||||
<Domains
|
||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
|
||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||
<ManageDomains
|
||||
searchDomains={data.searchDomains}
|
||||
disabled={!data.config.write}
|
||||
isDisabled={isDisabled}
|
||||
magic={data.magicDns ? data.baseDomain : undefined}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-2/3">
|
||||
<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.
|
||||
Devices will be accessible at{' '}
|
||||
<Code>
|
||||
@@ -102,7 +76,7 @@ export default function Page() {
|
||||
</Code>{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import { del, post } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { send } from '~/utils/res';
|
||||
import log from '~/utils/log';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
|
||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
@@ -109,9 +109,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const to = String(data.get('to'));
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
|
||||
await post(`v1/node/${id}/user`, session.get('hsApiKey')!, {
|
||||
user: to,
|
||||
});
|
||||
|
||||
return { message: `Moved node ${id} to ${to}` };
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return send(
|
||||
{ message: `Failed to move node ${id} to ${to}` },
|
||||
{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
|
||||
import { Link } from 'react-router';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import Chip from '~/components/Chip';
|
||||
import Menu from '~/components/Menu';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import type { Machine, Route, User, HostInfo } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
import type { HostInfo, Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface Props {
|
||||
@@ -18,7 +19,13 @@ interface Props {
|
||||
stats?: HostInfo;
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, magic, users, stats }: Props) {
|
||||
export default function MachineRow({
|
||||
machine,
|
||||
routes,
|
||||
magic,
|
||||
users,
|
||||
stats,
|
||||
}: Props) {
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
@@ -26,7 +33,7 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
const tags = [...machine.forcedTags, ...machine.validTags];
|
||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired');
|
||||
@@ -72,13 +79,24 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
|
||||
tags.unshift('Subnets');
|
||||
}
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
|
||||
}
|
||||
|
||||
return machine.ipAddresses;
|
||||
}, [magic, machine.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
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">
|
||||
<Link to={`/machines/${machine.id}`} className="group/link h-full">
|
||||
<td className="pl-0.5 py-2 focus-within:ring">
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
className={cn('group/link h-full focus:outline-none')}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'font-semibold leading-snug',
|
||||
@@ -88,21 +106,10 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
|
||||
>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
|
||||
{machine.name}
|
||||
</p>
|
||||
<p className="text-sm font-mono opacity-50">{machine.name}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -110,46 +117,32 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{machine.ipAddresses[0]}
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
{machine.ipAddresses.map((ip) => (
|
||||
<Menu.ItemButton
|
||||
key={ip}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full',
|
||||
)}
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(ip);
|
||||
toast('Copied IP address to clipboard');
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
))}
|
||||
{magic ? (
|
||||
<Menu.ItemButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep',
|
||||
)}
|
||||
onPress={async () => {
|
||||
const ip = `${machine.givenName}.${prefix}`;
|
||||
await navigator.clipboard.writeText(ip);
|
||||
toast('Copied hostname to clipboard');
|
||||
}}
|
||||
>
|
||||
{machine.givenName}.{prefix}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
) : undefined}
|
||||
</Menu.Items>
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={async (key) => {
|
||||
await navigator.clipboard.writeText(key.toString());
|
||||
toast('Copied IP address to clipboard');
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
{ipOptions.map((ip) => (
|
||||
<Menu.Item key={ip} textValue={ip}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'text-sm w-full gap-x-6',
|
||||
)}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
@@ -160,12 +153,12 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(stats)}
|
||||
</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)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
<p className="text-sm opacity-50">
|
||||
Unknown
|
||||
</p>
|
||||
)}
|
||||
@@ -175,7 +168,7 @@ export default function MachineRow({ machine, routes, magic, users, stats }: Pro
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { KebabHorizontalIcon } from '@primer/octicons-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import MenuComponent from '~/components/Menu';
|
||||
import { Cog, Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
import cn from '~/utils/cn';
|
||||
import Delete from '../dialogs/delete';
|
||||
import Expire from '../dialogs/expire';
|
||||
import Move from '../dialogs/move';
|
||||
@@ -17,22 +15,19 @@ interface MenuProps {
|
||||
routes: Route[];
|
||||
users: User[];
|
||||
magic?: string;
|
||||
buttonChild?: ReactNode;
|
||||
isFullButton?: boolean;
|
||||
}
|
||||
|
||||
export default function Menu({
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
|
||||
export default function MachineMenu({
|
||||
machine,
|
||||
routes,
|
||||
magic,
|
||||
users,
|
||||
buttonChild,
|
||||
isFullButton,
|
||||
}: MenuProps) {
|
||||
const renameState = useState(false);
|
||||
const expireState = useState(false);
|
||||
const removeState = useState(false);
|
||||
const routesState = useState(false);
|
||||
const moveState = useState(false);
|
||||
const tagsState = useState(false);
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
@@ -43,51 +38,103 @@ export default function Menu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rename machine={machine} state={renameState} magic={magic} />
|
||||
<Delete machine={machine} state={removeState} />
|
||||
{expired ? undefined : <Expire machine={machine} state={expireState} />}
|
||||
<Routes machine={machine} routes={routes} state={routesState} />
|
||||
<Tags machine={machine} state={tagsState} />
|
||||
<Move machine={machine} state={moveState} users={users} magic={magic} />
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={machine}
|
||||
isOpen={modal === 'remove'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'move' && (
|
||||
<Move
|
||||
machine={machine}
|
||||
users={users}
|
||||
isOpen={modal === 'move'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
machine={machine}
|
||||
magic={magic}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'routes' && (
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
isOpen={modal === 'routes'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'tags' && (
|
||||
<Tags
|
||||
machine={machine}
|
||||
isOpen={modal === 'tags'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{expired && modal === 'expire' ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
isOpen={modal === 'expire'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MenuComponent>
|
||||
{buttonChild ?? (
|
||||
<MenuComponent.Button
|
||||
<Menu>
|
||||
{isFullButton ? (
|
||||
<Menu.Button className="flex items-center gap-x-2">
|
||||
<Cog className="h-5" />
|
||||
<p>Machine Settings</p>
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<Menu.IconButton
|
||||
label="Machine Options"
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
|
||||
'py-0.5 w-10 bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
'dark:group-hover:border-headplane-700',
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className="w-5" />
|
||||
</MenuComponent.Button>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
)}
|
||||
<MenuComponent.Items>
|
||||
<MenuComponent.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={tagsState}>
|
||||
Edit ACL tags
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={moveState}>
|
||||
Change owner
|
||||
</MenuComponent.ItemButton>
|
||||
{expired ? undefined : (
|
||||
<MenuComponent.ItemButton control={expireState}>
|
||||
Expire
|
||||
</MenuComponent.ItemButton>
|
||||
)}
|
||||
<MenuComponent.ItemButton
|
||||
className="text-red-500 dark:text-red-400"
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</MenuComponent.ItemButton>
|
||||
</MenuComponent.Items>
|
||||
</MenuComponent>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
||||
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
|
||||
<Menu.Item key="move">Change owner</Menu.Item>
|
||||
</Menu.Section>
|
||||
<Menu.Section>
|
||||
{expired ? (
|
||||
<></>
|
||||
) : (
|
||||
<Menu.Item key="expire" textValue="Expire">
|
||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="remove" textValue="Remove">
|
||||
<p className="text-red-500 dark:text-red-400">Remove</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,23 @@
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { Machine } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
interface DeleteProps {
|
||||
readonly machine: Machine;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Delete({ machine, state }: DeleteProps) {
|
||||
const submit = useSubmit();
|
||||
|
||||
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This machine will be permanently removed from your network. To
|
||||
re-add it, you will need to reauthenticate to your tailnet from
|
||||
the device.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
Remove
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This machine will be permanently removed from your network. To re-add
|
||||
it, you will need to reauthenticate to your tailnet from the device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,56 +1,23 @@
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { Machine } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
interface ExpireProps {
|
||||
readonly machine: Machine;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Expire({ machine, state }: ExpireProps) {
|
||||
const submit = useSubmit();
|
||||
|
||||
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This will disconnect the machine from your Tailnet. In order to
|
||||
reconnect, you will need to re-authenticate from the device.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="expire" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This will disconnect the machine from your Tailnet. In order to
|
||||
reconnect, you will need to re-authenticate from the device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="expire" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,71 +1,34 @@
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Select from '~/components/Select';
|
||||
import type { Machine, User } from '~/types';
|
||||
|
||||
interface MoveProps {
|
||||
readonly machine: Machine;
|
||||
readonly users: User[];
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly magic?: string;
|
||||
machine: Machine;
|
||||
users: User[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Move({ machine, state, magic, users }: MoveProps) {
|
||||
const [owner, setOwner] = useState(machine.user.name);
|
||||
const submit = useSubmit();
|
||||
|
||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="move" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<Select
|
||||
label="Owner"
|
||||
name="to"
|
||||
placeholder="Select a user"
|
||||
state={[owner, setOwner]}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
{magic ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}.{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
) : undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Change owner
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="move" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<Select
|
||||
label="Owner"
|
||||
name="to"
|
||||
placeholder="Select a user"
|
||||
defaultSelectedKey={machine.user.id}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Form, useFetcher, Link } from 'react-router';
|
||||
import { Dispatch, SetStateAction, useState, useEffect } from 'react';
|
||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
import { Computer, KeySquare } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
import Select from '~/components/Select';
|
||||
import Input from '~/components/Input';
|
||||
import Menu from '~/components/Menu';
|
||||
import Spinner from '~/components/Spinner';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import { Machine, type User } from '~/types';
|
||||
import Select from '~/components/Select';
|
||||
import type { User } from '~/types';
|
||||
|
||||
export interface NewProps {
|
||||
server: string;
|
||||
@@ -18,112 +14,74 @@ export interface NewProps {
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
const fetcher = useFetcher<{ success?: boolean }>();
|
||||
const mkeyState = useState(false);
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const [mkey, setMkey] = useState('');
|
||||
const [user, setUser] = useState('');
|
||||
const [toasted, setToasted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Registered new machine');
|
||||
} else {
|
||||
toast('Failed to register machine due to an invalid key');
|
||||
}
|
||||
|
||||
setToasted(true);
|
||||
}, [fetcher.data, toasted, mkey]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog>
|
||||
<Dialog.Panel control={mkeyState}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
The machine key is given when you run{' '}
|
||||
<Code isCopyable>
|
||||
tailscale up --login-server=
|
||||
{data.server}
|
||||
</Code>{' '}
|
||||
on your device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
fetcher.submit(e.currentTarget);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<TextField
|
||||
label="Machine Key"
|
||||
placeholder="mkey:ff....."
|
||||
name="mkey"
|
||||
state={[mkey, setMkey]}
|
||||
className="my-2 font-mono"
|
||||
/>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
isDisabled={
|
||||
!mkey || !mkey.trim().startsWith('mkey:') || !user
|
||||
}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Register
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||
<Dialog.Panel isDisabled={!mkey.trim().startsWith('mkey:')}>
|
||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
The machine key is given when you run{' '}
|
||||
<Code isCopyable>
|
||||
tailscale up --login-server=
|
||||
{data.server}
|
||||
</Code>{' '}
|
||||
on your device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
placeholder="mkey:ff....."
|
||||
validationBehavior="native"
|
||||
name="mkey"
|
||||
onChange={setMkey}
|
||||
/>
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
)}
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
if (key === 'register') {
|
||||
setPushDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'pre-auth') {
|
||||
navigate('/settings/auth-keys');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Device
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={mkeyState}>
|
||||
<ServerIcon className="w-4 h-4 mr-2" />
|
||||
Register Machine Key
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton>
|
||||
<Link to="/settings/auth-keys">
|
||||
<KeyIcon className="w-4 h-4 mr-2" />
|
||||
Generate Pre-auth Key
|
||||
</Link>
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="register" textValue="Register Machine Key">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Computer className="w-4" />
|
||||
Register Machine Key
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeySquare className="w-4" />
|
||||
Generate Pre-auth Key
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,78 +1,59 @@
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
import Input from '~/components/Input';
|
||||
import type { Machine } from '~/types';
|
||||
|
||||
interface RenameProps {
|
||||
readonly machine: Machine;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly magic?: string;
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
magic?: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
||||
export default function Rename({
|
||||
machine,
|
||||
magic,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName);
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit machine name for {machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This name is shown in the admin panel, in Tailscale clients, and
|
||||
used when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<TextField
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
name="name"
|
||||
state={[name, setName]}
|
||||
className="my-2"
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname{' '}
|
||||
<Code className="text-sm">
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname{' '}
|
||||
<Code className="text-sm">{machine.givenName}</Code> will no
|
||||
longer point to this machine.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname{' '}
|
||||
<Code className="text-sm">{machine.givenName}</Code>.
|
||||
</p>
|
||||
)
|
||||
) : undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
This name is shown in the admin panel, in Tailscale clients, and used
|
||||
when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<Input
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
name="name"
|
||||
defaultValue={machine.givenName}
|
||||
onChange={setName}
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
|
||||
This machine will be accessible by the hostname{' '}
|
||||
<Code className="text-sm">
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname <Code className="text-sm">{machine.givenName}</Code>{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
|
||||
This machine is accessible by the hostname{' '}
|
||||
<Code className="text-sm">{machine.givenName}</Code>.
|
||||
</p>
|
||||
)
|
||||
) : undefined}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { GlobeLock, RouteOff } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Switch from '~/components/Switch';
|
||||
import Link from '~/components/Link';
|
||||
import Switch from '~/components/Switch';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { Machine, Route } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface RoutesProps {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
export default function Routes({
|
||||
machine,
|
||||
routes,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: RoutesProps) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
@@ -40,136 +47,87 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
}, [exit]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit route settings of {machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on by
|
||||
advertising IP ranges as subnet routes.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<div
|
||||
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 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>No routes are advertised on this machine.</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
{subnet.map((route) => (
|
||||
<div
|
||||
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>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'routes');
|
||||
form.set('route', route.id);
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant="unactionable">
|
||||
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on by advertising
|
||||
IP ranges as subnet routes.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<TableList className="mt-4">
|
||||
{subnet.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<RouteOff />
|
||||
<p className="font-semibold">
|
||||
No routes are advertised by this machine
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
{subnet.map((route) => (
|
||||
<TableList.Item key={route.id}>
|
||||
<p>{route.prefix}</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'routes');
|
||||
form.set('route', route.id);
|
||||
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Allow your network to route internet traffic through this machine.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
name="Tailscale Exit-node Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<div
|
||||
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 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>This machine is not an exit node.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>Use as exit node</p>
|
||||
<Switch
|
||||
defaultSelected={exitEnabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'exit-node');
|
||||
form.set(
|
||||
'routes',
|
||||
exit.map((route) => route.id).join(','),
|
||||
);
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Allow your network to route internet traffic through this machine.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
name="Tailscale Exit-node Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<TableList className="mt-4">
|
||||
{exit.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<GlobeLock />
|
||||
<p className="font-semibold">This machine is not an exit node</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
<TableList.Item>
|
||||
<p>Use as exit node</p>
|
||||
<Switch
|
||||
defaultSelected={exitEnabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'exit-node');
|
||||
form.set('routes', exit.map((route) => route.id).join(','));
|
||||
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
isDisabled={fetcher.state === 'submitting'}
|
||||
onPress={close}
|
||||
>
|
||||
Close
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableList.Item>
|
||||
)}
|
||||
</TableList>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,146 +1,100 @@
|
||||
import { PlusIcon, XIcon } from '@primer/octicons-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
import { Button, Input } from 'react-aria-components';
|
||||
|
||||
import { Plus, TagsIcon, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Button from '~/components/Button';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import Link from '~/components/Link';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { Machine } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface TagsProps {
|
||||
readonly machine: Machine;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Tags({ machine, state }: TagsProps) {
|
||||
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
const [tags, setTags] = useState(machine.forcedTags);
|
||||
const [tag, setTag] = useState('');
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies.
|
||||
See the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
name="Tailscale documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget);
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies. See
|
||||
the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
name="Tailscale documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="tags" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<TagsIcon />
|
||||
<p className="font-semibold">No tags are set on this machine</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" key={item} id={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
<TableList.Item
|
||||
className={cn(
|
||||
'rounded-b-xl focus-within:ring',
|
||||
tag.length > 0 &&
|
||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
||||
'ring ring-red-500 ring-opacity-50',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
labelHidden
|
||||
label="Add a tag"
|
||||
placeholder="tag:example"
|
||||
onChange={setTag}
|
||||
className={cn(
|
||||
'border-none font-mono p-0',
|
||||
'rounded-none focus:ring-0 w-full',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-md p-0.5',
|
||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
||||
'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={
|
||||
tag.length === 0 ||
|
||||
!tag.startsWith('tag:') ||
|
||||
tags.includes(tag)
|
||||
}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag]);
|
||||
setTag('');
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="tags" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<div
|
||||
className={cn(
|
||||
'border border-ui-300 rounded-lg overflow-visible',
|
||||
'dark:border-ui-700 dark:text-ui-300 mt-4',
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
||||
{tags.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center rounded-t-lg',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>No tags are set on this machine.</p>
|
||||
</div>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
id={item}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 flex',
|
||||
'items-center justify-between',
|
||||
'font-mono text-sm',
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex px-2.5 py-1.5 w-full',
|
||||
'border-t border-ui-300 dark:border-ui-700',
|
||||
'rounded-b-lg justify-between items-center',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
tag.length > 0 &&
|
||||
!tag.startsWith('tag:') &&
|
||||
'outline outline-red-500',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
placeholder="tag:example"
|
||||
className={cn(
|
||||
'bg-transparent w-full',
|
||||
'border-none focus:ring-0',
|
||||
'focus:outline-none font-mono text-sm',
|
||||
'dark:bg-transparent dark:text-ui-300',
|
||||
)}
|
||||
value={tag}
|
||||
onChange={(e) => {
|
||||
setTag(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg p-0 h-6 w-6',
|
||||
!tag.startsWith('tag:') &&
|
||||
'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={
|
||||
tag.length === 0 ||
|
||||
!tag.startsWith('tag:') ||
|
||||
tags.includes(tag)
|
||||
}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag]);
|
||||
setTag('');
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Save
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
<Plus className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
</TableList>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import {
|
||||
InfoIcon,
|
||||
GearIcon,
|
||||
CheckCircleIcon,
|
||||
SkipIcon,
|
||||
PersonIcon,
|
||||
} from '@primer/octicons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Menu from '~/components/Menu';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import Chip from '~/components/Chip';
|
||||
import Link from '~/components/Link';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { loadConfig } from '~/utils/config/headscale';
|
||||
import cn from '~/utils/cn';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import Link from '~/components/Link';
|
||||
|
||||
import { menuAction } from './action';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
@@ -34,11 +24,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
throw new Error('No machine ID provided');
|
||||
}
|
||||
|
||||
const context = await loadContext();
|
||||
const { mode, config } = hs_getConfig();
|
||||
let magic: string | undefined;
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig();
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
}
|
||||
@@ -64,8 +53,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>();
|
||||
const routesState = useState(false);
|
||||
useLiveData({ interval: 1000 });
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
@@ -74,7 +62,7 @@ export default function Page() {
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
const tags = [...machine.forcedTags, ...machine.validTags];
|
||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired');
|
||||
@@ -127,81 +115,57 @@ export default function Page() {
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between items-center',
|
||||
'border-b border-ui-100 dark:border-ui-800',
|
||||
'flex justify-between items-center pb-2',
|
||||
'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>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
isFullButton
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
buttonChild={
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-x-2',
|
||||
'bg-main-200 dark:bg-main-700/30',
|
||||
'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
'text-ui-700 dark:text-ui-300 mb-2',
|
||||
'w-fit text-sm rounded-lg px-3 py-2',
|
||||
)}
|
||||
>
|
||||
<GearIcon className="w-5" />
|
||||
Machine Settings
|
||||
</Menu.Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
|
||||
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Info className="p-1" />
|
||||
<Tooltip.Body>
|
||||
By default, a machine’s permissions match its creator’s.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<div
|
||||
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>
|
||||
<UserCircle />
|
||||
{machine.user.name}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
|
||||
<Routes machine={machine} routes={routes} state={routesState} />
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
isOpen={showRouting}
|
||||
setIsOpen={setShowRouting}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||
@@ -212,9 +176,7 @@ export default function Page() {
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button variant="light" control={routesState}>
|
||||
Review
|
||||
</Button>
|
||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||
</div>
|
||||
<Card
|
||||
variant="flat"
|
||||
@@ -224,12 +186,10 @@ export default function Page() {
|
||||
)}
|
||||
>
|
||||
<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
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being routed through this machine.
|
||||
</Tooltip.Body>
|
||||
@@ -237,7 +197,7 @@ export default function Page() {
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnetApproved.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">—</span>
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnetApproved.map((route) => (
|
||||
@@ -247,24 +207,20 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => setShowRouting(true)}
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</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
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes, but they must be
|
||||
approved before traffic will be routed to them.
|
||||
@@ -273,7 +229,7 @@ export default function Page() {
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnet.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">—</span>
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnet.map((route) => (
|
||||
@@ -283,24 +239,20 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => setShowRouting(true)}
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</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
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
@@ -308,27 +260,25 @@ export default function Page() {
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{exit.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">—</span>
|
||||
<span className="opacity-50">—</span>
|
||||
) : exitEnabled ? (
|
||||
<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
|
||||
</span>
|
||||
) : (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onPress={() => setShowRouting(true)}
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { InfoIcon } from '@primer/octicons-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import Link from '~/components/Link';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { loadConfig } from '~/utils/config/headscale';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
|
||||
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { AppContext } from '~server/context/app';
|
||||
import { menuAction } from './action';
|
||||
import MachineRow from './components/machine';
|
||||
import NewMachine from './dialogs/new';
|
||||
|
||||
export async function loader({ request, context: lC }: LoaderFunctionArgs) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
@@ -27,21 +30,22 @@ export async function loader({ request, context: lC }: LoaderFunctionArgs) {
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
]);
|
||||
|
||||
initAgentSocket(lC);
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
initAgentSocket(context);
|
||||
|
||||
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
|
||||
const context = await loadContext();
|
||||
const ctx = context.context;
|
||||
const { mode, config } = hs_getConfig();
|
||||
|
||||
let magic: string | undefined;
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig();
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
}
|
||||
|
||||
if (config.dns.use_username_in_magic_dns) {
|
||||
magic = `[user].${magic}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -50,8 +54,8 @@ export async function loader({ request, context: lC }: LoaderFunctionArgs) {
|
||||
users: users.users,
|
||||
magic,
|
||||
stats,
|
||||
server: context.headscaleUrl,
|
||||
publicServer: context.headscalePublicUrl,
|
||||
server: ctx.headscale.url,
|
||||
publicServer: ctx.headscale.public_url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,15 +64,14 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 });
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
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">
|
||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<h1 className="text-2xl font-medium mb-2">Machines</h1>
|
||||
<p>
|
||||
Manage the devices connected to your Tailnet.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
@@ -84,43 +87,35 @@ export default function Page() {
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-gray-500 dark:text-gray-400">
|
||||
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
||||
<th className="pb-2">Name</th>
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
||||
<th className="pb-2 w-1/4">
|
||||
<div className="flex items-center gap-x-1">
|
||||
Addresses
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
{data.magic ? (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Tooltip
|
||||
className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-zinc-800',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based on
|
||||
their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{data.magic}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/**<th className="pb-2">Version</th>**/}
|
||||
<th className="pb-2">Last Seen</th>
|
||||
{/**<th className="uppercase text-xs font-bold pb-2">Version</th>**/}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border-t border-zinc-200 dark:border-zinc-700',
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{data.nodes.map((machine) => (
|
||||
@@ -142,7 +137,5 @@ export default function Page() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<ErrorPopup type="embedded" />
|
||||
)
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import { pull, post } from '~/utils/headscale';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { useState } from 'react';
|
||||
import { send } from '~/utils/res';
|
||||
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import TableList from '~/components/TableList';
|
||||
import Select from '~/components/Select';
|
||||
import Switch from '~/components/Switch';
|
||||
|
||||
import AddPreAuthKey from './dialogs/new';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import { post, pull } from '~/utils/headscale';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import AuthKeyRow from './components/key';
|
||||
import AddPreAuthKey from './dialogs/new';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
@@ -94,14 +91,21 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const context = await loadContext();
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const users = await pull<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('hsApiKey')!,
|
||||
);
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const ctx = context.context;
|
||||
const preAuthKeys = await Promise.all(
|
||||
users.users.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
@@ -117,38 +121,37 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
return {
|
||||
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
|
||||
users: users.users,
|
||||
server: context.headscalePublicUrl ?? context.headscaleUrl,
|
||||
server: ctx.headscale.public_url ?? ctx.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { keys, users, server } = useLoaderData<typeof loader>();
|
||||
const [user, setUser] = useState('All');
|
||||
const [status, setStatus] = useState('Active');
|
||||
useLiveData({ interval: 3000 });
|
||||
const [user, setUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState('active');
|
||||
|
||||
const filteredKeys = keys.filter((key) => {
|
||||
if (user !== 'All' && key.user !== user) {
|
||||
if (user !== '__headplane_all' && key.user !== user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status !== 'All') {
|
||||
if (status !== 'all') {
|
||||
const now = new Date();
|
||||
const expiry = new Date(key.expiration);
|
||||
|
||||
if (status === 'Active') {
|
||||
if (status === 'active') {
|
||||
return !(expiry < now) && (!key.used || key.reusable);
|
||||
}
|
||||
|
||||
if (status === 'Used/Expired') {
|
||||
if (status === 'expired') {
|
||||
return key.used || expiry < now;
|
||||
}
|
||||
|
||||
if (status === 'Reusable') {
|
||||
if (status === 'reusable') {
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
if (status === 'Ephemeral') {
|
||||
if (status === 'ephemeral') {
|
||||
return key.ephemeral;
|
||||
}
|
||||
}
|
||||
@@ -156,6 +159,7 @@ export default function Page() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// TODO: Fix the selects
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-8 text-md">
|
||||
@@ -164,8 +168,8 @@ export default function Page() {
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add
|
||||
devices to your Tailnet. To learn more about using pre-authentication
|
||||
keys, visit the{' '}
|
||||
@@ -177,40 +181,34 @@ export default function Page() {
|
||||
</Link>
|
||||
</p>
|
||||
<AddPreAuthKey users={users} />
|
||||
<div className="flex justify-between gap-4 mt-4">
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Filter by user
|
||||
</p>
|
||||
<Select
|
||||
label="Filter by User"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
<Select.Item id="All">All</Select.Item>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Filter by status
|
||||
</p>
|
||||
<Select
|
||||
label="Filter by status"
|
||||
placeholder="Select a status"
|
||||
state={[status, setStatus]}
|
||||
>
|
||||
<Select.Item id="All">All</Select.Item>
|
||||
<Select.Item id="Active">Active</Select.Item>
|
||||
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
|
||||
<Select.Item id="Reusable">Reusable</Select.Item>
|
||||
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
label="Filter by User"
|
||||
placeholder="Select a user"
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...users.map((user) => (
|
||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
label="Filter by status"
|
||||
placeholder="Select a status"
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{filteredKeys.length === 0 ? (
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import Link from '~/components/Link';
|
||||
import Button from '~/components/Button';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export default function AgentSection() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headplane provides a local agent that can be installed on a
|
||||
server to provide additional features including viewing device
|
||||
information and SSH access via the web interface (soon).
|
||||
To learn more about the agent visit the{' '}
|
||||
<p>
|
||||
Headplane provides a local agent that can be installed on a server to
|
||||
provide additional features including viewing device information and
|
||||
SSH access via the web interface (soon). To learn more about the agent
|
||||
visit the{' '}
|
||||
<Link
|
||||
to="https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"
|
||||
name="Headplane Agent Documentation"
|
||||
@@ -23,16 +23,11 @@ export default function AgentSection() {
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/local-agent">
|
||||
<div
|
||||
className={cn(
|
||||
'text-lg font-medium flex items-center',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
)}
|
||||
>
|
||||
<div className={cn('text-lg font-medium flex items-center')}>
|
||||
Manage Agent
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import Card from '~/components/Card';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import type { HostInfo } from '~/types';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
@@ -12,26 +12,21 @@ export default function AgentManagement({ reachable, hostInfo }: Props) {
|
||||
console.log('hostInfo:', hostInfo);
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Local Agent Configuration
|
||||
</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-8">
|
||||
A local agent has already been configured for this
|
||||
Headplane instance. You can manage the agent settings here.
|
||||
<h1 className="text-2xl font-medium mb-4">Local Agent Configuration</h1>
|
||||
<p className="mb-8">
|
||||
A local agent has already been configured for this Headplane instance.
|
||||
You can manage the agent settings here.
|
||||
</p>
|
||||
<Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusCircle
|
||||
isOnline={reachable}
|
||||
className="w-4 h-4 px-1 w-fit"
|
||||
/>
|
||||
<StatusCircle isOnline={reachable} className="w-4 h-4 px-1" />
|
||||
<div>
|
||||
<p className="text-lg font-bold">
|
||||
{hostInfo.Hostname ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="leading-snug">
|
||||
{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)}
|
||||
</span>
|
||||
</p>
|
||||
@@ -40,5 +35,5 @@ export default function AgentManagement({ reachable, hostInfo }: Props) {
|
||||
{JSON.stringify(hostInfo)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PreAuthKey } from '~/types';
|
||||
import { toast } from '~/components/Toaster';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Button from '~/components/Button';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import toast from '~/utils/toast';
|
||||
import ExpireKey from '../dialogs/expire';
|
||||
|
||||
interface Props {
|
||||
@@ -31,7 +31,7 @@ export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
tailscale up --login-server {server} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div className="flex gap-4 items-center">
|
||||
{authKey.used && !authKey.reusable ||
|
||||
{(authKey.used && !authKey.reusable) ||
|
||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||
<ExpireKey authKey={authKey} />
|
||||
)}
|
||||
|
||||
@@ -1,61 +1,22 @@
|
||||
import { useFetcher } from 'react-router';
|
||||
import type { PreAuthKey } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Spinner from '~/components/Spinner';
|
||||
import type { PreAuthKey } from '~/types';
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey;
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">Expire Key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<fetcher.Form
|
||||
method="DELETE"
|
||||
onSubmit={(e) => {
|
||||
fetcher.submit(e.currentTarget);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it
|
||||
from being used to authenticate new devices. This action cannot
|
||||
be undone.
|
||||
</Dialog.Text>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
<Dialog.Button>Expire Key</Dialog.Button>
|
||||
<Dialog.Panel method="DELETE" variant="destructive">
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it from
|
||||
being used to authenticate new devices. This action cannot be undone.
|
||||
</Dialog.Text>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useFetcher } from 'react-router';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
import NumberField from '~/components/NumberField';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import Link from '~/components/Link';
|
||||
import NumberInput from '~/components/NumberInput';
|
||||
import Select from '~/components/Select';
|
||||
import Switch from '~/components/Switch';
|
||||
import Link from '~/components/Link';
|
||||
import Spinner from '~/components/Spinner';
|
||||
|
||||
import { cn } from '~/utils/cn';
|
||||
import type { User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
@@ -20,128 +13,81 @@ interface Props {
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddPreAuthKey(data: Props) {
|
||||
const fetcher = useFetcher();
|
||||
const [user, setUser] = useState('');
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [aclTags, setAclTags] = useState([]);
|
||||
const [expiry, setExpiry] = useState(90);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
fetcher.submit(e.currentTarget);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Attach this key to a user
|
||||
</Dialog.Text>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<NumberInput
|
||||
isRequired
|
||||
name="expiry"
|
||||
label="Key Expiration"
|
||||
description="Set this key to expire after a certain number of days."
|
||||
minValue={1}
|
||||
maxValue={365_000} // 1000 years
|
||||
defaultValue={90}
|
||||
formatOptions={{
|
||||
style: 'unit',
|
||||
unit: 'day',
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this key to authenticate more than one device.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Reusable"
|
||||
name="reusable"
|
||||
defaultSelected={reusable}
|
||||
onChange={() => {
|
||||
setReusable(!reusable);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Devices authenticated with this key will be automatically removed
|
||||
once they go offline.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||
name="Tailscale Ephemeral Nodes Documentation"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<Dialog.Text className="font-semibold mt-4">
|
||||
Key Expiration
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Set this key to expire after a certain number of days.
|
||||
</Dialog.Text>
|
||||
<NumberField
|
||||
label="Expiry"
|
||||
name="expiry"
|
||||
minValue={1}
|
||||
maxValue={365_000} // 1000 years
|
||||
state={[expiry, setExpiry]}
|
||||
formatOptions={{
|
||||
style: 'unit',
|
||||
unit: 'day',
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this key to authenticate more than one device.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Reusable"
|
||||
name="reusable"
|
||||
defaultSelected={reusable}
|
||||
onChange={() => {
|
||||
setReusable(!reusable);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="reusable"
|
||||
value={reusable.toString()}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Devices authenticated with this key will be automatically
|
||||
removed once they go offline.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||
name="Tailscale Ephemeral Nodes Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Ephemeral"
|
||||
name="ephemeral"
|
||||
defaultSelected={ephemeral}
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="ephemeral"
|
||||
value={ephemeral.toString()}
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={!user || !expiry}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Generate
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
Learn more
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Ephemeral"
|
||||
name="ephemeral"
|
||||
defaultSelected={ephemeral}
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLoaderData, type LoaderFunctionArgs } from 'react-router';
|
||||
import { getSession, commitSession } from '~/utils/sessions.server'
|
||||
import { queryAgent } from '~/utils/ws-agent'
|
||||
import AgentManagement from './components/agent/manage'
|
||||
import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import { queryAgent } from '~/utils/ws-agent';
|
||||
import AgentManagement from './components/agent/manage';
|
||||
|
||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
||||
const { ws, wsAuthKey } = context;
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const onboarding = session.get('agent_onboarding') ?? false;
|
||||
|
||||
const nodeKey = 'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b'
|
||||
const nodeKey =
|
||||
'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b';
|
||||
const stats = await queryAgent([nodeKey]);
|
||||
|
||||
return {
|
||||
configured: wsAuthKey !== undefined,
|
||||
onboarding,
|
||||
stats: stats[nodeKey]
|
||||
}
|
||||
stats: stats?.[nodeKey],
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
@@ -24,21 +25,18 @@ export default function Page() {
|
||||
|
||||
// Whether we show the onboarding or management UI
|
||||
const management = useMemo(() => {
|
||||
return data.configured && (data.onboarding === false)
|
||||
return data.configured && data.onboarding === false;
|
||||
}, [data.configured, data.onboarding]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
{management ? (
|
||||
<AgentManagement
|
||||
reachable={true}
|
||||
hostInfo={data.stats}
|
||||
/>
|
||||
<AgentManagement reachable={true} hostInfo={data.stats} />
|
||||
) : (
|
||||
<div>
|
||||
<h1>Local Agent Coming Soon</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Local Agent Coming Soon</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from '~/components/Link';
|
||||
import Button from '~/components/Button';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import AgentSection from './components/agent';
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Page() {
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<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
|
||||
features, I'll be adding them here. If you require any features, feel
|
||||
free to open an issue on the GitHub repository.
|
||||
@@ -19,7 +19,7 @@ export default function Page() {
|
||||
</div>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<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
|
||||
add devices to your Tailnet. To learn more about using
|
||||
pre-authentication keys, visit the{' '}
|
||||
@@ -32,12 +32,7 @@ export default function Page() {
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/auth-keys">
|
||||
<div
|
||||
className={cn(
|
||||
'text-lg font-medium flex items-center',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
)}
|
||||
>
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Auth Keys
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react';
|
||||
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
|
||||
import Add from '../dialogs/add';
|
||||
|
||||
interface Props {
|
||||
readonly magic: string | undefined;
|
||||
}
|
||||
|
||||
export default function Auth({ magic }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<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">
|
||||
<HomeIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">Basic Authentication</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Users are not managed externally. Using OpenID Connect can create a
|
||||
better experience when using Headscale.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">User Management</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
You can add, remove, and rename users here.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Add magic={magic} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Building2, House, Key } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import CreateUser from '../dialogs/create-user';
|
||||
|
||||
interface Props {
|
||||
oidc?: NonNullable<AppContext['context']['oidc']>;
|
||||
}
|
||||
|
||||
export default function ManageBanner({ oidc }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
||||
{oidc ? (
|
||||
<Building2 className="w-5 h-5 mb-2" />
|
||||
) : (
|
||||
<House className="w-5 h-5 mb-2" />
|
||||
)}
|
||||
<h2 className="font-medium mb-1">
|
||||
{oidc ? 'OpenID Connect' : 'User Authentication'}
|
||||
</h2>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
{oidc ? (
|
||||
<>
|
||||
Users are managed through your{' '}
|
||||
<Link to={oidc.issuer} name="OIDC Provider">
|
||||
OpenID Connect provider
|
||||
</Link>
|
||||
{'. '}
|
||||
Groups and user information do not automatically sync.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Users are not managed externally. Using OpenID Connect can
|
||||
create a better experience when using Headscale.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
|
||||
<Key className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">User Management</h2>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
{oidc
|
||||
? 'You can still add users manually, however it is recommended that you manage users through your OIDC provider.'
|
||||
: 'You can add, remove, and rename users here.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CreateUser />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react';
|
||||
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import type { HeadplaneContext } from '~/utils/config/headplane';
|
||||
|
||||
import Add from '../dialogs/add';
|
||||
|
||||
interface Props {
|
||||
readonly oidc: NonNullable<HeadplaneContext['oidc']>;
|
||||
readonly magic: string | undefined;
|
||||
}
|
||||
|
||||
export default function Oidc({ oidc, magic }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<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">
|
||||
<OrganizationIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">OpenID Connect</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Users are managed through your{' '}
|
||||
<Link to={oidc.issuer} name="OIDC Provider">
|
||||
OpenID Connect provider
|
||||
</Link>
|
||||
{'. '}
|
||||
Groups and user information do not automatically sync.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">User Management</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
You can still add users manually, however it is recommended that you
|
||||
manage users through your OIDC provider.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Add magic={magic} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
|
||||
interface Props {
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
export default function Add({ magic }: Props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add a new user</Dialog.Button>
|
||||
|
||||
<Dialog.Panel>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Add a new user</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Enter a username to create a new user.{' '}
|
||||
{magic ? (
|
||||
<>
|
||||
Since Magic DNS is enabled, machines will be accessible via{' '}
|
||||
<Code>[machine]. .{magic}</Code>.
|
||||
</>
|
||||
) : undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget);
|
||||
setUsername('');
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="create" />
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="my-new-user"
|
||||
name="username"
|
||||
state={[username, setUsername]}
|
||||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Create
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
// TODO: Support image upload for user avatars
|
||||
export default function CreateUser() {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add a new user</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add a new user</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Enter a username to create a new user. Usernames can be addressed when
|
||||
managing ACL policies.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="create_user" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
name="username"
|
||||
label="Username"
|
||||
placeholder="my-new-user"
|
||||
/>
|
||||
<Input
|
||||
name="display_name"
|
||||
label="Display Name"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<Input name="email" label="Email" placeholder="name@example.com" />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { X } from 'lucide-react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import { User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// TODO: Warn that OIDC users will be recreated on next login
|
||||
export default function DeleteUser({ user }: Props) {
|
||||
const name =
|
||||
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.IconButton label={`Delete ${name}`}>
|
||||
<X className="p-0.5" />
|
||||
</Dialog.IconButton>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Delete {name}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Are you sure you want to delete {name}? A deleted user cannot be
|
||||
recovered.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="delete_user" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { XIcon } from '@primer/octicons-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default function Remove({ username }: Props) {
|
||||
const submit = useSubmit();
|
||||
const dialogState = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
control={dialogState}
|
||||
className="rounded-full p-0 w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Delete {username}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Are you sure you want to delete {username}? A deleted user
|
||||
cannot be recovered.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="username" value={username} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Delete
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Pencil } from 'lucide-react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import { User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// TODO: Server side validation before submitting
|
||||
export default function RenameUser({ user }: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.IconButton label={`Rename ${user.name}`}>
|
||||
<Pencil className="p-1" />
|
||||
</Dialog.IconButton>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Rename {user.name}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Enter a new username for {user.name}. Changing a username will not
|
||||
update any ACL policies that may refer to this user by their old
|
||||
username.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename_user" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<Input
|
||||
isRequired
|
||||
name="new_name"
|
||||
label="Username"
|
||||
placeholder="my-new-name"
|
||||
defaultValue={user.name}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { PencilIcon } from '@primer/octicons-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '~/components/Button';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
export default function Rename({ username, magic }: Props) {
|
||||
const submit = useSubmit();
|
||||
const dialogState = useState(false);
|
||||
const [newName, setNewName] = useState(username);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
control={dialogState}
|
||||
className="rounded-full p-0 w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>Rename {username}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Enter a new username for {username}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="old" value={username} />
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="my-new-name"
|
||||
name="new"
|
||||
state={[newName, setNewName]}
|
||||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+41
-126
@@ -1,36 +1,35 @@
|
||||
import {
|
||||
DataRef,
|
||||
DndContext,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { PersonIcon } from '@primer/octicons-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useActionData, useLoaderData, useSubmit } from 'react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData, useSubmit } from 'react-router';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Card from '~/components/Card';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import type { Machine, User } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
import { loadContext } from '~/utils/config/headplane';
|
||||
import { loadConfig } from '~/utils/config/headscale';
|
||||
import { del, post, pull } from '~/utils/headscale';
|
||||
import cn from '~/utils/cn';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import { send } from '~/utils/res';
|
||||
|
||||
import Auth from './components/auth';
|
||||
import Oidc from './components/oidc';
|
||||
import Remove from './dialogs/remove';
|
||||
import Rename from './dialogs/rename';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { noContext } from '~/utils/log';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import ManageBanner from './components/manage-banner';
|
||||
import DeleteUser from './dialogs/delete-user';
|
||||
import RenameUser from './dialogs/rename-user';
|
||||
import { userAction } from './user-actions';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
@@ -42,112 +41,35 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||
}));
|
||||
|
||||
const context = await loadContext();
|
||||
const ctx = context.context;
|
||||
const { mode, config } = hs_getConfig();
|
||||
let magic: string | undefined;
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig();
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
oidc: ctx.oidc,
|
||||
magic,
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ message: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
if (!data.has('_method')) {
|
||||
return send({ message: 'No method provided' }, 400);
|
||||
}
|
||||
|
||||
const method = String(data.get('_method'));
|
||||
|
||||
switch (method) {
|
||||
case 'create': {
|
||||
if (!data.has('username')) {
|
||||
return send({ message: 'No name provided' }, 400);
|
||||
}
|
||||
|
||||
const username = String(data.get('username'));
|
||||
await post('v1/user', session.get('hsApiKey')!, {
|
||||
name: username,
|
||||
});
|
||||
|
||||
return { message: `User ${username} created` };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!data.has('username')) {
|
||||
return send({ message: 'No name provided' }, 400);
|
||||
}
|
||||
|
||||
const username = String(data.get('username'));
|
||||
await del(`v1/user/${username}`, session.get('hsApiKey')!);
|
||||
return { message: `User ${username} deleted` };
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('old') || !data.has('new')) {
|
||||
return send({ message: 'No old or new name provided' }, 400);
|
||||
}
|
||||
|
||||
const old = String(data.get('old'));
|
||||
const newName = String(data.get('new'));
|
||||
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!);
|
||||
return { message: `User ${old} renamed to ${newName}` };
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
||||
return send({ message: 'No ID or destination provided' }, 400);
|
||||
}
|
||||
|
||||
const id = String(data.get('id'));
|
||||
const to = String(data.get('to'));
|
||||
const name = String(data.get('name'));
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
|
||||
return { message: `Moved ${name} to ${to}` };
|
||||
} catch {
|
||||
return send({ message: `Failed to move ${name} to ${to}` }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return send({ message: 'Invalid method' }, 400);
|
||||
}
|
||||
}
|
||||
export async function action(data: ActionFunctionArgs) {
|
||||
return userAction(data);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [users, setUsers] = useState(data.users);
|
||||
const actionData = useActionData<typeof action>();
|
||||
useLiveData({ interval: 3000 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionData) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(actionData.message);
|
||||
if (actionData.message.startsWith('Failed')) {
|
||||
setUsers(data.users);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionData]);
|
||||
const [users, setUsers] = useState<UserMachine[]>(data.users);
|
||||
|
||||
// This useEffect is entirely for the purpose of updating the users when the
|
||||
// drag and drop changes the machines between users. It's pretty hacky, but
|
||||
// the idea is to treat data.users as the source of truth and update the
|
||||
// local state when it changes.
|
||||
useEffect(() => {
|
||||
setUsers(data.users);
|
||||
}, [data.users]);
|
||||
@@ -159,11 +81,7 @@ export default function Page() {
|
||||
Manage the users in your network and their permissions. Tip: You can
|
||||
drag machines between users to change ownership.
|
||||
</p>
|
||||
{data.oidc ? (
|
||||
<Oidc oidc={data.oidc} magic={data.magic} />
|
||||
) : (
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ManageBanner oidc={data.oidc} />
|
||||
<ClientOnly fallback={<Users users={users} />}>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
@@ -230,10 +148,9 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
|
||||
setUsers?.(newUsers);
|
||||
const data = new FormData();
|
||||
data.append('_method', 'move');
|
||||
data.append('id', active.id.toString());
|
||||
data.append('to', over.id.toString());
|
||||
data.append('name', reference.current.givenName);
|
||||
data.append('action_id', 'change_owner');
|
||||
data.append('user_id', over.id.toString());
|
||||
data.append('node_id', reference.current.id);
|
||||
|
||||
submit(data, {
|
||||
method: 'POST',
|
||||
@@ -260,7 +177,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'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={{
|
||||
transform: transform
|
||||
@@ -270,7 +187,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
{...listeners}
|
||||
{...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
|
||||
name={machine.givenName}
|
||||
link={`machines/${machine.id}`}
|
||||
@@ -296,7 +213,7 @@ function UserCard({ user, magic }: CardProps) {
|
||||
variant="flat"
|
||||
className={cn(
|
||||
'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">
|
||||
@@ -305,9 +222,9 @@ function UserCard({ user, magic }: CardProps) {
|
||||
<span className="text-lg font-mono">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rename username={user.name} magic={magic} />
|
||||
<RenameUser user={user} />
|
||||
{user.machines.length === 0 ? (
|
||||
<Remove username={user.name} />
|
||||
<DeleteUser user={user} />
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,7 +239,5 @@ function UserCard({ user, magic }: CardProps) {
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<ErrorPopup type="embedded" />
|
||||
)
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { del, post } from '~/utils/headscale';
|
||||
import { auth } from '~/utils/sessions.server';
|
||||
|
||||
export async function userAction({ request }: ActionFunctionArgs) {
|
||||
const session = await auth(request);
|
||||
if (!session) {
|
||||
return data({ success: false }, 401);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const apiKey = session.get('hsApiKey');
|
||||
if (!apiKey) {
|
||||
return data({ success: false }, 401);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'create_user':
|
||||
return createUser(formData, apiKey);
|
||||
case 'delete_user':
|
||||
return deleteUser(formData, apiKey);
|
||||
case 'rename_user':
|
||||
return renameUser(formData, apiKey);
|
||||
case 'change_owner':
|
||||
return changeOwner(formData, apiKey);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(formData: FormData, apiKey: string) {
|
||||
const name = formData.get('username')?.toString();
|
||||
const displayName = formData.get('display_name')?.toString();
|
||||
const email = formData.get('email')?.toString();
|
||||
|
||||
if (!name) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await post('v1/user', apiKey, {
|
||||
name,
|
||||
displayName,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUser(formData: FormData, apiKey: string) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
if (!userId) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await del(`v1/user/${userId}`, apiKey);
|
||||
}
|
||||
|
||||
async function renameUser(formData: FormData, apiKey: string) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!userId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
||||
}
|
||||
|
||||
async function changeOwner(formData: FormData, apiKey: string) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const nodeId = formData.get('node_id')?.toString();
|
||||
if (!userId || !nodeId) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user: userId,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user