Compare commits

...

85 Commits

Author SHA1 Message Date
Aarnav Tale 272233ae62 fix: add patch command to docker build 2026-04-09 22:42:51 -04:00
Aarnav Tale 974c3b0e48 chore: v0.7.0-beta.2 2026-04-09 22:08:47 -04:00
github-actions[bot] acd3ff3403 chore: update nix pnpm deps hash 2026-04-10 02:06:23 +00:00
Aarnav Tale 1961608e12 docs: get rid of deleted png 2026-04-09 22:01:34 -04:00
Aarnav Tale c4ac28f90b fix: correct patch permissions for nix 2026-04-09 21:58:39 -04:00
Aarnav Tale a0b2077c7b chore: remove faker-js dependency 2026-04-09 21:57:46 -04:00
Aarnav Tale 0f19fdf0da feat: rebuild browser ssh from the ground up 2026-04-09 21:56:42 -04:00
Aarnav Tale d255098128 fix: make the ssh button look nicer 2026-04-07 00:36:04 -04:00
Aarnav Tale 98e0806e5a fix: handle the tag owner username 2026-04-07 00:32:59 -04:00
Aarnav Tale 44dffeaff0 fix: patch tailscale to support a custom DERP port 2026-04-07 00:24:50 -04:00
Aarnav Tale 33f7bbb0cf fix: handle empty ACLs 2026-04-07 00:24:50 -04:00
Aarnav Tale 10278d0cc9 fix: correctly expire pre-auth-keys in 0.28.0+ 2026-04-07 00:24:50 -04:00
Aarnav Tale bdcd4c5bad Merge pull request #518 from Kroppeb/fix/wrong-url-in-help-message 2026-04-06 21:42:48 -04:00
Aarnav Tale 43cff2f4b7 feat: i guess we're undoing agent work 2026-04-06 21:19:27 -04:00
Aarnav Tale 61e7303363 fix: handle URI components in provider ID 2026-04-06 21:19:27 -04:00
Aarnav Tale dc44cc4155 Merge pull request #521 from sinanmohd/chore/deprecated-package-attribute 2026-04-06 20:37:17 -04:00
Aarnav Tale 30ce5e2727 Merge pull request #514 from tale/update_flake_lock_action 2026-04-06 20:34:29 -04:00
sinanmohd 55a09476ab chore: deprecated package attributes 2026-04-05 17:50:37 +05:30
github-actions[bot] ac90b4e8bb flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/9cf7092' (2026-03-18)
  → 'github:nixos/nixpkgs/8d8c1fa' (2026-04-02)
2026-04-05 08:45:30 +00:00
Aarnav Tale 4b47b1bbed chore: update auth-service to not be class based 2026-04-03 16:57:39 -04:00
Aarnav Tale 003985d192 docs: document the modular architecture for services 2026-04-03 16:36:52 -04:00
Aarnav Tale 1259642f8a feat: replace openid-client with clean-room oidc system 2026-04-03 16:36:27 -04:00
Aarnav Tale 4cd0c1e206 fix: use headscale.api_key where possible 2026-04-03 16:34:50 -04:00
Robbe Pincket b2dfa773b0 fix: change oidc config error notice url 2026-04-01 22:17:11 +02:00
Aarnav Tale 73b5d5514e fix: don't nuke agent working dir 2026-03-30 14:02:39 -04:00
Aarnav Tale 838a2cd732 fix: store profile picture in db to prevent header overload 2026-03-30 13:58:29 -04:00
Aarnav Tale 0191da4ce2 ci: correctly check if hash changed 2026-03-30 13:32:59 -04:00
Aarnav Tale ad1d47b223 docs: 0.7.0-beta.1 2026-03-30 13:26:20 -04:00
Aarnav Tale a5e2af9b30 docs: update some option docs 2026-03-29 20:31:20 -04:00
Aarnav Tale 9ac3ab1861 Merge pull request #507 from siemenvdn/feature/extend-machine-filtering
Add additional Machines list filters
2026-03-29 20:29:33 -04:00
Siemen Van den Neste 44f45c396c fix: update user filtering to use user name instead of user ID 2026-03-29 20:37:18 +02:00
Aarnav Tale ee59a2d06d feat: switch agent to a periodic dump rather than long running process 2026-03-27 13:32:59 -04:00
Aarnav Tale 2c57187628 feat: switch to a generalized api key config 2026-03-27 13:32:59 -04:00
github-actions[bot] 57c15f7c6b chore: update nix pnpm deps hash 2026-03-26 17:00:23 +00:00
Aarnav Tale 131c54f501 fix: correctly re-enqueue event stream controller when closed 2026-03-26 12:58:35 -04:00
Aarnav Tale 333220a153 feat: ditch react-aria 2026-03-26 12:58:24 -04:00
Aarnav Tale 61ca6693c4 fix: keep footer below dialogs 2026-03-26 12:57:45 -04:00
Aarnav Tale 30bbcbab2a feat: separate code snippets and code blocks for copyable snippets 2026-03-26 12:57:30 -04:00
Aarnav Tale a1eb6d35db feat: pin codemirror to 6.6.0 2026-03-26 12:56:45 -04:00
Aarnav Tale 98d8dfe235 fix: prefix hp_wasm paths correctly
Co-authored-by: palchrb <Breyholtz@gmail.com>
2026-03-26 12:54:29 -04:00
Aarnav Tale 54f236a332 feat: update some components 2026-03-26 12:54:29 -04:00
Aarnav Tale 873e6005d9 Merge pull request #510 from lloydowen/fix/oidc-profile-pictures 2026-03-24 09:10:54 -04:00
Aarnav Tale 3213385c0f Merge pull request #509 from tale/update_flake_lock_action 2026-03-24 09:07:24 -04:00
Lloyd Owen 2e2d5d1dff Update oidc-callback.ts 2026-03-24 11:48:20 +00:00
github-actions[bot] 925d78a175 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/e802360' (2026-03-14)
  → 'github:nixos/nixpkgs/9cf7092' (2026-03-18)
2026-03-22 08:36:54 +00:00
Siemen Van den Neste 4e94e82a01 feat: implement custom hook for machine filter parameters management 2026-03-21 01:46:27 +01:00
Siemen Van den Neste 3ec00e17d8 feat: add machine filtering functionality with dropdowns for user, tag, status, and route 2026-03-21 01:43:54 +01:00
Aarnav Tale dfda629b12 test: add some unit tests for auth methods 2026-03-19 14:11:17 -04:00
github-actions[bot] 04debdcadf chore: update nix pnpm deps hash 2026-03-19 16:37:08 +00:00
Aarnav Tale 718cbf3c40 feat: switch to a new form hook 2026-03-19 12:35:25 -04:00
Aarnav Tale 246bb90ef0 test: add docker and proc e2e testing 2026-03-19 12:35:25 -04:00
Aarnav Tale 7403ebea06 feat: switch form fields to base-ui 2026-03-19 12:35:25 -04:00
Aarnav Tale 941971b8c7 feat: replace button and toast provider with base-ui 2026-03-19 12:35:24 -04:00
Aarnav Tale 5aa17e448d Merge pull request #501 from AgathaSorceress/main 2026-03-18 23:35:06 -04:00
Agatha V. Lovelace fc9df0c536 docs: openssl rand takes byte count, not character 2026-03-18 13:21:48 +01:00
github-actions[bot] b6b773fa6d chore: update nix pnpm deps hash 2026-03-17 17:43:33 +00:00
Aarnav Tale cc5aa99046 feat: upgrade to vite 8 stable 2026-03-17 13:41:47 -04:00
Aarnav Tale 5f81822366 feat: correct package.json dependencies and remove libsql 2026-03-17 13:31:35 -04:00
Aarnav Tale 4fb1cf059e feat: update nodejs 22 references to 24 2026-03-17 13:24:55 -04:00
Aarnav Tale b1361e9062 feat: upgrade to node 24 and drizzle 2026-03-17 13:22:48 -04:00
Aarnav Tale dd0587dad1 chore: update build process 2026-03-17 12:23:39 -04:00
Aarnav Tale 4997687a58 Merge pull request #500 from dotWee/feat/devcontainer
[dev/tooling]: add basic devcontainer setup w/ node, pnpm, go, nix & recommended tools
2026-03-17 11:58:50 -04:00
Aarnav Tale 8f6fe05c83 fix: actually fix type errors 2026-03-16 23:41:09 -04:00
Aarnav Tale 25dc09e025 perf: switch to SSE dispatched changes
Previously we would use naive revalidators which would invalidate EVERY
SINGLE action loader every 3 seconds, resulting in several fetches. It
would also bubble fetches across the layout actions into the individual
pages.

This new approach selectively has live stores of resources which then
poll for changes on the server side and then dispatches updates to the
client via a new /events/live SSE endpoint.
2026-03-16 23:32:06 -04:00
Aarnav Tale 27f8fa0b42 feat: swap button and decompose dialog components 2026-03-16 23:32:05 -04:00
Aarnav Tale eda5713700 Merge pull request #497 from mrangger/main 2026-03-15 21:49:11 -04:00
Aarnav Tale dc2e2dcc98 Merge pull request #498 from tale/update_flake_lock_action 2026-03-15 21:47:31 -04:00
Lukas Wolfsteiner f0df663c90 [dev/tooling]: add basic devcontainer setup w/ node, pnpm, go, nix & recommended tools
Add a development container for contributors and document how to use it.

- Add .devcontainer/devcontainer.json
- Add .devcontainer/Dockerfile
- Update docs/CONTRIBUTING.md with Dev Container section

- devcontainer.json parses successfully and opens in vscode without issues
- all build.sh variants, recommended tools & lint/typecheck run without issues

Closes #499
2026-03-15 18:55:18 +00:00
github-actions[bot] 1025a657b0 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/917fec9' (2026-03-05)
  → 'github:nixos/nixpkgs/e802360' (2026-03-14)
2026-03-15 08:38:59 +00:00
Mathias Rangger 0741567754 fix: validate required Docker API version 2026-03-15 02:08:07 +01:00
Aarnav Tale a26faab139 feat: add ownership transfer support 2026-03-14 15:17:45 -04:00
Aarnav Tale 55ddbe0987 fix: handle user deletion 2026-03-14 14:59:34 -04:00
Aarnav Tale e255407115 feat: redo user page to match account linking 2026-03-14 14:56:04 -04:00
Aarnav Tale 225a52ded6 feat: correctly use error boundaries per-page 2026-03-14 13:52:08 -04:00
Aarnav Tale 38cf93e5ae feat: replace onboarding for explicit user linking 2026-03-14 13:23:22 -04:00
Aarnav Tale b7a85684a9 fix: tweak card styling to have better contrast 2026-03-14 13:22:23 -04:00
Aarnav Tale e1808bee1e fix: use currentColor on the svgs 2026-03-14 13:22:09 -04:00
Aarnav Tale cf4c163c92 chore: update agents file 2026-03-14 11:50:19 -04:00
Aarnav Tale 6470f5a821 feat: refactor several components and clean up ui 2026-03-09 22:09:58 -04:00
Aarnav Tale a53fd31d27 chore: combine link usage 2026-03-09 13:56:03 -04:00
Aarnav Tale 37d0080cba feat: cleanup button + add correct focus rings 2026-03-09 00:17:39 -04:00
Aarnav Tale 0ce411e92b feat: switch to mist color from tailwind 2026-03-08 23:48:03 -04:00
Aarnav Tale df5e04f239 Merge pull request #491 from tale/update_flake_lock_action 2026-03-08 18:38:37 -04:00
Aarnav Tale e6d90c3124 Merge pull request #489 from tale/auth-rework 2026-03-08 18:38:25 -04:00
github-actions[bot] c7f3b60b62 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/c0f3d81' (2026-02-27)
  → 'github:nixos/nixpkgs/917fec9' (2026-03-05)
