Compare commits
109 Commits
v0.6.2
...
v0.7.0-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 272233ae62 | |||
| 974c3b0e48 | |||
| acd3ff3403 | |||
| 1961608e12 | |||
| c4ac28f90b | |||
| a0b2077c7b | |||
| 0f19fdf0da | |||
| d255098128 | |||
| 98e0806e5a | |||
| 44dffeaff0 | |||
| 33f7bbb0cf | |||
| 10278d0cc9 | |||
| bdcd4c5bad | |||
| 43cff2f4b7 | |||
| 61e7303363 | |||
| dc44cc4155 | |||
| 30ce5e2727 | |||
| 55a09476ab | |||
| ac90b4e8bb | |||
| 4b47b1bbed | |||
| 003985d192 | |||
| 1259642f8a | |||
| 4cd0c1e206 | |||
| b2dfa773b0 | |||
| 73b5d5514e | |||
| 838a2cd732 | |||
| 0191da4ce2 | |||
| ad1d47b223 | |||
| a5e2af9b30 | |||
| 9ac3ab1861 | |||
| 44f45c396c | |||
| ee59a2d06d | |||
| 2c57187628 | |||
| 57c15f7c6b | |||
| 131c54f501 | |||
| 333220a153 | |||
| 61ca6693c4 | |||
| 30bbcbab2a | |||
| a1eb6d35db | |||
| 98d8dfe235 | |||
| 54f236a332 | |||
| 873e6005d9 | |||
| 3213385c0f | |||
| 2e2d5d1dff | |||
| 925d78a175 | |||
| 4e94e82a01 | |||
| 3ec00e17d8 | |||
| dfda629b12 | |||
| 04debdcadf | |||
| 718cbf3c40 | |||
| 246bb90ef0 | |||
| 7403ebea06 | |||
| 941971b8c7 | |||
| 5aa17e448d | |||
| fc9df0c536 | |||
| b6b773fa6d | |||
| cc5aa99046 | |||
| 5f81822366 | |||
| 4fb1cf059e | |||
| b1361e9062 | |||
| dd0587dad1 | |||
| 4997687a58 | |||
| 8f6fe05c83 | |||
| 25dc09e025 | |||
| 27f8fa0b42 | |||
| eda5713700 | |||
| dc2e2dcc98 | |||
| f0df663c90 | |||
| 1025a657b0 | |||
| 0741567754 | |||
| a26faab139 | |||
| 55ddbe0987 | |||
| e255407115 | |||
| 225a52ded6 | |||
| 38cf93e5ae | |||
| b7a85684a9 | |||
| e1808bee1e | |||
| cf4c163c92 | |||
| 6470f5a821 | |||
| a53fd31d27 | |||
| 37d0080cba | |||
| 0ce411e92b | |||
| df5e04f239 | |||
| e6d90c3124 | |||
| a967731461 | |||
| a1efe36ff1 | |||
| 270b99f063 | |||
| 434f886034 | |||
| 684a95b5e8 | |||
| 45984ec639 | |||
| c7f3b60b62 | |||
| 0f8e192b5c | |||
| 6d70497758 | |||
| f4a2811afb | |||
| 5f2cbebbcd | |||
| da3132323c | |||
| cb97b73abd | |||
| 432cd29cd0 | |||
| 1feb109a36 | |||
| f6613f1faf | |||
| 210d473bc2 | |||
| 67b1611d13 | |||
| df11aefd0c | |||
| f9dc9d454b | |||
| 64edd2041b | |||
| 1462016a39 | |||
| 779103147d | |||
| be79413178 | |||
| 787f8decbc |
@@ -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/*
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question or Support
|
||||
url: https://github.com/tale/headplane/discussions/new?category=q-a
|
||||
about: Ask questions or get help with your setup
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
Agent:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_agent/**
|
||||
- internal/**
|
||||
- go.mod
|
||||
- go.sum
|
||||
|
||||
UI/UX:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/components/**
|
||||
- app/layouts/**
|
||||
- app/root.tsx
|
||||
- app/tailwind.css
|
||||
|
||||
Authentication:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/web/**
|
||||
- app/routes/auth*/**
|
||||
|
||||
Config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/config/**
|
||||
- internal/config/**
|
||||
- config.example.yaml
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/headscale/**
|
||||
- app/openapi-*.json
|
||||
|
||||
Web SSH:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_ssh/**
|
||||
|
||||
Docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- docs/**
|
||||
- README.md
|
||||
@@ -1,5 +1,11 @@
|
||||
name: Automated
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "pnpm-lock.yaml"
|
||||
- "package.json"
|
||||
branches:
|
||||
- "main"
|
||||
schedule:
|
||||
- cron: "0 8 * * 0"
|
||||
workflow_dispatch:
|
||||
@@ -9,24 +15,70 @@ 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
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
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: |
|
||||
|
||||
@@ -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:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -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,19 +53,22 @@ jobs:
|
||||
run: pnpm run test:integration
|
||||
|
||||
nix:
|
||||
name: nix
|
||||
name: Nix Flake Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Labeler
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label Pull Request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels based on changed files
|
||||
uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Release Lifecycle
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
beta:
|
||||
name: Beta Release Lifecycle
|
||||
if: contains(github.ref_name, '-')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Label and comment on referenced issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag found, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing issues referenced between ${PREV_TAG} and ${TAG}"
|
||||
ISSUES=$(git log --format="%B" "${PREV_TAG}..HEAD" | grep -oE '#[0-9]+' | sort -u | tr -d '#')
|
||||
|
||||
for ISSUE in $ISSUES; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
STATE=$(gh issue view "$ISSUE" --json state --jq '.state' \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || echo "")
|
||||
[ "$STATE" != "OPEN" ] && continue
|
||||
|
||||
echo "Labeling and commenting on #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --add-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "🧪 Available in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
stable:
|
||||
name: Stable Release Lifecycle
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close milestone issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
MILESTONE_NUMBER=$(gh api "repos/${{ github.repository }}/milestones" \
|
||||
--jq ".[] | select(.title == \"${VERSION}\") | .number" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$MILESTONE_NUMBER" ]; then
|
||||
echo "No milestone found for ${VERSION}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing milestone ${VERSION} (#${MILESTONE_NUMBER})"
|
||||
gh api "repos/${{ github.repository }}/issues?milestone=${MILESTONE_NUMBER}&state=open&per_page=100" \
|
||||
--paginate --jq '.[].number' 2>/dev/null | while IFS= read -r ISSUE; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
echo "Closing #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --remove-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "✅ Released in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue close "$ISSUE" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "Closing milestone ${VERSION}"
|
||||
gh api -X PATCH "repos/${{ github.repository }}/milestones/${MILESTONE_NUMBER}" \
|
||||
-f state=closed 2>/dev/null || true
|
||||
@@ -1,82 +0,0 @@
|
||||
name: Pre-release (next)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pre-release-${{ github.ref }}
|
||||
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
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag: "next"
|
||||
- target: debug-shell
|
||||
tag: "next-shell"
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.tag }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
HEADPLANE_VERSION=next+${{ github.sha }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -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@v4
|
||||
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 }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Stale
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Close Stale Issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark and close stale issues
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-label: Stale
|
||||
only-labels: "Needs Info"
|
||||
days-before-stale: 30
|
||||
stale-issue-message: >
|
||||
This issue has been waiting for information for 30 days.
|
||||
It will be closed in 7 days if there is no further activity.
|
||||
Feel free to reopen if you can provide the requested details.
|
||||
close-issue-message: >
|
||||
Closed due to inactivity. Feel free to reopen with the
|
||||
requested information.
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Triage
|
||||
on:
|
||||
issues:
|
||||
types: [milestoned]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
remove-triage:
|
||||
name: Remove Needs Triage Label
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove Needs Triage label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh issue edit ${{ github.event.issue.number }} \
|
||||
--remove-label "Needs Triage" \
|
||||
--repo ${{ github.repository }} 2>/dev/null || true
|
||||
@@ -13,3 +13,4 @@ node_modules
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
/.direnv
|
||||
/vendor
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Core Concepts
|
||||
|
||||
Headplane is a web application to manage Headscale, a self-hosted implementation
|
||||
of the Tailscale control server. There are a few tenets that guide the entire
|
||||
development of the project:
|
||||
|
||||
- **Simple starts**: We want to make it as easy as possible to set up and use
|
||||
Headplane, while still providing powerful features for advanced users. This
|
||||
means that we prioritize a clean and intuitive user interface, as well as
|
||||
straightforward installation and configuration processes.
|
||||
|
||||
- **No breaking changes**: We want to avoid making breaking changes to the
|
||||
project as much as possible. This means that we will strive to maintain
|
||||
backward compatibility and provide clear migration paths when necessary.
|
||||
|
||||
- **Documentation**: This is the most important part of the project, without it
|
||||
the entire project falls apart and is hard to use.
|
||||
|
||||
## Project Management
|
||||
|
||||
It's hard to manage this project easily, use the `gh` CLI when responding to
|
||||
prompts to get context. Some common issue tags to keep track of include a
|
||||
"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based
|
||||
on what parts of the project are affected.
|
||||
|
||||
## Headplane Agent
|
||||
|
||||
The Headplane Agent is a lightweight component that runs on the same server as
|
||||
Headplane and connects directly to the Tailnet in order to pull in details about
|
||||
nodes that aren't available through the Headscale API such as versions, etc.
|
||||
|
||||
## WebSSH
|
||||
|
||||
This is an ephemeral WASM shim that runs in the browser and connects directly
|
||||
to the Tailnet using Tailscale's go packages. It allows anyone to open up an
|
||||
ephemeral machine in the Tailnet that directly SSHes into a target node.
|
||||
|
||||
## Build/Tooling
|
||||
|
||||
Headplane is a React Router 7 (framework mode) project built with Vite. Take
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
The project has a documentation site available at the `docs/` directory built
|
||||
with VitePress. The documentation is written in Markdown and can be easily
|
||||
edited and extended. If making changes to staple features, please take care to
|
||||
also update the documentation to reflect any changes in functionality or usage.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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__} ·{" "}
|
||||
{urlVisible ? (
|
||||
<code>{baseUrl}</code>
|
||||
) : (
|
||||
<span aria-hidden="true">•••••</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +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 session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
// 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)) {
|
||||
log.warn(
|
||||
'auth',
|
||||
'Logging out %s due to expired API key',
|
||||
session.user.name,
|
||||
);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
healthy,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Outlet, redirect } from "react-router";
|
||||
|
||||
import Footer from "~/components/Footer";
|
||||
import Header from "~/components/Header";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
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 session = await context.sessions.auth(request);
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
session.user.subject !== "unknown-non-oauth" &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user?.onboarded) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
|
||||
// OIDC users without ui_access go to pending approval
|
||||
if (
|
||||
!check &&
|
||||
session.user.subject !== "unknown-non-oauth" &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
return redirect("/pending-approval");
|
||||
}
|
||||
|
||||
return {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
dns: await context.sessions.check(request, Capabilities.read_network),
|
||||
users: await context.sessions.check(request, Capabilities.read_users),
|
||||
policy: await context.sessions.check(request, Capabilities.read_policy),
|
||||
machines: await context.sessions.check(request, Capabilities.read_machines),
|
||||
settings: await context.sessions.check(request, Capabilities.read_feature),
|
||||
},
|
||||
onboarding: request.url.endsWith("/onboarding"),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
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 |
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -2,41 +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("/pending-approval", "routes/auth/pending-approval.tsx"),
|
||||
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,139 +1,129 @@
|
||||
import { data } from 'react-router';
|
||||
import { isDataWithApiError } from '~/server/headscale/api/error-client';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
// We only check capabilities here and assume it is writable
|
||||
// If it isn't, it'll gracefully error anyways, since this means some
|
||||
// fishy client manipulation is happening.
|
||||
export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_policy,
|
||||
);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to write to the ACL policy', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to write to the ACL policy", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||
const formData = await request.formData();
|
||||
const policyData = formData.get('policy')?.toString();
|
||||
if (!policyData) {
|
||||
throw data('Missing `policy` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||
const formData = await request.formData();
|
||||
const policyData = formData.get("policy")?.toString();
|
||||
if (!policyData) {
|
||||
throw data("Missing `policy` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
const rawData = error.data.rawData;
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||
if (rawData.includes('update is disabled')) {
|
||||
throw data('Policy is not writable', { status: 403 });
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
const rawData = error.data.rawData;
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||
if (rawData.includes("update is disabled")) {
|
||||
throw data("Policy is not writable", { status: 403 });
|
||||
}
|
||||
|
||||
const message =
|
||||
error.data.data != null &&
|
||||
'message' in error.data.data &&
|
||||
typeof error.data.data.message === 'string'
|
||||
? error.data.data.message
|
||||
: undefined;
|
||||
const message =
|
||||
error.data.data != null &&
|
||||
"message" in error.data.data &&
|
||||
typeof error.data.data.message === "string"
|
||||
? error.data.data.message
|
||||
: undefined;
|
||||
|
||||
if (message == null) {
|
||||
throw error;
|
||||
}
|
||||
if (message == null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
|
||||
if (message.includes('parsing HuJSON:')) {
|
||||
const cutIndex = message.indexOf('parsing HuJSON:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
|
||||
: message;
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('parsing policy from bytes:')) {
|
||||
const cutIndex = message.indexOf('parsing policy from bytes:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
|
||||
: message;
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes('parsing hujson')) {
|
||||
const cutIndex = message.indexOf('err: hujson:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
||||
: message;
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes("parsing hujson")) {
|
||||
const cutIndex = message.indexOf("err: hujson:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('unmarshalling policy')) {
|
||||
const cutIndex = message.indexOf('err:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
||||
: message;
|
||||
if (message.includes("unmarshalling policy")) {
|
||||
const cutIndex = message.indexOf("err:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { data } from 'react-router';
|
||||
import { isDataWithApiError } from '~/server/headscale/api/error-client';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
// The logic for deciding policy factors is very complicated because
|
||||
// there are so many factors that need to be accounted for:
|
||||
@@ -11,38 +13,42 @@ import type { Route } from './+types/overview';
|
||||
// If database, we can read/write easily via the API.
|
||||
// If in file mode, we can only write if context.config is available.
|
||||
export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to read the ACL policy.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to read the ACL policy.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: await context.sessions.check(request, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: '',
|
||||
};
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: context.auth.can(principal, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: "",
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
|
||||
if (error.data.rawData.includes('acl policy not found')) {
|
||||
flags.policy = '';
|
||||
flags.writable = true;
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
// Try to load the ACL policy from the API.
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
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") || error.data.statusCode === 500) {
|
||||
flags.policy = "";
|
||||
flags.writable = true;
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { data, redirect } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import log from "~/utils/log";
|
||||
@@ -15,7 +15,10 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
"auth",
|
||||
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
|
||||
);
|
||||
throw data("Missing `api_key`", { status: 400 });
|
||||
return {
|
||||
success: false,
|
||||
message: "Missing API key. Please enter your API key.",
|
||||
};
|
||||
}
|
||||
|
||||
if (apiKey.length === 0) {
|
||||
@@ -24,13 +27,15 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
"auth",
|
||||
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
|
||||
);
|
||||
throw data("Received an empty `api_key`", { status: 400 });
|
||||
return {
|
||||
success: false,
|
||||
message: "API key cannot be empty. Please enter a valid API key.",
|
||||
};
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const apiKeys = await api.getApiKeys();
|
||||
console.log(apiKeys);
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
@@ -47,7 +52,10 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
if (lookup.expiration === null || lookup.expiration === undefined) {
|
||||
log.error("auth", "Got an API key without an expiration");
|
||||
throw data("API key is malformed", { status: 500 });
|
||||
return {
|
||||
success: false,
|
||||
message: "API key is malformed (missing expiration). Please generate a new API key.",
|
||||
};
|
||||
}
|
||||
|
||||
const expiry = new Date(lookup.expiration);
|
||||
@@ -58,19 +66,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
|
||||
|
||||
return redirect("/machines", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.createSession(
|
||||
{
|
||||
api_key: apiKey,
|
||||
user: {
|
||||
subject: "unknown-non-oauth",
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
},
|
||||
},
|
||||
"Set-Cookie": await context.auth.createApiKeySession(
|
||||
apiKey,
|
||||
`${lookup.prefix}...`,
|
||||
expiry.getTime() - Date.now(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||