2026-03-08 08:33:58 +00:00
258 changed files with 14513 additions and 12747 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM mcr.microsoft.com/devcontainers/base:bookworm
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential libnss3-tools mkcert pkg-config && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
+44
View File
@@ -0,0 +1,44 @@
{
"name": "headplane",
"build": {
"dockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"upgradePackages": true,
"username": "vscode"
},
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "24"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.25.1"
},
"ghcr.io/devcontainers/features/nix:1": {
"extraNixConfig": "experimental-features = nix-command flakes,accept-flake-config = true"
}
},
"containerEnv": {
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"golang.go",
"ms-azuretools.vscode-docker",
"ms-vscode.vscode-typescript-next"
],
"settings": {
"editor.formatOnSave": true,
"go.useLanguageServer": true
}
}
},
"forwardPorts": [3000, 5173],
"postCreateCommand": "pnpm config set store-dir $HOME/.pnpm-store --global && pnpm install",
"remoteUser": "vscode",
"updateRemoteUserUID": true
}
+58 -6
View File
@@ -1,5 +1,11 @@
name: Automated
on:
push:
paths:
- "pnpm-lock.yaml"
- "package.json"
branches:
- "main"
schedule:
- cron: "0 8 * * 0"
workflow_dispatch:
@@ -9,13 +15,14 @@ concurrency:
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
actions: write
contents: write
pull-requests: write
jobs:
flake-inputs:
name: flake-inputs
nix-pnpm-hash:
name: Update Nix PNPM Hash
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -26,7 +33,52 @@ jobs:
with:
determinate: true
- uses: DeterminateSystems/update-flake-lock@main
- name: Cache Nix store
uses: DeterminateSystems/magic-nix-cache-action@main
- name: Compute correct PNPM dependencies hash
run: |
CURRENT_HASH=$(grep -oP "hash = \"\K[^\"]*" nix/package.nix)
sed -i "s|hash = \"$CURRENT_HASH\"|hash = \"\"|" nix/package.nix
NEW_HASH=$(nix build .#headplane.pnpmDeps --no-link 2>&1 \
| grep 'got:' \
| awk '{print $2}')
if [ -n "$NEW_HASH" ]; then
sed -i "s|hash = \"\"|hash = \"$NEW_HASH\"|" nix/package.nix
else
git checkout nix/package.nix
fi
- name: Commit and push
run: |
if git diff --quiet nix/package.nix; then
echo "Hash is already up to date, skipping"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add nix/package.nix
git commit -m "chore: update nix pnpm deps hash"
git push
flake-inputs:
name: Update Flake Inputs
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
+9 -5
View File
@@ -15,8 +15,8 @@ concurrency:
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
actions: write
contents: read
jobs:
ci:
@@ -41,8 +41,9 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: CI pipeline
- name: Build
run: ./build.sh --skip-pnpm-prune
- name: Run unit tests
@@ -52,7 +53,7 @@ jobs:
run: pnpm run test:integration
nix:
name: nix
name: Nix Flake Check
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -63,8 +64,11 @@ jobs:
with:
determinate: true
- name: Cache Nix store
uses: DeterminateSystems/magic-nix-cache-action@main
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@main
- name: Check flake outputs
run: nix flake check --all-systems
run: nix flake check
+3 -2
View File
@@ -9,9 +9,10 @@ permissions:
jobs:
label:
name: Label PR
name: Label Pull Request
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
- name: Apply labels based on changed files
uses: actions/labeler@v6
with:
sync-labels: true
+4 -3
View File
@@ -9,11 +9,12 @@ permissions:
jobs:
beta:
name: Beta Release
name: Beta Release Lifecycle
if: contains(github.ref_name, '-')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check out the repo
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -48,7 +49,7 @@ jobs:
done
stable:
name: Stable Release
name: Stable Release Lifecycle
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
+11 -12
View File
@@ -9,15 +9,15 @@ concurrency:
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
id-token: write # For the attest action to push
attestations: write # For the attest action to push
actions: write
contents: read
packages: write
id-token: write
attestations: write
jobs:
docker:
name: Docker Release
name: Docker Release (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
matrix:
@@ -26,12 +26,11 @@ jobs:
tag_suffix: ""
- target: debug-shell
tag_suffix: "-shell"
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Docker Metadata
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
@@ -55,9 +54,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: docker/build-push-action@v6
- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -71,9 +70,9 @@ jobs:
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
+3 -2
View File
@@ -10,10 +10,11 @@ permissions:
jobs:
stale:
name: Close stale issues
name: Close Stale Issues
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- name: Mark and close stale issues
uses: actions/stale@v10
with:
stale-issue-label: Stale
only-labels: "Needs Info"
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
remove-triage:
name: Remove Needs Triage
name: Remove Needs Triage Label
runs-on: ubuntu-latest
steps:
- name: Remove Needs Triage label
+1
View File
@@ -13,3 +13,4 @@ node_modules
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
/vendor
+4 -2
View File
@@ -42,8 +42,10 @@ care to use our preferred PNPM version and Node version as defined in the
`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for
type-checking and formatting respectively.
You can also run Headscale CLI commands with
`docker exec headscale headscale <command>` when the dev environment is running.
When typechecking, use `pnpm run typecheck`, when linting and formatting, use
the respective `lint` and `format` scripts, you can pass flags to them. You can
also run Headscale CLI commands with `docker exec headscale headscale <command>`
when the dev environment is running.
## Docs
+59
View File
@@ -1,3 +1,62 @@
# 0.7.0-beta.2 (April 9, 2026)
> This is a beta release. Please report any issues you encounter.
- **Rebuilt the Browser SSH feature**
- Should now work with custom DERP ports and properly handle sessions.
- Switched to using `libghostty` for a proper, modern terminal experience (closes [#515](https://github.com/tale/headplane/issues/515)).
- Added more resilient error handling and state handling when initiating connections.
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
- Removed the `openid-client` dependency entirely.
- Fixed `client_secret_basic` auth method not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493)).
- Fixed OIDC connector initialization failures on beta.1 (closes [#516](https://github.com/tale/headplane/issues/516)).
- **Rearchitected the Headplane Agent** with a new sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet and fetches all peer hostinfo as JSON.
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
- Added `integration.agent.executable_path` config field.
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`).
- Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Both the agent and OIDC now use the same key from `headscale.api_key`.
- **Reworked the authentication system** with a new `AuthService` that consolidates session management and role enforcement (via [#489](https://github.com/tale/headplane/pull/489)).
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
- Store OIDC profile pictures in the database to prevent cookie header overload (closes [#326](https://github.com/tale/headplane/issues/326), via [#510](https://github.com/tale/headplane/pull/510)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers that use special characters in user IDs (e.g. Auth0 `github|12345`) (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed intermittent SSR crash on the Access Control page caused by client-only CodeMirror imports.
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed.
- **Rewrote the WebSSH WASM module** to match Tailscale's proven `tsconnect` init sequence.
- Switched the terminal renderer from xterm.js to [restty](https://restty.dev) (Ghostty WASM).
- Bundled self-hosted JetBrains Mono Nerd Font with Nerd Fonts symbol fallback — no CDN dependency.
- Fixed SSH sessions failing with EOF: the SSH channel multiplexer was not receiving server traffic.
- Fixed terminal resize sending swapped rows/cols, causing garbled output on window resize.
- Fixed `log.Fatal()` calls in the WASM bridge killing the entire runtime on recoverable errors.
- Fixed `Close()` returning `true` on error and `false` on success.
- Fixed stale closure bug in the NodeKey tracking callback.
- Removed unnecessary `LoginDefault` and `LocalBackendStartKeyOSNeutral` control flags.
- Added cancellation support for in-flight SSH connections on close.
- Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)).
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Nix WASM build applying DERP patch to wrong vendor directory.
- Fixed Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
---
# 0.6.2 (February 26, 2026)
- **Added support for Headscale 0.28.0** including all API and data model changes.
+7 -7
View File
@@ -1,7 +1,9 @@
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum build.sh ./
COPY patches/ ./patches/
RUN go mod download
COPY cmd/ ./cmd/
@@ -25,25 +27,24 @@ RUN chmod +x /bin/hp_healthcheck
# Folder needs to exist for later stages
RUN mkdir -p /var/lib/headplane/agent
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
FROM --platform=$BUILDPLATFORM node:24-slim AS js-base
WORKDIR /run
RUN corepack enable
COPY patches ./patches
COPY package.json pnpm-lock.yaml build.sh ./
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
COPY --from=go-base /bin/hp_ssh.wasm /run/public/hp_ssh.wasm
COPY --from=go-base /bin/wasm_exec.js /run/public/wasm_exec.js
RUN ./build.sh --app --app-install-only
COPY . .
ARG HEADPLANE_VERSION
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
FROM gcr.io/distroless/nodejs24-debian13:latest AS final
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
@@ -59,12 +60,11 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
FROM node:22-alpine AS debug-shell
FROM node:24-alpine AS debug-shell
RUN apk add --no-cache bash curl
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 63 36" height="36" width="63" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M47.0239 10.6522L52.1669 1.64895C52.4571 1.14423 52.2816 0.503129 51.7889 0.209825C51.2962 -0.0834073 50.655 0.0871143 50.3716 0.59178L45.161 9.71096C41.1856 7.87622 36.7108 6.8531 31.8985 6.8531C27.0861 6.8531 22.6113 7.87622 18.6359 9.71096L13.4253 0.59178C13.1351 0.0870609 12.5007 -0.0834429 12.0012 0.209825C11.5018 0.50304 11.333 1.14423 11.6232 1.64895L16.7663 10.6522C7.89757 15.5085 1.89061 24.5799 0.898438 35.1997H62.8984C61.9063 24.5799 55.8993 15.5085 47.0238 10.6522H47.0239ZM17.664 26.4011C16.2264 26.4011 15.0655 25.2211 15.0655 23.7751C15.0655 22.3223 16.2331 21.1492 17.664 21.1492C19.1016 21.1492 20.2625 22.3292 20.2625 23.7751C20.2695 25.2211 19.1016 26.4011 17.664 26.4011ZM46.1262 26.4011C44.6886 26.4011 43.5277 25.2211 43.5277 23.7751C43.5277 22.3223 44.6953 21.1492 46.1262 21.1492C47.5638 21.1492 48.7247 22.3292 48.7247 23.7751C48.7318 25.2211 47.5638 26.4011 46.1262 26.4011Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 39 46" height="46" width="39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M21.6443 4.03452C24.6748 0.0855621 28.8877 0.0663452 28.8877 0.0663452C28.8877 0.0663452 29.5143 3.779 26.5038 7.35551C23.2893 11.1745 19.6355 10.5496 19.6355 10.5496C19.6355 10.5496 18.9495 7.54621 21.6443 4.03452V4.03452ZM20.021 13.1503C21.58 13.1503 24.4733 11.0315 28.2395 11.0315C34.7224 11.0315 37.2728 15.5923 37.2728 15.5923C37.2728 15.5923 32.2847 18.1137 32.2847 24.2317C32.2847 31.1335 38.4984 33.5121 38.4984 33.5121C38.4984 33.5121 34.1548 45.5994 28.2878 45.5994C25.5931 45.5994 23.4982 43.8041 20.6589 43.8041C17.7656 43.8041 14.8944 45.6663 13.0241 45.6663C7.66679 45.6665 0.898438 34.2004 0.898438 24.9837C0.898438 15.9156 6.6274 11.1585 12.0009 11.1585C15.4941 11.1585 18.2049 13.1503 20.021 13.1503V13.1503Z" clip-rule="evenodd" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 903 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 55 40" height="40" width="55" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M0.613281 0.225403H54.3397V39.0278H0.613281V0.225403ZM8.36288 8.47665C8.75143 8.08811 9.38138 8.08811 9.76993 8.47665L14.7446 13.4513C15.1331 13.8399 15.1331 14.4698 14.7446 14.8584L9.76993 19.833C9.38138 20.2216 8.75143 20.2216 8.36288 19.833C7.97434 19.4445 7.97434 18.8145 8.36288 18.426L12.634 14.1548L8.36288 9.8837C7.97434 9.49515 7.97434 8.8652 8.36288 8.47665ZM16.534 22.1143C15.9845 22.1143 15.5391 22.5597 15.5391 23.1092C15.5391 23.6587 15.9845 24.1041 16.534 24.1041H25.4884C26.0379 24.1041 26.4833 23.6587 26.4833 23.1092C26.4833 22.5597 26.0379 22.1143 25.4884 22.1143H16.534Z" clip-rule="evenodd" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 767 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="44" height="44" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.7588 27.4022C22.0336 22.6094 24.079 14.802 28.5236 6H6.00006C3.79092 6 2.00006 7.79086 2.00006 10V42C2.00006 44.2092 3.79092 46 6.00006 46H28.735C28.3672 43.6252 28.2408 41.2548 28.2502 39.0346C27.353 39.1516 26.4424 39.2148 25.5294 39.2148C22.3036 39.2148 19.106 38.427 16.4322 37.2544C13.7899 36.0954 11.5029 34.4902 10.2351 32.7296C9.76718 32.0798 9.91469 31.1736 10.5647 30.7056C11.2145 30.2378 12.1207 30.3852 12.5885 31.0352C13.4385 32.2156 15.2105 33.5518 17.5972 34.5986C19.9528 35.6318 22.7552 36.3148 25.5294 36.3148C26.4688 36.3148 27.4114 36.2364 28.339 36.0926C28.4724 33.65 28.7306 31.5148 28.9364 29.9336H24.1764C22.8458 29.9336 21.6766 28.8396 21.7588 27.4022ZM31.156 38.4622C32.3768 38.1404 33.5446 37.729 34.6266 37.2544C37.2692 36.0954 39.5562 34.4902 40.8238 32.7296C41.2918 32.0798 41.1442 31.1736 40.4944 30.7056C39.8444 30.2378 38.9382 30.3852 38.4704 31.0352C37.6204 32.2156 35.8486 33.5518 33.4618 34.5986C32.7714 34.9014 32.0426 35.174 31.2876 35.4082C31.4398 33.1888 31.688 31.2466 31.8794 29.7964C32.073 28.328 30.9296 27.0336 29.4554 27.0336H24.692C25.0766 22.4392 27.179 14.7405 31.7868 6H46C48.2092 6 50 7.79086 50 10V42C50 44.2092 48.2092 46 46 46H31.6716C31.2398 43.446 31.1176 40.8638 31.156 38.4622ZM15.0001 14.55C15.8009 14.55 16.4501 15.1992 16.4501 16V20.7059C16.4501 21.5067 15.8009 22.1558 15.0001 22.1558C14.1993 22.1558 13.5501 21.5067 13.5501 20.7059V16C13.5501 15.1992 14.1993 14.55 15.0001 14.55ZM38.45 16C38.45 15.1992 37.8008 14.55 37 14.55C36.1992 14.55 35.55 15.1992 35.55 16V20.7059C35.55 21.5067 36.1992 22.1558 37 22.1558C37.8008 22.1558 38.45 21.5067 38.45 20.7059V16Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 46 46" height="46" width="46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M0.296875 6.56799L18.9443 4.0173V21.9762H0.296875V6.56799ZM21.1903 3.66333L45.8969 0.0663452V21.8252H21.1903V3.66333ZM0.296875 23.8762H18.9443V41.8872L0.296875 39.2844V23.8762ZM21.1903 24.1157H45.8969V45.6663L21.1903 42.1787" />
</svg>

After

Width:  |  Height:  |  Size: 361 B

-86
View File
@@ -1,86 +0,0 @@
import { Check, Copy, Info } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import Tooltip from './Tooltip';
export interface AttributeProps {
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
}
export default function Attribute({
name,
value,
tooltip,
isCopyable,
}: AttributeProps) {
return (
<dl className="flex gap-1 items-center text-sm">
<dt
className={cn(
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
'text-headplane-500 dark:text-headplane-400',
tooltip ? 'flex items-center gap-1' : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
...(isCopyable
? [
'cursor-pointer hover:shadow-xs',
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
'hover:border-headplane-100 dark:hover:border-headplane-700',
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="flex items-center gap-1.5 relative min-w-0 w-full"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="size-4 hidden data-copied:block" />
<Copy className="size-4 block data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
);
}
-43
View File
@@ -1,43 +0,0 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
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);
return (
<button
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
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,
)}
>
{props.children}
</button>
);
}
-28
View File
@@ -1,28 +0,0 @@
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
}
function Card({ variant = 'raised', ...props }: Props) {
return (
<div
{...props}
className={cn(
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(Card, { Title, Text });
-32
View File
@@ -1,32 +0,0 @@
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(
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
'inline-flex items-center gap-x-1',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}
-50
View File
@@ -1,50 +0,0 @@
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[] | number;
}
export default function Code({ isCopyable, children, className }: CodeProps) {
return (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<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);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
type="button"
>
<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>
)}
</code>
);
}
-194
View File
@@ -1,194 +0,0 @@
import React, { cloneElement, useEffect, useRef } from 'react';
import {
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';
import { useLiveData } from '~/utils/live-data';
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
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>
)}
</>
);
}
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
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}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
ref={ref}
>
<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
isDisabled={isDisabled}
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
>
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,
});
-71
View File
@@ -1,71 +0,0 @@
import { CircleX } from 'lucide-react';
import Link from '~/components/Link';
import cn from '~/utils/cn';
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
}
export default function Footer({ url, debug, healthy }: FooterProps) {
return (
<footer
className={cn(
'fixed w-full bottom-0 left-0 z-40 h-12',
'flex items-center justify-center',
'bg-headplane-50 dark:bg-headplane-950',
'dark:border-t dark:border-headplane-800',
)}
>
<div
className={cn(
'grid grid-rows-1 items-center container mx-auto',
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
)}
>
<div
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
>
<p>
Headplane is free. Please consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-hidden focus:ring-2 rounded-xs',
)}
>
{url}
</button>
{debug && ' (Debug mode enabled)'}
</p>
</div>
{!healthy ? (
<div
className={cn(
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
'bg-red-500 text-white font-semibold',
)}
>
<CircleX size={16} strokeWidth={3} />
<p className="text-nowrap">Headscale is unreachable</p>
</div>
) : undefined}
</div>
</footer>
);
}
-192
View File
@@ -1,192 +0,0 @@
import {
CircleUser,
Globe2,
Lock,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Logo from '~/components/Logo';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
interface Props {
configAvailable: boolean;
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
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-hidden focus:ring-3',
isActive ? 'after:visible' : 'after:invisible',
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-hidden focus:ring-3 rounded-md',
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<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">
<Logo />
<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" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton
className={cn(data.user.picture ? 'p-0' : '')}
label="User"
>
{data.user.picture ? (
<img
alt={data.user.name}
className="w-8 h-8 rounded-full"
src={data.user.picture}
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
disabledKeys={['profile']}
onAction={(key) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
>
<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>
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
icon={<Server className="w-5" />}
name="Machines"
to="/machines"
/>
) : undefined}
{data.access.users ? (
<TabLink
icon={<Users className="w-5" />}
name="Users"
to="/users"
/>
) : undefined}
{data.access.policy ? (
<TabLink
icon={<Lock className="w-5" />}
name="Access Control"
to="/acls"
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
icon={<Globe2 className="w-5" />}
name="DNS"
to="/dns"
/>
) : undefined}
{data.access.settings ? (
<TabLink
icon={<Settings className="w-5" />}
name="Settings"
to="/settings"
/>
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}
-46
View File
@@ -1,46 +0,0 @@
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-hidden focus:ring-3',
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>
);
}
-93
View File
@@ -1,93 +0,0 @@
import { Asterisk } from 'lucide-react';
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;
isInvalid?: boolean;
errorMessage?: string;
}
export default function Input(props: InputProps) {
const {
label,
labelHidden,
className,
isInvalid: customIsInvalid,
errorMessage,
} = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex flex-col w-full">
<label
{...labelProps}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
htmlFor={id}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{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')}
>
{errorMessage ?? validationErrors.join(' ')}
</div>
) : null}
</div>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { ExternalLink } from 'lucide-react';
import cn from '~/utils/cn';
export interface LinkProps {
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({
to,
name: alt,
children,
className,
}: LinkProps) {
return (
<a
href={to}
aria-label={alt}
target="_blank"
rel="noreferrer"
className={cn(
'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-hidden focus:ring-3 rounded-md',
className,
)}
>
{children}
<ExternalLink className="w-3.5" />
</a>
);
}
-10
View File
@@ -1,10 +0,0 @@
import cn from '~/utils/cn';
import LogoSvg from '../../public/logo-light.svg';
export interface LogoProps {
className?: string;
}
export default function Logo({ className }: LogoProps) {
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
}
-170
View File
@@ -1,170 +0,0 @@
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 {
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';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled, disabledKeys = [] } = 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 (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: 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}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-hidden 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',
)}
>
{item.rendered}
</li>
);
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
});
-45
View File
@@ -1,45 +0,0 @@
import {
CircleAlert,
CircleSlash2,
LucideProps,
TriangleAlert,
} from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: 'default' | 'error' | 'warning';
icon?: React.ReactElement<LucideProps>;
}
export default function Notice({
children,
title,
variant,
icon,
}: NoticeProps) {
return (
<Card variant="flat" className="max-w-2xl my-6">
<div className="flex items-center justify-between">
{title ? (
<Card.Title className="text-xl mb-0">{title}</Card.Title>
) : undefined}
{!variant && icon ? icon : iconForVariant(variant)}
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
switch (variant) {
case 'error':
return <TriangleAlert className="text-red-500" />;
case 'warning':
return <CircleAlert className="text-yellow-500" />;
default:
return <CircleSlash2 />;
}
}
-102
View File
@@ -1,102 +0,0 @@
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-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<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>
);
}
-78
View File
@@ -1,78 +0,0 @@
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 OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
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="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });
-49
View File
@@ -1,49 +0,0 @@
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-xs 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>
);
}
-26
View File
@@ -1,26 +0,0 @@
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',
)}
/>
);
}
-83
View File
@@ -1,83 +0,0 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });
-174
View File
@@ -1,174 +0,0 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
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';
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 (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)}
>
<input
{...inputProps}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
props.isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
ref={buttonRef}
>
<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>
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} 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}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-hidden 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',
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });
-21
View File
@@ -1,21 +0,0 @@
import clsx from 'clsx';
interface Props {
className?: string;
}
export default function Spinner({ className }: Props) {
return (
<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,
)}
>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import cn from '~/utils/cn';
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
}
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
return (
<svg
className={cn(
isOnline
? '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>
);
}
-64
View File
@@ -1,64 +0,0 @@
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
switchClassName?: 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 (
<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-[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',
props.className,
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-full',
state.isSelected && 'translate-x-full',
props.switchClassName,
)}
/>
</div>
</label>
);
}
-34
View File
@@ -1,34 +0,0 @@
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
}
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(TableList, { Item });
-90
View File
@@ -1,90 +0,0 @@
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}
className={cn(
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} 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}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
ref={ref}
>
{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}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });
-11
View File
@@ -1,11 +0,0 @@
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>;
}
-13
View File
@@ -1,13 +0,0 @@
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>
);
}
-87
View File
@@ -1,87 +0,0 @@
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} />
)}
</>
);
}
-83
View File
@@ -1,83 +0,0 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-hidden focus:ring-3 rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
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-hidden 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,
});
+82
View File
@@ -0,0 +1,82 @@
import { Check, Copy, Info } from "lucide-react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import Tooltip from "./tooltip";
export interface AttributeProps {
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
}
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
return (
<dl className="flex items-center gap-1 text-sm">
<dt
className={cn(
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
"text-mist-500 dark:text-mist-400",
tooltip ? "flex items-center gap-1" : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip content={tooltip}>
<Info className="size-4" />
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
...(isCopyable
? [
"cursor-pointer hover:shadow-xs",
"hover:bg-mist-50 dark:hover:bg-mist-800",
"hover:border-mist-100 dark:hover:border-mist-700",
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="relative flex w-full min-w-0 items-center gap-1.5"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) {
svg.toggleAttribute("data-copied", true);
}
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute("data-copied", false);
}
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="hidden size-4 data-copied:block" />
<Copy className="block size-4 data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Button as BaseButton } from "@base-ui/react/button";
import React from "react";
import cn from "~/utils/cn";
export interface ButtonProps extends React.ComponentProps<typeof BaseButton> {
variant?: "heavy" | "light" | "danger" | "ghost";
}
export default function Button({ variant = "light", className, ...props }: ButtonProps) {
return (
<BaseButton
{...props}
className={cn(
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
"transition-colors duration-100",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
props.disabled && "pointer-events-none opacity-50",
...(variant === "heavy"
? [
"bg-indigo-500 font-semibold text-white",
"hover:bg-indigo-500/90",
"dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80",
]
: variant === "danger"
? [
"bg-red-600 font-semibold text-white",
"hover:bg-red-600/90",
"dark:bg-red-500 dark:hover:bg-red-500/90",
]
: variant === "ghost"
? [
"font-medium text-indigo-600 dark:text-indigo-400",
"hover:bg-indigo-50 dark:hover:bg-indigo-500/10",
]
: [
"border border-mist-200 bg-white font-medium",
"hover:bg-mist-50",
"dark:border-mist-700 dark:bg-mist-800/50",
"dark:hover:bg-mist-700/50",
]),
className,
)}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import Text from "~/components/text";
import Title from "~/components/title";
import cn from "~/utils/cn";
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: "raised" | "flat";
}
function Card({ variant = "raised", ...props }: Props) {
return (
<div
{...props}
className={cn(
"w-full max-w-md rounded-lg p-5",
"bg-mist-50/50 dark:bg-mist-950/50",
variant === "flat" ? "shadow-none" : "shadow",
"border border-mist-200 dark:border-mist-800",
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(Card, { Title, Text });
+28
View File
@@ -0,0 +1,28 @@
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(
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
"text-mist-700 dark:text-mist-100",
"bg-mist-100 dark:bg-mist-700",
"inline-flex items-center gap-x-1",
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Copy } from "lucide-react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
export interface CodeBlockProps {
children: string;
className?: string;
}
export default function CodeBlock({ children, className }: CodeBlockProps) {
const text = children.trim();
return (
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-md bg-mist-100 text-left dark:bg-mist-800",
"hover:bg-mist-200 dark:hover:bg-mist-700 transition-colors",
className,
)}
onClick={async () => {
await navigator.clipboard.writeText(text);
toast("Copied to clipboard");
}}
>
<code className="block px-3 pt-2 pb-1 text-sm break-all">{text}</code>
<span className="mt-0.5 flex items-center gap-1 px-3 pb-2 text-xs text-mist-500 dark:text-mist-400">
<Copy className="size-3" />
Click to copy
</span>
</button>
);
}
+19
View File
@@ -0,0 +1,19 @@
import cn from "~/utils/cn";
export interface CodeProps {
children: string | string[] | number;
className?: string;
}
export default function Code({ children, className }: CodeProps) {
return (
<code
className={cn(
"bg-mist-100 dark:bg-mist-800 px-1.5 py-0.5 font-mono rounded-sm text-[0.875em]",
className,
)}
>
{children}
</code>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { AlertDialog } from "@base-ui/react/alert-dialog";
import React, { cloneElement, useEffect, useRef } from "react";
import { Form, type HTMLFormMethod } from "react-router";
import Button, { type ButtonProps } from "~/components/button";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
export interface DialogProps {
children:
| [React.ReactElement<ButtonProps>, React.ReactElement<DialogPanelProps>]
| React.ReactElement<DialogPanelProps>;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const { isOpen, onOpenChange } = props;
useEffect(() => {
if (isOpen) {
pause();
} else {
resume();
}
}, [isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
<AlertDialog.Trigger render={cloneElement(button)} />
<DialogOverlay>{panel}</DialogOverlay>
</AlertDialog.Root>
);
}
return (
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
<DialogOverlay>{props.children}</DialogOverlay>
</AlertDialog.Root>
);
}
export interface DialogPanelProps {
children: React.ReactNode;
variant?: "normal" | "destructive" | "unactionable";
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
}
function Panel(props: DialogPanelProps) {
const { children, onSubmit, isDisabled, variant, method = "POST" } = props;
const closeRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog.Popup
className={cn(
"w-full max-w-lg rounded-xl p-4",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
"shadow-overlay",
)}
>
<Form
method={method ?? "POST"}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
if (!event.defaultPrevented) {
closeRef.current?.click();
}
}}
>
<div className="flex flex-col gap-4">{children}</div>
<div className="mt-5 flex justify-end gap-3">
{variant === "unactionable" ? (
<AlertDialog.Close render={<Button>Close</Button>} />
) : (
<>
<AlertDialog.Close render={<Button>Cancel</Button>} />
<AlertDialog.Close ref={closeRef} className="hidden" aria-hidden tabIndex={-1} />
<Button
disabled={isDisabled}
type="submit"
variant={variant === "destructive" ? "danger" : "heavy"}
>
Confirm
</Button>
</>
)}
</div>
</Form>
</AlertDialog.Popup>
);
}
function DialogOverlay({ children }: { children: React.ReactNode }) {
return (
<AlertDialog.Portal>
<AlertDialog.Backdrop
className={cn(
"fixed inset-0 z-20 h-screen w-screen",
"bg-mist-900/30 dark:bg-mist-950/60",
"transition-opacity duration-100",
)}
/>
<div
className={cn(
"fixed inset-0 z-20 h-screen w-screen",
"flex items-center justify-center p-4",
)}
>
{children}
</div>
</AlertDialog.Portal>
);
}
export { Panel as DialogPanel };
export default Dialog;
+175 -192
View File
@@ -1,211 +1,194 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
import { AlertCircle } from "lucide-react";
import { isRouteErrorResponse } from "react-router";
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
import Card from "./card";
import Code from "./code";
import Link from "./link";
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Headscale API Error',
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of <strong>{statusCode}</strong>, indicating
a server-side issue. Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
title: "Headscale API Error",
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
return {
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{" "}
The status code indicates an authentication error. Please verify your API key and
Headplane configuration.
</>
) : (
<> You may be using an unsupported version of Headscale or this may be a bug.</>
)}
</Card.Text>
<ul className="mt-2 list-inside list-disc">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{" "}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
title: "Invalid response from Headscale API",
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your network setup and
configuration to ensure Headplane is able to connect.
</Card.Text>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
title: "Cannot connect to Headscale API",
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
return {
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
title: `Error ${error.status}`,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unexpected Error',
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please
consider reporting filing an issue on the{' '}
<Link
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
if (!(error instanceof Error)) {
return {
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please consider reporting
filing an issue on the{" "}
<Link external styled to="https://github.com/tale/headplane/issues">
Headplane GitHub
</Link>{" "}
repository with the details below.
</Card.Text>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
title: "Unexpected Error",
};
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error("AggregateError handling not implemented yet");
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
return {
jsxMessage: rootError.message,
title:
rootError.name.length > 0 && rootError.name !== "Error"
? `Error: ${rootError.name}`
: "Error",
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
return (
<Card className={cn("w-screen", className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { Field } from "@base-ui/react/field";
import { Input as BaseInput } from "@base-ui/react/input";
import { Asterisk } from "lucide-react";
import type { ComponentProps } from "react";
import cn from "~/utils/cn";
export interface InputProps extends Omit<ComponentProps<typeof BaseInput>, "onChange"> {
label: string;
labelHidden?: boolean;
required?: boolean;
className?: string;
invalid?: boolean;
errorMessage?: string;
description?: string;
onChange?: (value: string) => void;
}
export default function Input(props: InputProps) {
const {
label,
labelHidden,
className,
invalid,
errorMessage,
description,
required,
onChange,
...rest
} = props;
return (
<Field.Root className={cn("flex w-full flex-col gap-1", className)} invalid={invalid}>
<Field.Label
className={cn(
"text-sm font-medium",
"text-mist-700 dark:text-mist-200",
labelHidden && "sr-only",
)}
>
{label}
{required && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
</Field.Label>
<BaseInput
{...rest}
required={required}
className={cn(
"rounded-md px-3 py-2 text-sm",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
)}
onChange={
onChange
? (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
: undefined
}
/>
{description && (
<Field.Description className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>
{description}
</Field.Description>
)}
{invalid && errorMessage ? (
<Field.Error className={cn("text-xs", "text-red-500 dark:text-red-400")}>
{errorMessage}
</Field.Error>
) : null}
</Field.Root>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { ExternalLink } from "lucide-react";
import type { JSX, ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import cn from "~/utils/cn";
export type LinkProps =
| {
external: true;
to: string;
children: ReactNode;
className?: string;
styled?: boolean;
}
| {
external?: false;
to: string;
children?: ReactNode;
className?: string;
};
export default function Link(props: LinkProps): JSX.Element {
if (props.external) {
return (
<a
href={props.to}
target="_blank"
rel="noreferrer"
className={cn(
props.styled && [
"inline-flex items-center gap-x-0.5",
"text-blue-500 hover:text-blue-700",
"dark:text-blue-400 dark:hover:text-blue-300",
],
props.className,
)}
>
{props.children}
{props.styled && <ExternalLink className="w-3.5" />}
</a>
);
}
return (
<RouterLink to={props.to} prefetch="intent" className={props.className}>
{props.children}
</RouterLink>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
import type { ComponentProps, JSX, ReactNode } from "react";
import cn from "~/utils/cn";
const SIDE_OFFSET = 8;
export const Menu = ({
children,
disabled,
}: {
children: ReactNode;
disabled?: boolean;
}): JSX.Element => <BaseMenu.Root disabled={disabled}>{children}</BaseMenu.Root>;
export const MenuTrigger = ({
className,
children,
disabled,
onClick,
}: Pick<
ComponentProps<typeof BaseMenu.Trigger>,
"className" | "children" | "disabled" | "onClick"
>): JSX.Element => (
<BaseMenu.Trigger
className={cn(
"inline-flex items-center justify-center",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</BaseMenu.Trigger>
);
export const MenuContent = ({
children,
className,
side = "bottom",
sideOffset = SIDE_OFFSET,
align = "start",
}: {
children: ReactNode;
className?: string;
side?: ComponentProps<typeof BaseMenu.Positioner>["side"];
sideOffset?: number;
align?: ComponentProps<typeof BaseMenu.Positioner>["align"];
}): JSX.Element => (
<BaseMenu.Portal>
<BaseMenu.Positioner side={side} sideOffset={sideOffset} align={align}>
<BaseMenu.Popup
className={cn(
"min-w-50 rounded-lg py-1",
"bg-white dark:bg-mist-900",
"shadow-lg dark:shadow-none",
"border border-mist-200 dark:border-mist-700",
"focus:outline-hidden",
className,
)}
>
{children}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
export const MenuItem = ({
className,
variant,
children,
disabled,
onClick,
}: Pick<ComponentProps<typeof BaseMenu.Item>, "className" | "children" | "disabled" | "onClick"> & {
variant?: "danger";
}): JSX.Element => (
<BaseMenu.Item
className={cn(
"py-2 px-3 mx-1 rounded-md",
"select-none cursor-pointer",
"focus:outline-hidden",
"text-mist-700 dark:text-mist-300",
"data-highlighted:bg-mist-100/50 dark:data-highlighted:bg-mist-800",
"data-disabled:text-mist-400 dark:data-disabled:text-mist-600 data-disabled:cursor-default",
variant === "danger" && "text-red-500 dark:text-red-400",
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</BaseMenu.Item>
);
export const MenuSeparator = ({ className }: { className?: string }): JSX.Element => (
<BaseMenu.Separator
className={cn("mx-2 my-1 border-t border-mist-200 dark:border-mist-800", className)}
/>
);
+34
View File
@@ -0,0 +1,34 @@
import { CircleAlert, CircleSlash2, LucideProps, TriangleAlert } from "lucide-react";
import React from "react";
import Card from "~/components/card";
export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: "default" | "error" | "warning";
icon?: React.ReactElement<LucideProps>;
}
export default function Notice({ children, title, variant, icon }: NoticeProps) {
return (
<Card variant="flat" className="my-6 max-w-2xl">
<div className="flex items-center justify-between">
{title ? <Card.Title className="mb-0 text-xl">{title}</Card.Title> : undefined}
{!variant && icon ? icon : iconForVariant(variant)}
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
function iconForVariant(variant?: "default" | "error" | "warning") {
switch (variant) {
case "error":
return <TriangleAlert className="text-red-500" />;
case "warning":
return <CircleAlert className="text-yellow-500" />;
default:
return <CircleSlash2 />;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NumberField } from "@base-ui/react/number-field";
import { Minus, Plus } from "lucide-react";
import cn from "~/utils/cn";
export interface NumberInputProps {
label?: string;
name?: string;
description?: string;
required?: boolean;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
defaultValue?: number;
value?: number;
onValueChange?: (value: number | null) => void;
}
export default function NumberInput(props: NumberInputProps) {
const { label, name, description } = props;
return (
<NumberField.Root
className="flex flex-col gap-1"
defaultValue={props.defaultValue}
value={props.value}
onValueChange={props.onValueChange}
min={props.min}
max={props.max}
step={props.step}
disabled={props.disabled}
required={props.required}
>
{label && (
<NumberField.ScrubArea>
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
<NumberField.ScrubAreaCursor />
{label}
</label>
</NumberField.ScrubArea>
)}
<NumberField.Group
className={cn(
"flex items-center gap-1 rounded-md pr-1",
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
)}
>
<NumberField.Input
name={name}
className="w-full rounded-l-md bg-transparent py-2 pl-3 text-sm focus:outline-hidden"
/>
<NumberField.Decrement aria-label="Decrement" className="h-7.5 w-7.5 rounded-lg p-1">
<Minus className="h-4 w-4" />
</NumberField.Decrement>
<NumberField.Increment aria-label="Increment" className="h-7.5 w-7.5 rounded-lg p-1">
<Plus className="h-4 w-4" />
</NumberField.Increment>
</NumberField.Group>
{description && (
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
)}
</NumberField.Root>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { RefreshCw, ServerOff } from "lucide-react";
import { isRouteErrorResponse, useRevalidator } from "react-router";
import { isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
import Button from "./button";
import { ErrorBanner } from "./error-banner";
interface PageErrorProps {
error: unknown;
page: string;
}
export default function PageError({ error, page }: PageErrorProps) {
const { revalidate, state } = useRevalidator();
if (isRouteErrorResponse(error) && isConnectionError(error.data)) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ServerOff className={cn("h-12 w-12", "text-mist-400 dark:text-mist-500")} />
<h2 className="mt-4 text-lg font-semibold">{page} Unavailable</h2>
<p className="mt-1 max-w-sm text-sm text-mist-500 dark:text-mist-400">
This page could not be loaded because the Headscale server is unreachable. It will be
available once the connection is restored.
</p>
<Button
className="mt-6"
variant="light"
onClick={() => revalidate()}
disabled={state === "loading"}
>
<RefreshCw
className={cn("mr-2 inline-block h-4 w-4", state === "loading" && "animate-spin")}
/>
Retry
</Button>
</div>
);
}
return <ErrorBanner className="max-w-2xl" error={error} />;
}
+60
View File
@@ -0,0 +1,60 @@
import { Radio } from "@base-ui/react/radio";
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import type React from "react";
import cn from "~/utils/cn";
interface RadioGroupProps {
children: React.ReactNode;
label: string;
className?: string;
name?: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string, eventDetails: BaseRadioGroup.ChangeEventDetails) => void;
}
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
return (
<BaseRadioGroup
{...props}
aria-label={label}
className={cn("flex flex-col gap-2.5", className)}
>
{children}
</BaseRadioGroup>
);
}
interface RadioItemProps {
value: string;
label: string;
className?: string;
disabled?: boolean;
children?: React.ReactNode;
}
function RadioItem({ children, label, className, value, disabled }: RadioItemProps) {
return (
<label className="flex items-center gap-2.5 text-sm">
<Radio.Root
value={value}
disabled={disabled}
aria-label={label}
className={cn(
"w-5 h-5 aspect-square rounded-full border-2",
"border-mist-400 dark:border-mist-500",
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
"data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
"data-[checked]:border-[6px] data-[checked]:border-mist-900 dark:data-[checked]:border-mist-100",
className,
)}
>
<Radio.Indicator />
</Radio.Root>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio: RadioItem });
+138
View File
@@ -0,0 +1,138 @@
import { Combobox } from "@base-ui/react/combobox";
import { Check, ChevronDown } from "lucide-react";
import cn from "~/utils/cn";
export interface SelectItem {
value: string;
label: string;
}
export interface SelectProps {
items: SelectItem[];
label?: string;
"aria-label"?: string;
name?: string;
className?: string;
placeholder?: string;
description?: string;
required?: boolean;
disabled?: boolean;
invalid?: boolean;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
}
export default function Select({
items,
label,
className,
placeholder,
description,
required,
disabled,
invalid,
value,
defaultValue,
onValueChange,
name,
...props
}: SelectProps) {
const selectedItem =
value !== undefined ? (items.find((i) => i.value === value) ?? null) : undefined;
const defaultSelectedItem =
defaultValue !== undefined ? (items.find((i) => i.value === defaultValue) ?? null) : undefined;
return (
<div className={cn("flex flex-col gap-1", className)}>
{label && (
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
{label}
</label>
)}
<Combobox.Root
items={items}
value={selectedItem}
defaultValue={defaultSelectedItem}
onValueChange={(item) => onValueChange?.(item?.value ?? null)}
disabled={disabled}
name={name}
aria-label={props["aria-label"]}
>
<div
className={cn(
"relative rounded-md",
"focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
invalid && "ring-red-400",
)}
>
<Combobox.Input
placeholder={placeholder}
required={required}
className="w-full rounded-md bg-transparent px-3 py-2 pr-9 text-sm outline-hidden"
data-1p-ignore
/>
<Combobox.Trigger
className={cn(
"absolute inset-y-0 right-0 flex items-center pr-3",
"text-mist-400 dark:text-mist-500",
disabled
? "opacity-50 cursor-not-allowed"
: "hover:text-mist-600 dark:hover:text-mist-300",
)}
>
<ChevronDown className="h-4 w-4" />
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Positioner
className="z-50"
sideOffset={8}
style={{ width: "var(--anchor-width)" }}
>
<Combobox.Popup
className={cn(
"max-h-72 w-full overflow-auto rounded-lg p-1",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
"shadow-overlay",
)}
>
<Combobox.Empty className="px-3 py-2 text-sm text-mist-500 empty:hidden">
No results found.
</Combobox.Empty>
<Combobox.List>
{(item: SelectItem) => (
<Combobox.Item
key={item.value}
value={item}
className={cn(
"flex items-center justify-between text-sm",
"py-1.5 px-2.5 rounded-md",
"outline-hidden select-none cursor-default",
"data-[highlighted]:bg-mist-100 dark:data-[highlighted]:bg-mist-800",
"data-[selected]:font-medium",
"data-[disabled]:text-mist-300 dark:data-[disabled]:text-mist-600",
)}
>
{item.label}
<Combobox.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</Combobox.ItemIndicator>
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
{description && (
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
)}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { AlertCircle, CloudOff, Info, TriangleAlert, X } from "lucide-react";
import { useState } from "react";
import cn from "~/utils/cn";
type Variant = "info" | "warning" | "error" | "critical";
interface StatusBannerProps {
variant: Variant;
title: string;
children: React.ReactNode;
dismissable?: boolean;
className?: string;
}
const icons: Record<Variant, React.ReactNode> = {
info: <Info className="h-5 w-5 shrink-0" />,
warning: <TriangleAlert className="h-5 w-5 shrink-0" />,
error: <AlertCircle className="h-5 w-5 shrink-0" />,
critical: <CloudOff className="h-5 w-5 shrink-0" />,
};
export default function StatusBanner({
variant,
title,
children,
dismissable = true,
className,
}: StatusBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) {
return null;
}
return (
<div
role="alert"
className={cn(
"flex items-start gap-3 rounded-lg border px-4 py-3 text-sm",
variant === "info" && [
"border-indigo-200 bg-indigo-50 text-indigo-900",
"dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200",
],
variant === "warning" && [
"border-yellow-300 bg-yellow-50 text-yellow-900",
"dark:border-yellow-500/30 dark:bg-yellow-500/10 dark:text-yellow-200",
],
variant === "error" && [
"border-red-300 bg-red-50 text-red-900",
"dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200",
],
variant === "critical" && [
"border-red-400 bg-red-100 text-red-900",
"dark:border-red-500/40 dark:bg-red-500/20 dark:text-red-100",
],
className,
)}
>
{icons[variant]}
<div className="flex-1">
<p className="font-medium">{title}</p>
<div className="mt-0.5 text-current/80">{children}</div>
</div>
{dismissable && (
<button
type="button"
aria-label="Dismiss"
onClick={() => setDismissed(true)}
className={cn(
"shrink-0 rounded p-0.5 transition-colors",
"hover:bg-black/10 dark:hover:bg-white/10",
)}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import cn from "~/utils/cn";
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
}
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
return (
<svg
className={cn(
isOnline ? "text-green-600 dark:text-green-500" : "text-mist-200 dark:text-mist-800",
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? "Online" : "Offline"}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Switch as BaseSwitch } from "@base-ui/react/switch";
import cn from "~/utils/cn";
export interface SwitchProps {
label: string;
className?: string;
switchClassName?: string;
name?: string;
disabled?: boolean;
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export default function Switch(props: SwitchProps) {
return (
<BaseSwitch.Root
aria-label={props.label}
checked={props.checked}
className={cn(
"flex h-[22px] w-[38px] p-[3px] shrink-0 rounded-full",
"bg-mist-300 dark:bg-mist-700",
"border border-transparent dark:border-mist-800",
"data-[checked]:bg-mist-900 dark:data-[checked]:bg-mist-950",
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
props.disabled && "opacity-50",
props.className,
)}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
name={props.name}
onCheckedChange={props.onCheckedChange}
>
<BaseSwitch.Thumb
className={cn(
"h-[14px] w-[14px] transform rounded-full",
"bg-white transition duration-50 ease-in-out",
"translate-x-0 data-[checked]:translate-x-full",
props.switchClassName,
)}
/>
</BaseSwitch.Root>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { HTMLProps } from "react";
import cn from "~/utils/cn";
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn("rounded-lg", "border border-mist-200 dark:border-mist-800", props.className)}
>
{props.children}
</div>
);
}
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
"flex items-center justify-between p-2 last:border-b-0",
"border-b border-mist-200 dark:border-mist-800",
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(TableList, { Item });
+89
View File
@@ -0,0 +1,89 @@
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import type { ComponentProps, ReactNode } from "react";
import cn from "~/utils/cn";
export interface TabsProps {
label: string;
className?: string;
defaultValue?: string | number;
value?: string | number;
onValueChange?: (value: string | number) => void;
children: ReactNode;
}
function Tabs({ label, className, children, ...props }: TabsProps) {
return (
<BaseTabs.Root
{...props}
defaultValue={props.defaultValue ?? 0}
aria-label={label}
className={cn("flex flex-col", className)}
>
{children}
</BaseTabs.Root>
);
}
function TabList({ children, className }: { children: ReactNode; className?: string }) {
return (
<BaseTabs.List
className={cn(
"flex items-center rounded-t-lg w-fit max-w-full",
"border-mist-200 dark:border-mist-800",
"border-t border-x",
className,
)}
>
{children}
</BaseTabs.List>
);
}
function Tab({
value,
children,
className,
...props
}: ComponentProps<typeof BaseTabs.Tab> & { className?: string }) {
return (
<BaseTabs.Tab
value={value}
{...props}
className={cn(
"pl-2 pr-3 py-2.5",
"data-[selected]:bg-mist-50 dark:data-[selected]:bg-mist-950",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"border-r border-mist-200 dark:border-mist-800",
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
className,
)}
>
{children}
</BaseTabs.Tab>
);
}
function Panel({
value,
children,
className,
...props
}: ComponentProps<typeof BaseTabs.Panel> & { className?: string }) {
return (
<BaseTabs.Panel
value={value}
{...props}
className={cn(
"w-full overflow-clip rounded-b-lg rounded-r-lg",
"border border-mist-200 dark:border-mist-800",
className,
)}
>
{children}
</BaseTabs.Panel>
);
}
export { Tabs, TabList as TabsList, Tab as TabsTab, Panel as TabsPanel };
+27 -26
View File
@@ -1,32 +1,33 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import { Info } from "lucide-react";
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export interface ExitNodeTagProps {
isEnabled?: boolean;
isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this
from the "Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip
content={
isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this from the "Edit route
settings..." option in the machine's menu.
</>
)
}
>
<Chip
text="Exit Node"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
</Tooltip>
);
}
+30 -35
View File
@@ -1,42 +1,37 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import Chip from "../chip";
import Tooltip from "../tooltip";
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
variant: "expired" | "no-expiry";
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip
content={
variant === "expired" ? (
<>
This machine is expired and will not be able to connect to the network. Re-authenticate
with Tailscale on the machine to re-enable it.
</>
) : (
<>This machine has key expiry disabled and will never need to re-authenticate.</>
)
}
>
<Chip
text={
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
}
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
/>
</Tooltip>
);
}
+12 -17
View File
@@ -1,20 +1,15 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn(
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
)}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide
host information in the web UI.
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip content="This machine is running the Headplane agent, which allows it to provide host information in the web UI.">
<Chip
text="Headplane Agent"
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")}
/>
</Tooltip>
);
}
+27 -26
View File
@@ -1,32 +1,33 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import { Info } from "lucide-react";
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export interface SubnetTagProps {
isEnabled?: boolean;
isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the
"Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip
content={
isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the "Edit route
settings..." option in the machine's menu.
</>
)
}
>
<Chip
text="Subnets"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
</Tooltip>
);
}
+12 -18
View File
@@ -1,21 +1,15 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export function TailscaleSSHTag() {
return (
<Tooltip>
<Chip
text="Tailscale SSH"
className={cn(
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
)}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate
SSH credentials using your Tailscale account and via the Headplane web
UI.
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip content="This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials using your Tailscale account and via the Headplane web UI.">
<Chip
text="Tailscale SSH"
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")}
/>
</Tooltip>
);
}
+12
View File
@@ -0,0 +1,12 @@
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>;
}
+12
View File
@@ -0,0 +1,12 @@
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>;
}
+45
View File
@@ -0,0 +1,45 @@
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import type { ReactNode } from "react";
import cn from "~/utils/cn";
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
className?: string;
}
export default function Tooltip({ children, content, className }: TooltipProps) {
return (
<BaseTooltip.Root>
<BaseTooltip.Trigger
delay={0}
closeDelay={0}
className={cn(
"inline-flex items-center justify-center rounded-md",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
>
{children}
</BaseTooltip.Trigger>
<BaseTooltip.Portal>
<BaseTooltip.Positioner sideOffset={4}>
<BaseTooltip.Popup
className={cn(
"z-50 rounded-lg p-3 text-sm w-48",
"outline-hidden",
"bg-white dark:bg-mist-950",
"text-black dark:text-white",
"shadow-overlay",
"border border-mist-100 dark:border-mist-800",
className,
)}
>
{content}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
</BaseTooltip.Root>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { type, type Type } from "arktype";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type FormValue = string | number | boolean | null;
interface FormState {
values: Record<string, FormValue>;
errors: Record<string, string>;
modified: Record<string, boolean>;
}
export interface FormFieldProps {
name: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
invalid: boolean;
errorMessage?: string;
}
interface UseFormOptions<T extends Record<string, unknown>> {
schema: Type<T>;
defaultValues?: Partial<Record<keyof T & string, FormValue>>;
actionData?: unknown;
validate?: (values: Record<string, FormValue>) => Record<string, string> | undefined;
}
// arktype's Type<T> doesn't expose `.props` in the type system,
// but it exists at runtime. This extracts schema keys safely.
function schemaKeys(schema: Type): string[] {
const props = (schema as unknown as { props?: { key: string }[] }).props;
return props?.map((p) => p.key) ?? [];
}
export function useForm<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
const { schema, actionData, validate } = options;
const keys = schemaKeys(schema);
const validateRef = useRef(validate);
validateRef.current = validate;
const initialValues = useMemo(() => {
const values: Record<string, FormValue> = {};
for (const key of keys) {
values[key] =
options.defaultValues && key in options.defaultValues
? (options.defaultValues[key as keyof T & string] as FormValue)
: "";
}
return values;
}, []);
const [state, setState] = useState<FormState>({
values: initialValues,
errors: {},
modified: {},
});
useEffect(() => {
if (actionData && typeof actionData === "object" && "errors" in actionData) {
const errors = actionData.errors as Record<string, string>;
setState((prev) => ({
...prev,
errors: { ...prev.errors, ...errors },
}));
}
}, [actionData]);
const setValue = useCallback((name: keyof T & string, raw: FormValue) => {
setState((prev) => ({
...prev,
values: { ...prev.values, [name]: raw },
modified: { ...prev.modified, [name]: true },
}));
}, []);
function validateField(name: string) {
if (!state.modified[name]) {
return;
}
const errors: Record<string, string> = {};
// Schema validation
const result = schema({ ...state.values } as never);
if (result instanceof type.errors) {
const fieldProblems = result.flatProblemsByPath[name];
if (fieldProblems) {
errors[name] = fieldProblems[0];
}
}
// Cross-field validation
if (!errors[name]) {
const customErrors = validateRef.current?.(state.values);
if (customErrors?.[name]) {
errors[name] = customErrors[name];
}
}
setState((prev) => ({
...prev,
errors: {
...prev.errors,
[name]: errors[name] ?? "",
},
}));
}
function field(name: keyof T & string): FormFieldProps {
const error = state.errors[name];
return {
name,
value: String(state.values[name] ?? ""),
onChange: (value: string) => setValue(name, value),
onBlur: () => validateField(name),
invalid: !!error,
errorMessage: error || undefined,
};
}
function computeCanSubmit(): boolean {
const result = schema({ ...state.values } as never);
if (result instanceof type.errors) {
return false;
}
const customErrors = validateRef.current?.(state.values);
if (customErrors && Object.values(customErrors).some(Boolean)) {
return false;
}
return true;
}
const reset = useCallback(() => {
setState({
values: { ...initialValues },
errors: {},
modified: {},
});
}, [initialValues]);
return {
values: state.values,
errors: state.errors,
field,
canSubmit: computeCanSubmit(),
setValue,
reset,
};
}
type ServerValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string> };
export async function validateFormData<T extends Record<string, unknown>>(
request: Request,
schema: Type<T>,
): Promise<ServerValidationResult<T>> {
const formData = await request.formData();
const parsed: Record<string, unknown> = {};
for (const key of schemaKeys(schema)) {
const raw = formData.get(key);
if (raw !== null) {
parsed[key] = raw.toString();
}
}
const result = schema(parsed as never);
if (result instanceof type.errors) {
const errors: Record<string, string> = {};
for (const [path, problems] of Object.entries(result.flatProblemsByPath)) {
errors[path] = problems[0];
}
return { success: false, errors };
}
return { success: true, data: result as T };
}
+139
View File
@@ -0,0 +1,139 @@
import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
import type { Route } from "./+types/app";
import Footer from "./footer";
import Header from "./header";
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
formAction,
defaultShouldRevalidate,
}) => {
if (formAction) {
return defaultShouldRevalidate;
}
// Allow programmatic revalidations (e.g. SSE-triggered) where the URL hasn't changed
if (currentUrl.href === nextUrl.href) {
return defaultShouldRevalidate;
}
return false;
};
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
principal.kind === "oidc"
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await api.isHealthy();
if (isHealthy) {
try {
await api.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
// Self-heal: if the linked Headscale user was deleted, clear the
// stale link so the user gets prompted to re-link.
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
await context.auth.unlinkHeadscaleUser(principal.user.id);
}
} catch {
// API call failed, skip validation
}
}
}
return {
access: {
dns: context.auth.can(principal, Capabilities.read_network),
machines: context.auth.can(principal, Capabilities.read_machines),
policy: context.auth.can(principal, Capabilities.read_policy),
settings: context.auth.can(principal, Capabilities.read_feature),
ui: context.auth.can(principal, Capabilities.ui_access),
users: context.auth.can(principal, Capabilities.read_users),
},
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
isDebug: context.config.debug,
isHealthy,
user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
export default function AppLayout({ loaderData }: Route.ComponentProps) {
return (
<>
<Header
access={loaderData.access}
configAvailable={loaderData.configAvailable}
user={loaderData.user}
/>
<main className="container mt-4 mb-24 overscroll-contain">
{!loaderData.isHealthy && (
<StatusBanner
className="mb-4"
dismissable={false}
title="Headscale Unreachable"
variant="critical"
>
Unable to connect to the Headscale server. Data shown may be stale and changes cannot be
saved until the connection is restored.
</StatusBanner>
)}
<Outlet />
</main>
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="mx-auto my-24 w-fit overscroll-contain">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import Link from "~/components/link";
import cn from "~/utils/cn";
export interface FooterProps {
isDebug: boolean;
baseUrl: string;
}
export default function Footer({ isDebug, baseUrl }: FooterProps) {
const [urlVisible, setUrlVisible] = useState(false);
return (
<footer
className={cn(
"fixed w-full bottom-0 left-0 z-20",
"bg-mist-50 dark:bg-mist-950",
"dark:border-t dark:border-mist-800",
)}
>
<div className="container flex items-center justify-between py-2">
<p className="text-xs">
Headplane is free and open-source. Please consider{" "}
<Link external styled to="https://tale.me/sponsor">
sponsoring
</Link>{" "}
to support development.
</p>
<div className="flex items-center gap-2 text-xs">
{isDebug && (
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium",
"bg-amber-100 text-amber-800",
"dark:bg-amber-900/50 dark:text-amber-300",
)}
>
Debug
</span>
)}
<p className="text-mist-500 dark:text-mist-400">
{__VERSION__} &middot;{" "}
{urlVisible ? (
<code>{baseUrl}</code>
) : (
<span aria-hidden="true">&bull;&bull;&bull;&bull;&bull;</span>
)}
<button
type="button"
aria-label={urlVisible ? "Hide server URL" : "Show server URL"}
className={cn(
"ml-1 inline-flex align-middle rounded-xs p-0.5",
"text-mist-400 hover:text-mist-600",
"dark:text-mist-500 dark:hover:text-mist-300",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
onClick={() => setUrlVisible((v) => !v)}
>
{urlVisible ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</p>
</div>
</div>
</footer>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
import { NavLink, useSubmit } from "react-router";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import logoBg from "~/logo/dark-bg.svg";
import logoDark from "~/logo/dark.svg";
import logoLight from "~/logo/light.svg";
import cn from "~/utils/cn";
export interface HeaderProps {
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
configAvailable: boolean;
}
const tabs = [
{ to: "/machines", icon: Server, label: "Machines", key: "machines" },
{ to: "/users", icon: Users, label: "Users", key: "users" },
{ to: "/acls", icon: Lock, label: "Access Control", key: "policy" },
{ to: "/dns", icon: Globe, label: "DNS", key: "dns" },
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
] as const;
export default function Header({ user, access, configAvailable }: HeaderProps) {
const submit = useSubmit();
const showTabs = access.ui;
return (
<header
className={cn(
"bg-mist-200 dark:bg-mist-950 text-mist-800 dark:text-mist-200",
"dark:border-b dark:border-mist-800 shadow-inner",
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-8">
<div className="flex items-center gap-x-2">
<picture>
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
<img src={logoBg} alt="Headplane logo" />
</picture>
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
{showTabs && (
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
return (
<NavLink
key={tab.to}
className={({ isActive }) =>
cn(
"px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
isActive
? "bg-mist-300/70 dark:bg-mist-800 text-mist-900 dark:text-mist-50"
: "text-mist-600 dark:text-mist-300",
)
}
prefetch="intent"
to={tab.to}
>
<tab.icon className="w-4" />
{tab.label}
</NavLink>
);
})}
</nav>
)}
</div>
<div className="grid grid-cols-2 gap-x-4">
<Menu>
<MenuTrigger className="size-8 rounded-full p-1">
<CircleQuestionMark className="w-5" />
</MenuTrigger>
<MenuContent align="end">
<MenuItem>
<Link external to="https://headplane.net">
Docs
</Link>
</MenuItem>
<MenuItem>
<Link external to="https://headscale.net">
Headscale
</Link>
</MenuItem>
<MenuItem>
<Link external to="https://tailscale.com/download">
Download
</Link>
</MenuItem>
</MenuContent>
</Menu>
<Menu>
<MenuTrigger className="size-8 overflow-hidden rounded-full">
{user.picture ? (
<img alt={user.name} className="size-8" src={user.picture} />
) : (
<CircleUser className="size-8" />
)}
</MenuTrigger>
<MenuContent align="end">
<MenuItem disabled>
<div className="text-mist-900 dark:text-mist-50">
{user.subject === "api_key" ? (
<>
<p className="font-bold">API Key</p>
<p>{user.name}</p>
</>
) : (
<>
<p className="font-bold">{user.name}</p>
{user.email && <p>{user.email}</p>}
</>
)}
</div>
</MenuItem>
<MenuSeparator />
<MenuItem
variant="danger"
onClick={() => submit({}, { action: "/logout", method: "POST" })}
>
Logout
</MenuItem>
</MenuContent>
</Menu>
</div>
</div>
</header>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Form } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import cn from "~/utils/cn";
interface LinkAccountProps {
headscaleUsers: { id: string; name: string }[];
}
export default function LinkAccount({ headscaleUsers }: LinkAccountProps) {
return (
<div className="mx-auto mt-6 flex max-w-xl flex-col items-center justify-center py-36">
<Card variant="flat" className="max-w-xl items-center gap-4">
<Card.Title>Link your Headscale account</Card.Title>
<Card.Text>
Headplane could not automatically match your SSO identity to an existing Headscale user.
Please select your user from the list below to link your account and continue.
</Card.Text>
<Form method="POST" className="mt-4">
<select
className={cn(
"mb-4 w-full rounded-lg border p-2",
"border-mist-200 dark:border-mist-700",
"bg-mist-50 dark:bg-mist-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
</Button>
</Form>
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
If you don't see your user listed, please contact your administrator. To automatically
link new users in the future, ensure that the Headscale user has the same email address as
the SSO identity.
</Card.Text>
</Card>
</div>
);
}
-54
View File
@@ -1,54 +0,0 @@
import { Outlet, redirect } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/dashboard";
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
if (healthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
}
return {
healthy,
};
}
export default function Layout() {
return (
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
<Outlet />
</main>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="mx-auto my-24 w-fit overscroll-contain">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
-307
View File
@@ -1,307 +0,0 @@
import { Icon } from "@iconify/react";
import { Form, Outlet, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import Link from "~/components/Link";
import Options from "~/components/Options";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import { Route } from "./+types/shell";
// 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, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.onboarded &&
!request.url.endsWith("/onboarding")
) {
return redirect("/onboarding");
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const check = context.auth.can(principal, Capabilities.ui_access);
const noAccess = !check && principal.kind === "oidc";
const user =
principal.kind === "oidc"
? {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
picture: principal.profile.picture,
}
: { subject: "api_key", name: principal.displayName };
let linkedUserName: string | undefined;
let osValue: string | undefined;
if (noAccess && principal.kind === "oidc") {
const hsUserId = principal.user.headscaleUserId;
if (hsUserId) {
try {
const apiUsers = await api.getUsers();
const hsUser = apiUsers.find((u) => u.id === hsUserId);
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
} catch {
// API unavailable, skip linked user resolution
}
}
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
switch (os?.[0]) {
case "Windows":
osValue = "windows";
break;
case "Mac OS X":
osValue = "macos";
break;
case "iPhone":
case "iPad":
osValue = "ios";
break;
case "Android":
osValue = "android";
break;
default:
osValue = "linux";
break;
}
}
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user,
access: {
ui: check,
dns: context.auth.can(principal, Capabilities.read_network),
users: context.auth.can(principal, Capabilities.read_users),
policy: context.auth.can(principal, Capabilities.read_policy),
machines: context.auth.can(principal, Capabilities.read_machines),
settings: context.auth.can(principal, Capabilities.read_feature),
},
onboarding: request.url.endsWith("/onboarding"),
noAccess,
linkedUserName,
osValue,
healthy: await api.isHealthy(),
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
export default function Shell({ loaderData }: Route.ComponentProps) {
if (loaderData.noAccess && !loaderData.onboarding) {
return (
<>
<Header {...loaderData} />
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
<div className="mx-auto mt-12 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
{loaderData.linkedUserName ? (
<Card className="col-span-1 mx-auto max-w-lg md:col-span-2" variant="flat">
<p className="text-sm">
Your account is linked to Headscale user{" "}
<strong>{loaderData.linkedUserName}</strong>.
</p>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Access your network
<br />
via Tailscale
</Card.Title>
<Card.Text>
You don't have dashboard access, but you can still connect to your Headscale
network. Install Tailscale on your device to get started.
</Card.Text>
<Options
className="my-4"
defaultSelectedKey={loaderData.osValue ?? "linux"}
label="Download Selector"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="text-md flex font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
"curl -fsSL https://tailscale.com/install.sh | sh",
);
toast("Copied to clipboard");
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
Click this button to copy the command.{" "}
<Link
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
macOS App Store
</Link>
{"."}
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card className="max-w-lg" variant="flat">
<div className="flex h-full flex-col justify-between">
<div>
<Card.Title className="mb-4">Need dashboard access?</Card.Title>
<Card.Text>
Your account is signed in but doesn't have permission to manage the dashboard.
Contact an administrator to request access.
</Card.Text>
</div>
<Form action="/logout" className="mt-6" method="POST">
<Button className="w-full" type="submit" variant="light">
Sign out
</Button>
</Form>
</div>
</Card>
</div>
</main>
<Footer {...loaderData} />
</>
);
}
return (
<>
<Header {...loaderData} />
<Outlet />
<Footer {...loaderData} />
</>
);
}

Before

Width:  |  Height:  |  Size: 901 B

After

Width:  |  Height:  |  Size: 901 B

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 732 B

Before

Width:  |  Height:  |  Size: 926 B

After

Width:  |  Height:  |  Size: 926 B

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 742 B

+5 -16
View File
@@ -1,12 +1,10 @@
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import ProgressBar from "~/components/ProgressBar";
import ToastProvider from "~/components/ToastProvider";
import { LiveDataProvider } from "~/utils/live-data";
import { useToastQueue } from "~/utils/toast";
import ToastProvider from "~/utils/toast-provider";
import type { Route } from "./+types/root";
import { ErrorBanner } from "./components/error-banner";
@@ -24,8 +22,6 @@ export const meta: MetaFunction = () => [
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
@@ -39,9 +35,9 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
{children}
<ToastProvider queue={toastQueue} />
<ToastProvider />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
@@ -60,12 +56,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
}
export default function App() {
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === "loading"} />
<Outlet />
</>
);
return <Outlet />;
}
+17 -21
View File
@@ -2,40 +2,36 @@ import { index, layout, prefix, route } from "@react-router/dev/routes";
export default [
// Utility Routes
index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
...prefix("/events", [route("/live", "routes/util/live.ts")]),
// Authentication Routes
route("/login", "routes/auth/login/page.tsx"),
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
route("/ssh/:id", "routes/ssh/page.tsx"),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout("layouts/shell.tsx", [
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
layout("layouts/dashboard.tsx", [
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
// All the main logged-in routes
layout("layout/app.tsx", [
index("routes/home.tsx"),
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
route("/agent", "routes/settings/agent.tsx"),
]),
]),
];
+1 -1
View File
@@ -26,7 +26,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
+5 -2
View File
@@ -29,7 +29,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.getPolicy();
@@ -38,8 +38,11 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// Headscale returns "acl policy not found" when the policy mode is
// set to file but no file exists, and returns a 500 when database
// mode is used but the policies table is empty.
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes("acl policy not found")) {
if (error.data.rawData.includes("acl policy not found") || error.data.statusCode === 500) {
flags.policy = "";
flags.writable = true;
return flags;
+30 -30
View File
@@ -1,36 +1,36 @@
import cn from '~/utils/cn';
import cn from "~/utils/cn";
interface Props {
readonly acl: string;
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
return (
<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
className={cn(
'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',
)}
readOnly
value={acl}
/>
</div>
);
return (
<div className="h-editor relative flex w-full">
<div
className={cn(
"h-full w-8 flex justify-center p-1",
"border-r border-mist-400 dark:border-mist-800",
)}
>
<div
aria-hidden
className={cn(
"h-5 w-5 animate-spin rounded-full",
"border-mist-900 dark:border-mist-100",
"border-2 border-t-transparent dark:border-t-transparent",
)}
/>
</div>
<textarea
className={cn(
"w-full h-editor font-mono resize-none text-sm",
"bg-mist-50 dark:bg-mist-950 opacity-60",
"pl-1 pt-1 leading-snug",
)}
readOnly
value={acl}
/>
</div>
);
}
+184 -207
View File
@@ -1,220 +1,197 @@
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { Suspense, lazy, useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import Link from "~/components/link";
import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import { Tabs, TabsList, TabsPanel, TabsTab } from "~/components/tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import Fallback from "./components/fallback";
const LazyEditor = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Editor })),
);
const LazyDiffer = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Differ })),
);
export const loader = aclLoader;
export const action = aclAction;
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
export default function Page({ loaderData: { access, writable, policy } }: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
if (fetcher.data.success === true) {
toast("Updated policy");
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>policy.mode</Code> to <Code>database</Code> in your
Headscale configuration.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
onChange={setCodePolicy}
value={codePolicy}
/>
</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={policy} right={codePolicy} />
</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">
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>
</Tabs.Item>
</Tabs>
<Button
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control List policy. Please
contact your administrator to request access or to make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your Headscale
configuration. This means that the ACL file cannot be edited through the web interface. In
order to resolve this, you'll need to set <Code>policy.mode</Code> to{" "}
<Code>database</Code> in your Headscale configuration.
</Notice>
) : undefined}
<h1 className="mb-4 text-2xl font-medium">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network. You can find more
information about the ACL file in the{" "}
<Link external styled to="https://tailscale.com/kb/1018/acls">
Tailscale ACL guide
</Link>{" "}
and the{" "}
<Link external styled to="https://headscale.net/stable/ref/acls/">
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice title={fetcher.data.error.split(":")[0] ?? "Error"} variant="error">
{fetcher.data.error.split(":").slice(1).join(": ") ??
"An unknown error occurred while trying to update the ACL policy."}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor" defaultValue="edit">
<TabsList>
<TabsTab value="edit">
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
</TabsTab>
<TabsTab value="diff">
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
</TabsTab>
<TabsTab value="preview">
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="preview">
<div className="flex flex-col items-center py-8">
<Construction />
<p className="mt-4 w-1/2 text-center">
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>
</TabsPanel>
</Tabs>
<Button
className="mr-2"
disabled={
disabled || fetcher.state !== "idle" || codePolicy.length === 0 || codePolicy === policy
}
onClick={() => {
const formData = new FormData();
formData.append("policy", codePolicy);
fetcher.submit(formData, { method: "PATCH" });
}}
variant="heavy"
>
Save
</Button>
<Button
disabled={disabled || fetcher.state !== "idle" || codePolicy === policy}
onClick={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes("reading policy from path") &&
error.data.rawData.includes("no such file or directory")
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does not exist on the
server. This usually indicates that Headscale is running in <Code>file</Code> mode for
ACLs, and the specified policy file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you can take:
</Card.Text>
<ul className="mt-2 ml-4 list-outside list-disc space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale configuration.
</li>
<li>
Alternatively, you can switch Headscale to use <Code>database</Code> mode for ACLs by
updating your Headscale configuration. This will allow Headplane to manage the ACL
policy directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
return <PageError error={error} page="Access Control" />;
}
+22 -45
View File
@@ -1,9 +1,9 @@
import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/Link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
import Card from "~/components/card";
import Code from "~/components/code";
import Link from "~/components/link";
import type { OidcErrorCode } from "~/server/oidc/provider";
export function OidcDiscoveryFailedNotice() {
return (
@@ -20,7 +20,7 @@ export function OidcDiscoveryFailedNotice() {
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
export function OidcConfigErrorNotice({ errors }: { errors: OidcErrorCode[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
@@ -34,10 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
<Link external styled to="https://headplane.net/features/sso#troubleshooting">
Learn more
</Link>
</Card.Text>
@@ -45,7 +42,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
function mapOidcErrorsToMessages(errors: OidcErrorCode[]) {
const messages: {
key: string;
node: React.ReactNode;
@@ -53,77 +50,57 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
case "invalid_api_key": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure that{" "}
<Code>oidc.headscale_api_key</Code> is a valid API key.
<Code>headscale.api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
}
case "MISSING_AUTHORIZATION_ENDPOINT":
case "missing_endpoints": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
The OIDC provider is missing required endpoints. Ensure the discovery URL is correct
or provide manual endpoint overrides in your configuration.
</Card.Text>
),
});
break;
}
case "MISSING_TOKEN_ENDPOINT":
case "discovery_failed": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
Unable to reach the OIDC provider for discovery. SSO will retry on the next login
attempt.
</Card.Text>
),
});
break;
}
case "MISSING_USERINFO_ENDPOINT":
default: {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_REQUIRED_CLAIMS":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
case "UNKNOWN_ERROR":
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
An unknown OIDC configuration error occurred. Please check the Headplane logs for more
information.
</Card.Text>
),
});
break;
}
}
}
+11 -12
View File
@@ -1,15 +1,14 @@
import Card from '~/components/Card';
import Card from "~/components/card";
export default function Logout() {
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
return (
<div className="flex h-screen w-screen items-center justify-center">
<Card className="m-4 max-w-md sm:m-0">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again, please refresh the page.
</Card.Text>
</Card>
</div>
);
}
+50 -53
View File
@@ -1,63 +1,60 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
import { AlertCircle } from "lucide-react";
import Card from "~/components/card";
import Code from "~/components/code";
export function OidcErrorNotice({ code }: { code: string }) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
}
function getErrorMessage(code: string) {
switch (code) {
case 'error_no_query':
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the
required parameters. Please ensure your SSO provider is configured
correctly.
</Card.Text>
);
switch (code) {
case "error_no_query":
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the required
parameters. Please ensure your SSO provider is configured correctly.
</Card.Text>
);
case 'error_no_session':
case 'error_invalid_session':
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data.
Ensure that your Headplane cookie configuration is correct and that
your browser is accepting cookies.
</Card.Text>
);
case "error_no_session":
case "error_invalid_session":
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data. Ensure that your
Headplane cookie configuration is correct and that your browser is accepting cookies.
</Card.Text>
);
case 'error_no_sub':
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure
your SSO provider is correctly configured to provide the{' '}
<Code>sub</Code> claim.
</Card.Text>
);
case "error_no_sub":
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure your SSO provider
is correctly configured to provide the <Code>sub</Code> claim.
</Card.Text>
);
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please try again later.
Headplane logs may provide more information.
</Card.Text>
);
case "error_auth_failed":
return (
<Card.Text>
Authentication with the SSO provider failed. Please try again later. Headplane logs may
provide more information.
</Card.Text>
);
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again
later.
</Card.Text>
);
}
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again later.
</Card.Text>
);
}
}
+25 -24
View File
@@ -1,12 +1,12 @@
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Form, Link as RemixLink, redirect, useSearchParams } from "react-router";
import { Form, Link as RouterLink, redirect, useSearchParams } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
@@ -24,16 +24,20 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidc?.connector.get();
const oidcService = context.oidc?.service;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
() => oidcService.status(),
)
: undefined;
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
return redirect("/oidc/start");
}
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
const isOidcConnectorEnabled = oidcStatus?.state === "ready";
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
@@ -63,14 +67,14 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
// Been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
// Which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
@@ -88,7 +92,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
) : oidcErrorCodes.includes("discovery_failed") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
@@ -103,7 +107,8 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
name="Headplane Common Issues"
external
styled
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
@@ -121,7 +126,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
required
label="API Key"
labelHidden
name="api_key"
@@ -138,15 +143,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="mt-2 w-full"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
<RouterLink to="/oidc/start" prefetch="none" reloadDocument>
<Button className="mt-2 w-full" disabled={oidcErrorCodes.length > 0} variant="light">
Single Sign-On
</Button>
</RemixLink>
</RouterLink>
) : undefined}
</Card>
</div>
+42 -112
View File
@@ -1,6 +1,3 @@
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
@@ -10,8 +7,8 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
@@ -34,115 +31,48 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/login?s=error_invalid_session");
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const flowState = {
state,
nonce,
codeVerifier: verifier,
redirectUri: redirect_uri,
};
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? "SSO User"));
const picture =
context.config.oidc?.profile_picture_source === "gravatar"
? (() => {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: userInfo.picture;
const userId = await context.auth.findOrCreateUser(claims.sub);
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name,
email: userInfo.email,
username,
picture,
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
const result = await service.handleCallback(url.searchParams, flowState);
if (!result.ok) {
log.error("auth", "OIDC callback failed [%s]: %s", result.error.code, result.error.message);
if (result.error.hint) {
log.error("auth", "Hint: %s", result.error.hint);
}
return redirect("/login?s=error_auth_failed");
}
const identity = result.value;
const userId = await context.auth.findOrCreateUser(identity.subject, {
name: identity.name,
email: identity.email,
picture: identity.picture,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name: identity.name,
email: identity.email,
username: identity.username,
}),
},
});
}
+13 -57
View File
@@ -1,7 +1,5 @@
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
@@ -12,70 +10,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/");
} catch {}
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const result = await service.startFlow();
if (!result.ok) {
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
return redirect(url, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
state: flowState.state,
nonce: flowState.nonce,
verifier: flowState.codeVerifier,
redirect_uri: flowState.redirectUri,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+163 -186
View File
@@ -1,205 +1,182 @@
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import { closestCorners, DndContext, DragOverlay } from "@dnd-kit/core";
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Form } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Lock } from "lucide-react";
import { useEffect, useState } from "react";
import { Form } from "react-router";
import Button from "~/components/button";
import Input from "~/components/input";
import TableList from "~/components/table-list";
import cn from "~/utils/cn";
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
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);
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]);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-full sm: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
collisionDetection={closestCorners}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
return (
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">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
collisionDetection={closestCorners}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
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];
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<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
domain={sd}
id={index + 1}
isDisabled={isDisabled}
key={sd}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
className="flex items-center justify-between w-full"
method="POST"
>
<input name="action_id" type="hidden" value="add_domain" />
<Input
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/>
<Button
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
type="submit"
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<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="py-0.5 font-mono text-sm">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext items={localDomains} strategy={verticalListSortingStrategy}>
{localDomains.map((sd, index) => (
<Domain domain={sd} id={index + 1} isDisabled={isDisabled} key={sd} />
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form className="flex w-full items-center justify-between" method="POST">
<input name="action_id" type="hidden" value="add_domain" />
<Input
className={cn(
"border-none font-mono p-0 text-sm",
"rounded-none focus:ring-0 w-full ml-1",
)}
required
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/>
<Button
className={cn("px-2 py-1 rounded-md", "text-blue-500 dark:text-blue-400")}
type="submit"
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
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 });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)}
ref={setNodeRef}
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-3 outline-hidden rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<Button
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
return (
<TableList.Item
className={cn(
isSortableDragging ? "opacity-50" : "",
isDragging ? "ring-3 bg-white dark:bg-mist-900" : "",
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="flex items-center gap-4 font-mono text-sm">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="rounded-md p-0.5 outline-hidden focus:ring-3"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<Button
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
disabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
}

Some files were not shown because too many files have changed in this diff Show More