Compare commits

...

65 commits

Author SHA1 Message Date
routerino
a7f79824bf
Update install-openvscode-server.sh 2025-08-30 11:25:52 +10:00
routerino
3c22ed935d
Update version before release 2025-08-23 11:00:38 +10:00
routerino
c48c0a54fb
210 feature request show user names not just usernames (#211)
* fix: display email if username is too short

* feat: added email field to expanded cards
2025-08-23 10:59:46 +10:00
routerino
fbb8b2b968
test before release 2025-07-12 10:02:00 +10:00
routerino
baffa0024c
fix undocumented headscale ui change (#207) 2025-07-12 10:01:05 +10:00
Eloxt Wang
15c4dd9575
Add route removal functionality and fix code formatting (#206)
- Add removeRouteAction function to allow disabling active routes
- Update approveDeviceRoute to accept full routes array instead of single route
2025-07-11 09:00:47 +10:00
routerino
7dd92ab4ad
Fix: CICD Pipeline for release plugin (#201)
* testing-github-piprline

* fix pipeline

* bump version
2025-05-24 12:57:50 +10:00
Chris Bisset
5e97c52303 update readme 2025-05-22 07:38:40 +00:00
Chris Bisset
9516a519b8 update version before publish 2025-05-22 04:24:21 +00:00
Chris Bisset
625647ed19 fix duplicate tags 2025-05-22 04:23:57 +00:00
Chris Bisset
907ab6af57 remove old route class 2025-05-22 04:13:58 +00:00
Chris Bisset
adef58f27d fix device route for new API 2025-05-22 04:07:22 +00:00
Chris Bisset
38e0de2696 fix api calls for preauth keys 2025-05-20 00:20:22 +00:00
Chris Bisset
84aec5f45a update package version for release 2025-03-20 23:12:41 +00:00
DmitryBoiadji
b9cc07d9de
fixing unknown command headscale (#191)
got an error running docker compose:


[+] Running 2/2
 ⠿ Container headscale-ui  Created                                                                                                   0.0s
 ⠿ Container headscale     Recreated                                                                                                 0.8s
Attaching to headscale, headscale-ui
headscale     | Error: unknown command "headscale" for "headscale"
headscale     | Run 'headscale --help' for usage.
headscale     | unknown command "headscale" for "headscale"
headscale-ui  | Starting Caddy

fixed by removing unnecessary headscale prefix before serve command
2025-03-21 10:09:29 +11:00
routerino
2508507644
remove min length from api and device fields (#198) 2025-03-21 10:09:00 +11:00
routerino
cf682bddbb
fix folder creation 2025-03-15 13:18:55 +11:00
routerino
f3c4a6afbf
fix dev build 2025-03-15 13:01:29 +11:00
routerino
722eaa64ed
fix user permissions on dev image 2025-03-15 12:54:29 +11:00
Chris Bisset
f5df8899ee move development image to debian 2025-03-15 01:41:46 +00:00
routerino
7bb7d1f7cb
Update install-openvscode-server.sh (#195) 2025-03-15 12:15:05 +11:00
routerino
d7a7136897
fix connection token env 2025-03-15 12:13:36 +11:00
Chris Bisset
4bc00fbbfc update readme 2025-03-13 22:23:28 +00:00
Chris Bisset
3bf4bfa2ea update version before release 2025-03-13 22:22:17 +00:00
Chris Bisset
2631785f9f fix api for reassigning user 2025-03-13 22:21:18 +00:00
Guillaume Chx
216a49ec52
fix: headscale 25 uses user's "name" instead of "id" to get/create/expire pre-auth keys (#192) 2025-03-14 09:09:56 +11:00
Chris Bisset
21075b994a update dependencies before release 2025-01-20 10:12:42 +11:00
routerino
25ac133d86
Revert "Update lock file and remove conflicting dependency (#186)" (#188)
This reverts commit b8042250ad.
2025-01-20 09:49:58 +11:00
rohanharikr
b8042250ad
Update lock file and remove conflicting dependency (#186)
* remove conflicting dep

* update lock
2025-01-20 09:12:10 +11:00
Poupapaa
3e5651189e
Make headscale-ui compatible with headscale v0.24 - *BREAKING* (#185) 2025-01-18 10:02:20 +11:00
Vlad Stefan
7a1725daa6
Update configuration.md (#176)
The latest major release of headscale ui change the default container ports from 80 and 443 to 8080 and 8443. Updated the traefik port to reflect changes.
2024-11-09 18:44:36 +11:00
routerino
0c56afac23
Update README.md for latest release warning (#173) 2024-10-22 09:26:45 +11:00
routerino
a387c3fc62
Update README.md for docker compose (#171) 2024-10-16 08:09:00 +11:00
routerino
5698becc6f
Merge pull request #169 from gurucomputing:test-before-release
update version and tests
2024-10-10 08:26:21 +11:00
Chris Bisset
19d3dab7be update version and tests 2024-10-10 08:25:26 +11:00
routerino
9bc6a9346c
Merge pull request #168 from gurucomputing:167-can-not-get-device-router
removed remaining references to 'machine' api
2024-10-10 08:16:55 +11:00
Chris Bisset
ec79e4c908 removed remaining references to 'machine' api 2024-10-10 08:15:03 +11:00
routerino
64323e49c5
163-production-build-not-completing
* resolved svelte-check warnings, reverted svelte preprocessor version

* added caddy dependency to dev image

* adapted to run on new port
2024-10-05 09:43:39 +10:00
routerino
c16a381755
updated dependencies and version
* updated dependencies and version
2024-10-05 08:59:01 +10:00
routerino
bcf9ef1e31
fix renaming users with numbers
* fix renaming users with numbers
2024-10-05 08:52:35 +10:00
routerino
beb4a4b8fc
added online check for device status tooltip
* added online check for device status tooltip
2024-10-05 08:48:44 +10:00
routerino
297ae51eaa
force lowercase on submit
* force lowercase on submit
2024-10-05 08:37:17 +10:00
routerino
d340d7dd4c
Update README.md 2024-10-01 22:22:18 +10:00
routerino
26ab0848da
Minor Update to README.md 2024-10-01 22:11:26 +10:00
routerino
a2cd992778
145-crashes-upon-boot-and-for-some-reason-tries-loading-caddy-stuff-im-not-using-caddy
* removed unused test docker profiles, changed default docker port

* updated readme
2024-10-01 22:02:33 +10:00
routerino
a6e53bae9c
removed backwards compatibility check
* removed backwards compatibility check
2024-10-01 21:56:15 +10:00
routerino
f91f88cdda
added numbers to possible inputs
* added numbers to possible inputs
2024-10-01 21:45:54 +10:00
Guillaume Chx
ae481f54cc
feat: redesign tweaks (#150) 2024-10-01 21:26:55 +10:00
routerino
8e54c94a8d
updated dependencies. Changed dev to http (as it needs proxying anyway)
* updated dependencies. Changed dev to http (as it needs proxying anyway)
2024-10-01 21:22:39 +10:00
routerino
89b113a223
Update issue templates 2024-02-24 18:56:17 +11:00
routerino
c4650bd59b
Merge pull request #136 from gurucomputing:routerino/housekeeping-133
updated dependencies
2024-02-24 15:21:47 +11:00
Christopher Bisset
31943c23da updated dependencies 2024-02-24 15:09:52 +11:00
routerino
2d9b1c03ef
Fix developer image pipeline (#135)
Fixes #134
2024-02-24 15:08:17 +11:00
Christopher Bisset
8ee2a6e9aa dependencies update 2024-02-24 14:35:14 +11:00
routerino
61fef46e48
Update install-openvscode-server.sh 2024-02-24 14:31:53 +11:00
routerino
eead9859f9
change api to dynamically check for breaking change (#132) 2024-02-24 14:13:17 +11:00
Austin Drummond
a9db179089
fix ssr with nodekey param (#122) 2023-11-20 10:21:51 +11:00
Austin Drummond
1f73a7bf8a
Add a new device registration route (#121)
* added a new device registration form

* removed unused variables

* remove old register html

* bind new device key from url

* remove redirect

* clear nodekey query string param after successuful addition

* expand bool flipping

* added documention on nodekey

* remove null check

* tweak wording
2023-11-18 09:34:36 +11:00
routerino
88012147d3
added troubleshooting clarification 2023-08-02 09:31:54 +10:00
routerino
61bd8ab49b
added example nginx config (#108) 2023-06-25 17:05:25 +10:00
routerino
dd944a45c1
GitHub actions testing (#107)
* test 1

* test 2

* remove accidental pasting in commit

* removed multi-platform for dev

* Revert "Add button to delete machine route"

This reverts commit 63041fd673.

* modernized release workflow

* update version

* Migration to Svelte 4

* cleaned up version injection

* moved build arg below FROM

* increment release
2023-06-25 17:00:13 +10:00
routerino
fab13597d8 Revert "Add button to delete machine route"
This reverts commit 63041fd673.
2023-06-18 19:56:33 +10:00
Christopher Bisset
1a6b7a6626 re-design docker development image 2023-06-18 17:18:40 +10:00
Christopher Bisset
e629197b36 updated dependencies to latest 2023-06-18 13:22:55 +10:00
幻猪
63041fd673 Add button to delete machine route 2023-04-11 15:16:23 +10:00
53 changed files with 2381 additions and 2981 deletions

View file

@ -13,5 +13,8 @@ Provide the following:
* Headscale Version:
* Any Browser Errors (`control+shift+i` in chrome to see)
** Note **
No bug reports are currently being accepted against the alpha version of headscale. Test against the production/stable version.
**Describe the bug**
A clear and concise description of what the bug is. Screenshots if applicable

View file

@ -16,29 +16,16 @@ jobs:
id: gathervars
run: |
# get a current BUILD_DATE
echo "::set-output name=BUILD_DATE::$(date +%Y%m%d-%H%M%S)"
echo "BUILD_DATE=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_ENV
# set version based on BUILD_DATE
echo "::set-output name=VERSION::$(date +%Y.%m.%d)-development"
# setting tags
echo "::set-output name=TAG::development"
echo "VERSION=$(date +%Y.%m.%d)-development" >> $GITHUB_ENV
- name: Checkout Repository
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: ${{ runner.os }}-buildx-
- name: Log in to the Container registry
uses: docker/login-action@v1
with:
@ -47,16 +34,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker Image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
build-args: |
BUILD_DATE=${{ steps.gathervars.outputs.BUILD_DATE }}
VERSION=${{ steps.gathervars.outputs.VERSION }}
BUILD_DATE=${{ env.BUILD_DATE }}
VERSION=${{ env.VERSION }}
context: ./docker/development
tags: |
ghcr.io/${{ github.repository }}-dev:latest
ghcr.io/${{ github.repository }}-dev:${{ steps.gathervars.outputs.VERSION }}
platforms: linux/amd64,linux/arm64,linux/arm32v7
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
ghcr.io/${{ github.repository }}-dev:${{ env.VERSION }}
push: true

View file

@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Variable Gathering
id: gathervars
@ -18,91 +18,69 @@ jobs:
NOT_PREVIOUSLY_PUBLISHED=0
# get a current BUILD_DATE
VERSION=$(jq -r '.version' ./package.json)
echo "::set-output name=BUILD_DATE::$(date +%Y%m%d-%H%M%S)"
echo "::set-output name=VERSION::$VERSION"
echo "BUILD_DATE=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_ENV
# setting tags
if echo "$VERSION" | grep -q "beta"; then
TAGS="ghcr.io/${{ github.repository }}:beta, ghcr.io/${{ github.repository }}:$VERSION, ghcr.io/${{ github.repository }}:latest"
PRIMARY_TAG=latest
echo "TAGS=ghcr.io/${{ github.repository }}:beta, ghcr.io/${{ github.repository }}:$VERSION, ghcr.io/${{ github.repository }}:latest" >> $GITHUB_ENV
else
TAGS="ghcr.io/${{ github.repository }}:release, ghcr.io/${{ github.repository }}:latest, ghcr.io/${{ github.repository }}:$VERSION"
PRIMARY_TAG=latest
echo "TAGS=ghcr.io/${{ github.repository }}:release, ghcr.io/${{ github.repository }}:latest, ghcr.io/${{ github.repository }}:$VERSION" >> $GITHUB_ENV
fi
echo "::set-output name=TAG::$TAGS"
echo "::set-output name=PRIMARY_TAG::$PRIMARY_TAG"
echo "PRIMARY_TAG=latest" >> $GITHUB_ENV
# check if version has already been published
$(docker manifest inspect ghcr.io/${{ github.repository }}:$VERSION > /dev/null) || NOT_PREVIOUSLY_PUBLISHED=1
echo "::set-output name=NOT_PREVIOUSLY_PUBLISHED::$NOT_PREVIOUSLY_PUBLISHED"
echo "NOT_PREVIOUSLY_PUBLISHED=$NOT_PREVIOUSLY_PUBLISHED" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
uses: docker/login-action@v3
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker Image
uses: docker/build-push-action@v2
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
uses: docker/build-push-action@v6
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
with:
build-args: |
BUILD_DATE=${{ steps.gathervars.outputs.BUILD_DATE }}
VERSION=${{ steps.gathervars.outputs.VERSION }}
BUILD_DATE=${{ env.BUILD_DATE }}
VERSION=${{ env.VERSION }}
context: ./docker/production
tags: |
${{ steps.gathervars.outputs.TAG }}
${{ env.TAGS }}
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
push: true
- name: Extract build out of docker image
uses: shrink/actions-docker-extract@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
uses: shrink/actions-docker-extract@v3
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
id: extract
with:
image: ghcr.io/${{ github.repository }}:${{ steps.gathervars.outputs.PRIMARY_TAG }}
image: ghcr.io/${{ github.repository }}:${{ env.PRIMARY_TAG }}
path: web
- name: create release asset
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
run: |
cd "${{ steps.extract.outputs.destination }}"
7z a headscale-ui.zip web
- name: Create Draft Release
id: create_release
uses: actions/create-release@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
- name: Create Release
uses: softprops/action-gh-release@v2
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.gathervars.outputs.VERSION }}
release_name: headscale-ui
draft: true
prerelease: false
- name: upload asset to releases
uses: actions/upload-release-asset@v1.0.1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ steps.extract.outputs.destination }}/headscale-ui.zip
asset_name: headscale-ui.zip
asset_content_type: application/zip
- name: publish release
uses: eregon/publish-release@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}
tag_name: ${{ env.VERSION }}
name: headscale-ui
files: ${{ steps.extract.outputs.destination }}/headscale-ui.zip
generate_release_notes: true
make_latest: true

View file

@ -1,12 +1,6 @@
{
skip_install_trust
}
:443 {
:8080 {
redir / /web
uri strip_prefix /web
tls internal {
on_demand
}
file_server {
root ./build
}

View file

@ -4,6 +4,9 @@ A web frontend for the [headscale](https://github.com/juanfont/headscale) Tailsc
![](documentation/assets/headscale-ui-demo.gif)
## Installation
> [!WARNING]
> The latest major release of headscale ui change the default container ports from `80` and `443` to `8080` and `8443` respectively. If you are using the `HTTP_PORT` or `HTTPS_PORT` environment variables this does not affect you, otherwise you need to change your ports in your docker-compose or kubernetes manifests.
Headscale-UI is currently released as a static site: just take the release and host with your favorite web server. Headscale-UI expects to be served from the `/web` path to avoid overlap with headscale on the same domain. Note that due to CORS (see https://github.com/juanfont/headscale/issues/623), headscale UI *must* be served on the same subdomain, or CORS headers injected via reverse proxy.
### Docker Installation
@ -13,42 +16,38 @@ If you are using docker, you can install `headscale` alongside `headscale-ui`, l
version: '3.5'
services:
headscale:
image: headscale/headscale:latest
image: headscale/headscale:stable
container_name: headscale
volumes:
- ./container-config:/etc/headscale
- ./container-data/data:/var/lib/headscale
# ports:
# - 27896:8080
command: headscale serve
command: serve
restart: unless-stopped
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
restart: unless-stopped
container_name: headscale-ui
# ports:
# - 9443:443
# - 8443:8443
# - 8080:8080
```
Headscale UI serves on port 443 and uses a self signed cert by default. You will need to add a `config.yaml` file under your `container-config` folder so that `headscale` has all of the required settings declared. An example from the official `headscale` repo is [here](https://github.com/juanfont/headscale/blob/main/config-example.yaml).
Headscale UI serves on port 8080/8443 and uses a self signed cert by default. You will need to add a `config.yaml` file under your `container-config` folder so that `headscale` has all of the required settings declared. An example from the official `headscale` repo is [here](https://github.com/juanfont/headscale/blob/main/config-example.yaml).
### Additional Docker Settings
The docker container lets you set the following settings:
| Variable | Description | Example |
|----|----|----|
| HTTP_PORT | Sets the HTTP port to an alternate value | `80` |
| HTTPS_PORT | Sets the HTTPS port to an alternate value | `443` |
| HTTP_PORT | Sets the HTTP port to an alternate value | `8080` |
| HTTPS_PORT | Sets the HTTPS port to an alternate value | `8443` |
### Proxy Settings
You will need a reverse proxy to install `headscale-ui` on your domain. Here is an example [Caddy Config](https://caddyserver.com/) to achieve this:
```
https://hs.yourdomain.com.au {
reverse_proxy /web* https://headscale-ui {
transport http {
tls_insecure_skip_verify
}
}
reverse_proxy /web* http://headscale-ui:8080
reverse_proxy * http://headscale:8080
}
@ -92,6 +91,10 @@ See [Other Configurations](/documentation/configuration.md) for further proxy ex
The following versions correspond to the appropriate headscale version
| Headscale Version | HS-UI Version |
|-------------------|---------------|
| 26+ | 2025-05-22+ |
| 25+ | 2025-03-14+ |
| 24+ | 2025-01-20+ |
| 23+ | 2024-10-01+ |
| 19+ | 2023-01-30+ |
| <19 | <2023-01-30 |
@ -107,9 +110,9 @@ Note that while mobile is checked for functionality, the web experience is not m
If you are getting errors about preflight checks, it's probably CORS related. Make sure your UI sits on the same subdomain as headscale or inject CORS headers.
### Errors related to "Missing Bearer Prefix"
Your API key is either not saved. Create an API key in `headscale` (via command line) with `headscale apikeys create` or `docker exec <headscale container> headscale apikeys create` and save it in `settings`.
Your API key is either not saved or you haven't configured your reverse proxy. Create an API key in `headscale` (via command line) with `headscale apikeys create` or `docker exec <headscale container> headscale apikeys create` and save it in `settings`.
Alternatively, you haven't fixed your domain. HS-UI *has* to be ran on the same subdomain or you need to configure CORS. Yes you need to use a reverse proxy to do this. Use a reverse proxy.
HS-UI *has* to be ran on the same subdomain as headscale or you need to configure CORS. Yes you need to use a reverse proxy to do this. Use a reverse proxy. If you are trying to use raw IPs and ports, it *will* not work.
## Security
see [security](/SECURITY.md) for details

View file

@ -1,24 +1,19 @@
FROM node:lts
# Arguments
ARG OPENVSCODE_VERSION="1.74.0"
# Volumes
VOLUME /data
# Ports
# openvscode server port. Note: Runs HTTP by default
EXPOSE 3000
# Dev Web Server port. Runs a self signed SSL certificate
EXPOSE 443
# System Environment Variables
ENV PATH="/opt/vscode:${PATH}"
ENV HOME="/data/home"
ENV SHELL="/bin/bash"
# User Set Environment Variables
# Set to false if you do not want to attempt to pull a repository on first load
ENV AUTOINITIALIZE=true
ENV AUTOINITIALIZE=false
# sets a connection token for VSCode Server. https://github.com/gitpod-io/openvscode-server#securing-access-to-your-ide
ENV USE_CONNECTION_TOKEN=true
#Set to a secret to have some measure of protection for vscode. Randomized if left blank
@ -28,7 +23,7 @@ ENV PROJECT_NAME="headscale-ui"
# URL for the github/git location
ENV PROJECT_URL="https://github.com/gurucomputing/headscale-ui"
# autostart the dev command on boot?
ENV AUTOSTART=true
ENV AUTOSTART="false"
# command to run in the background on startup
ENV DEV_COMMAND="npm run dev"
@ -49,8 +44,8 @@ RUN chmod -R 755 scripts
RUN /staging/scripts/1-image-build.sh
# set to the non-root user
USER node
USER 1000:1000
WORKDIR /data
ENTRYPOINT /bin/sh /staging/scripts/2-initialise.sh
ENTRYPOINT /bin/sh /staging/scripts/2-initialise.sh

View file

@ -1,44 +1,22 @@
#!/bin/sh
# script environment
# turn on bash logging
set -x
# turn on bash logging, exit on error
set -ex
# script variables
OPENVSCODE_URL="https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v$OPENVSCODE_VERSION/openvscode-server-v$OPENVSCODE_VERSION-linux-x64.tar.gz"
OPENVSCODE_RELEASE="openvscode-server-v$OPENVSCODE_VERSION-linux-x64"
CADDY_URL="https://caddyserver.com/api/download?os=linux&arch=amd64"
# # create a non-root user. Not needed for node image
# useradd -m -d /data/home dev-user
# install dependencies
# tmux used for monitoring secondary processes
# sudo for running specific commands as root
apt-get update
apt-get install -y tmux sudo
# set the default shell to the chosen shell
usermod --shell ${SHELL} node
# set new home directory
mkdir -p /data/home
usermod -d /data/home node
# Add the ability to set file permissions on /data to the non-privileged user
echo "ALL ALL=NOPASSWD: /bin/chown -R 1000\:1000 /data" >> /etc/sudoers
# install openVSCode
cd /opt
### Download Open VSCode
curl -LJO "$OPENVSCODE_URL"
### Extract and move into directory
tar -xzf "$OPENVSCODE_RELEASE.tar.gz"
mv $OPENVSCODE_RELEASE openvscode-server
rm -f "$OPENVSCODE_RELEASE.tar.gz"
### download caddy
curl -LJO "$CADDY_URL"
chmod +x caddy_linux_amd64
mv caddy_linux_amd64 /usr/bin/caddy
# create data and home directories
mkdir -p /data/home
# install dependencies
/staging/scripts/install-container-dependencies.sh
/staging/scripts/install-openvscode-server.sh
# set tmux to use mouse scroll
echo "set -g mouse on" > /data/home/.tmux.conf

View file

@ -14,7 +14,7 @@ then
echo "---- Forcing File Permissions to the node user ----"
sudo /bin/chown -R 1000:1000 /data
else
echo "---- You are not running as the node user AND your file permissions don't match your user ---\n"
echo "---- You are not running as the default non-root user AND your file permissions don't match your user ---\n"
echo "---- You may need to manually fix your file permissions ----"
fi
fi
@ -40,7 +40,6 @@ then
cd /data
git clone ${PROJECT_URL}
cd ${PROJECT_NAME}
npm install
else
cd /data/${PROJECT_NAME}
fi

View file

@ -0,0 +1,8 @@
# install dependencies
# tmux used for monitoring secondary processes
# sudo for running specific commands as root
# ncdu file navigation
# caddy web server
apt-get update
apt-get install -y --no-install-recommends tmux sudo git ncdu caddy
apt-get clean

View file

@ -0,0 +1,15 @@
# script variables
OPENVSCODE_VERSION="1.103.1"
OPENVSCODE_URL="https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v$OPENVSCODE_VERSION/openvscode-server-v$OPENVSCODE_VERSION-linux-x64.tar.gz"
OPENVSCODE_RELEASE="openvscode-server-v$OPENVSCODE_VERSION-linux-x64"
# install openVSCode
cd /opt
### Download Open VSCode
curl -LJO "$OPENVSCODE_URL"
### Extract and move into directory
tar -xzf "$OPENVSCODE_RELEASE.tar.gz"
mv $OPENVSCODE_RELEASE openvscode-server
rm -f "$OPENVSCODE_RELEASE.tar.gz"

View file

@ -1,10 +1,10 @@
FROM node:lts AS build
# arguments
ARG VERSION="master"
# Branch to check out
ARG CHECKOUT_BRANCH="master"
FROM node:lts AS build
#environment variables
ENV PROJECT_NAME="headscale-ui"
# URL for the github/git location
@ -37,8 +37,8 @@ ENV PROJECT_NAME="headscale-ui"
# URL for the github/git location
ENV PROJECT_URL="https://github.com/gurucomputing/headscale-ui"
# Ports that caddy will run on
ENV HTTP_PORT="80"
ENV HTTPS_PORT="443"
ENV HTTP_PORT="8080"
ENV HTTPS_PORT="8443"
# Production Web Server port. Runs a self signed SSL certificate
EXPOSE 443

View file

@ -2,9 +2,8 @@
set -x
# add dependencies
# jq for parsing version information
# git for cloning the repository
apk add --no-cache jq git
apk add --no-cache git
#clone the project
git clone ${PROJECT_URL} ${PROJECT_NAME}
@ -15,7 +14,6 @@ git checkout ${CHECKOUT_BRANCH}
npm install
# inject the version number
VERSION=$(jq -r '.version' package.json)
sed -i "s/insert-version/${VERSION}/g" ./src/routes/settings.html/+page.svelte
# build the project

View file

@ -1,25 +0,0 @@
{
http_port 80
https_port 443
}
https://headscale-test.local {
tls internal
reverse_proxy /web* https://headscale-test-frontend {
transport http {
tls_insecure_skip_verify
}
}
reverse_proxy * http://headscale-test-backend:8080
}
:80 {
reverse_proxy /web* https://headscale-test-frontend {
transport http {
tls_insecure_skip_verify
}
}
reverse_proxy * http://headscale-test-backend:8080
}

View file

@ -1,42 +0,0 @@
services:
headscale-test-backend:
image: headscale/headscale:latest-alpine
container_name: headscale-test-backend
security_opt:
- label:disable
# volumes:
# - ./container-config:/etc/headscale
# - ./container-data/data:/var/lib/headscale
entrypoint: |
sh -c "mkdir -p /var/lib/headscale;
mkdir -p /etc/headscale;
touch /var/lib/headscale/db.sqlite;
wget --output-document /etc/headscale/config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
sed -i 's|http://127.0.0.1:8080|https://headscale-test.local|g' /etc/headscale/config.yaml;
headscale serve"
restart: unless-stopped
networks:
headscale-ui-test-network:
headscale-test-frontend:
image: ghcr.io/gurucomputing/headscale-ui:latest
container_name: headscale-test-frontend
restart: unless-stopped
networks:
headscale-ui-test-network:
headscale-test-proxy:
image: headscale-test-proxy:latest
build: .
container_name: headscale-test-proxy
ports:
- 8080:80
restart: unless-stopped
networks:
headscale-ui-test-network:
aliases:
- headscale-test.local
networks:
headscale-ui-test-network:
external: true

View file

@ -1,27 +0,0 @@
FROM alpine:latest
# environment variables
ENV XDG_DATA_HOME=/data/
# Set the staging environment
WORKDIR /staging/scripts
WORKDIR /staging
# Copy across the scripts folder
COPY scripts/* ./scripts/
# Copy default caddy config from project root
COPY ./Caddyfile /staging/Caddyfile
# Set permissions for all scripts. We do not want normal users to have write
# access to the scripts
RUN chown -R 0:0 scripts
RUN chmod -R 755 scripts
# Build the image. This build runs as root
RUN /staging/scripts/1-image-build.sh
# Tell docker that all future commands should run as the appuser user
# USER appuser
WORKDIR /data
ENTRYPOINT /bin/sh /staging/scripts/2-initialise.sh

View file

@ -1,25 +0,0 @@
#!/bin/sh
set -x
# temporarily set the caddy home to staging
export XDG_DATA_HOME=/staging
# create the group and user
addgroup -S appgroup && adduser -D appuser -G appgroup
# install caddy plus dependencies
apk add --no-cache caddy nss-tools
# install tailscale
echo http://dl-2.alpinelinux.org/alpine/edge/community/ >> /etc/apk/repositories
apk add -U --no-cache tailscale
rc-update add tailscale
# do a dry run of caddy to install the certificates
caddy start
caddy trust -adapter caddyfile -config /staging/Caddyfile
caddy stop
# set the caddy directory to the non-root user
# commented out for now as we need root anyway for tailscale
# chown -R 1000:1000 /staging/caddy

View file

@ -1,23 +0,0 @@
#!/bin/sh
#----#
# placeholder for testing
# while true; do sleep 1; done
#----#
# copy everything from staging
if [ ! -f /data/Caddyfile ];
then
echo "no Caddyfile detected, copying across default config"
cp /staging/Caddyfile /data/Caddyfile
fi
if [ ! -f /data/caddy ];
then
echo "no caddy directory detected, copying across default config"
cp -r /staging/caddy /data/caddy
fi
# start caddy
echo "Starting Caddy"
/usr/sbin/caddy run --adapter caddyfile --config /data/Caddyfile

View file

@ -1,38 +0,0 @@
services:
headscale-worker-1:
image: headscale-test-proxy:latest
container_name: headscale-worker-1
restart: unless-stopped
networks:
headscale-ui-test-network:
entrypoint: |
sh -c "tailscaled --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055 &
tailscale up --authkey=$PREAUTH_KEY --login-server=https://headscale-test.local;
/etc/init.d/tailscale start
while true; do sleep 1; done"
headscale-worker-2:
image: headscale-test-proxy:latest
container_name: headscale-worker-2
restart: unless-stopped
networks:
headscale-ui-test-network:
entrypoint: |
sh -c "tailscaled --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055 &
tailscale up --authkey=$PREAUTH_KEY --login-server=https://headscale-test.local;
/etc/init.d/tailscale start
while true; do sleep 1; done"
headscale-worker-3:
image: headscale-test-proxy:latest
container_name: headscale-worker-3
restart: unless-stopped
networks:
headscale-ui-test-network:
entrypoint: |
sh -c "tailscaled --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055 &
tailscale up --authkey=$PREAUTH_KEY --login-server=https://headscale-test.local --advertise-routes=10.30.10.1/32,10.30.10.2/32,10.30.10.3/32;
/etc/init.d/tailscale start
while true; do sleep 1; done"
networks:
headscale-ui-test-network:
external: true

View file

@ -13,7 +13,7 @@ services:
pull_policy: always
container_name: headscale
restart: unless-stopped
command: headscale serve
command: serve
volumes:
- ./headscale/config:/etc/headscale
- ./headscale/data:/var/lib/headscale
@ -30,7 +30,7 @@ services:
labels:
- traefik.enable=true
- traefik.http.routers.headscale-ui-rtr.rule=PathPrefix(`/web`) # you might want to add: && Host(`your.domain.name`)"
- traefik.http.services.headscale-ui-svc.loadbalancer.server.port=80
- traefik.http.services.headscale-ui-svc.loadbalancer.server.port=8080
traefik:
image: traefik:latest
@ -81,4 +81,53 @@ Once all three services are running, set up Headscale and Headscale UI _by creat
location /web/ {
proxy_pass https://XXX.XXX.XXX.XXXX:port/web/;
}
```
```
# Nginx Example Configuration
From https://github.com/gurucomputing/headscale-ui/issues/71
```
map $http_upgrade $connection_upgrade {
default keep-alive;
'websocket' upgrade;
'' close;
}
server {
server_name headscale-01.example.com;
location /web {
alias /usr/local/www/headscale-ui;
index index.html;
}
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $server_name;
proxy_redirect http:// https://;
proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
}
listen 443 ssl;
ssl_certificate fullchain.pem;
ssl_certificate_key privkey.pem;
[...]
}
server {
if ($host = headscale-01.example.com) {
return 301 https://$host$request_uri;
}
server_name headscale-01.example.com;
listen 80;
return 404;
}
```

View file

@ -0,0 +1,27 @@
# Route Queries
Some routes offer additional behavior to a route when a `?` exists in the URL. These are called query string parameters or route queries. Route queries are used to modify the behavior of a route. Below are the available route queries.
## Devices
/devices.html
### Parameters
#### `?nodekey={nodekey of a pending device}`
When this parameter exists, it will automatically open the New Device form and pre-fill the Device Key input automatically. Everything right of the `=` is used as the value of the input.
Below is an example of how to set up a redirect in NGINX from the default headscale /register/{nodekey} URL to utilize this parameter:
```nginx
...
location /register/nodekey {
rewrite ^/register/(.*)$ /web/devices.html?nodekey=$1 redirect;
}
location /web {
...
```

4187
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"name": "headscale-ui",
"version": "2023.01.30-beta-1",
"version": "2025.08.23",
"scripts": {
"dev": "vite dev --https --port 443 --host 0.0.0.0",
"dev": "vite dev --port 8080 --host 0.0.0.0",
"build": "vite build",
"package": "vite package",
"preview": "vite preview --https --port 443 --host 0.0.0.0",
@ -13,23 +13,24 @@
"format": "prettier --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/adapter-static": "^1.0.0",
"@sveltejs/kit": "^1.0.0",
"@tailwindcss/typography": "github:tailwindcss/typography",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"autoprefixer": "^10.4.4",
"daisyui": "^2.19.0",
"fuse.js": "^6.6.2",
"postcss": "^8.4.12",
"postcss-load-config": "^4.0.1",
"prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^5.0.0",
"tailwindcss": "^3.0.23",
"typescript": "^4.7.2"
"@sveltejs/adapter-auto": "^4",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^3",
"@tailwindcss/typography": "^0",
"@vitejs/plugin-basic-ssl": "^1",
"autoprefixer": "^10",
"daisyui": "^4",
"fuse.js": "^7",
"postcss": "^8",
"postcss-load-config": "^5",
"prettier": "^3",
"prettier-plugin-svelte": "^3",
"svelte": "^4",
"svelte-check": "^3",
"svelte-preprocess": "^5",
"tailwindcss": "^3",
"typescript": "^5"
},
"type": "module"
}

View file

@ -8,7 +8,7 @@
}
.card-primary {
@apply grid grid-cols-1 divide-y p-2 max-w-screen-lg border mx-4 border-base-content rounded-md text-sm text-base-content shadow
@apply grid grid-cols-1 divide-y p-2 max-w-screen-lg mx-4 border-base-content rounded-md text-sm text-base-content shadow
}
.card-pending {

View file

@ -30,8 +30,9 @@
</script>
{#if visible}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
transition:slide
transition:slide|global
class="absolute alert text-lg left-1/2 transform -translate-x-1/2 justify-center shadow-lg max-w-lg"
on:keypress on:click={() => {
$alertStore = '';

View file

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { deviceSortStore, deviceSortDirectionStore, userSortStore, sortDirectionStore, themeStore, showACLPagesStore } from '$lib/common/stores.js';
import { deviceSortStore, deviceSortDirectionStore, userSortStore, sortDirectionStore, themeStore, showACLPagesStore} from '$lib/common/stores.js';
import { URLStore } from '$lib/common/stores.js';
import { APIKeyStore } from '$lib/common/stores.js';
import { preAuthHideStore } from '$lib/common/stores.js';

View file

@ -1,6 +1,6 @@
<script context="module" lang="ts">
import { APIKey, Device, PreAuthKey, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore } from '$lib/common/stores.js';
import { deviceStore, userStore, apiTestStore} from '$lib/common/stores.js';
import { sortDevices, sortUsers } from '$lib/common/sorting.svelte';
import { filterDevices, filterUsers } from './searching.svelte';
@ -40,7 +40,7 @@
});
await headscaleUsersResponse.json().then((data) => {
headscaleUsers = data.users
headscaleUsers = data.users;
// sort the users
headscaleUsers = sortUsers(headscaleUsers);
});
@ -51,13 +51,13 @@
filterUsers();
}
export async function editUser(currentUsername: string, newUsername: string): Promise<any> {
export async function editUser(currentUserId: string, newUsername: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/user/' + currentUsername + '/rename/' + newUsername;
let endpointURL = '/api/v1/user/' + currentUserId + '/rename/' + newUsername;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -152,12 +152,13 @@
}
export async function updateTags(deviceID: string, tags: string[]): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/machine/' + deviceID + '/tags';
let endpointURL = `/api/v1/node/${deviceID}/tags`;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -183,13 +184,13 @@
});
}
export async function removeUser(currentUsername: string): Promise<any> {
export async function removeUser(currentUserId: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/user/' + currentUsername;
let endpointURL = '/api/v1/user/' + currentUserId;
await fetch(headscaleURL + endpointURL, {
method: 'DELETE',
@ -245,12 +246,13 @@
}
export async function getDevices(): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for getting users
let endpointURL = '/api/v1/machine';
// endpoint url for getting devices
let endpointURL = `/api/v1/node`;
//returning variables
let headscaleDevices = [new Device()];
@ -281,7 +283,7 @@
});
await headscaleDeviceResponse.json().then((data) => {
headscaleDevices = data.machines;
headscaleDevices = data[`nodes`];
headscaleDevices = sortDevices(headscaleDevices);
});
// set the stores
@ -291,8 +293,6 @@
filterDevices();
}
export async function getAPIKeys(): Promise<APIKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
@ -329,7 +329,7 @@
return apiKeys;
}
export async function getPreauthKeys(userName: string): Promise<PreAuthKey[]> {
export async function getPreauthKeys(userID: string): Promise<PreAuthKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -341,7 +341,7 @@
let headscalePreAuthKey = [new PreAuthKey()];
let headscalePreAuthKeyResponse: Response = new Response();
await fetch(headscaleURL + endpointURL + '?user=' + userName, {
await fetch(headscaleURL + endpointURL + '?user=' + userID, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -367,7 +367,7 @@
return headscalePreAuthKey;
}
export async function newPreAuthKey(userName: string, expiry: string, reusable: boolean, ephemeral: boolean): Promise<any> {
export async function newPreAuthKey(userID: string, expiry: string, reusable: boolean, ephemeral: boolean): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -381,7 +381,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userName,
user: userID,
expiration: expiry,
reusable: reusable,
ephemeral: ephemeral
@ -401,7 +401,7 @@
});
}
export async function removePreAuthKey(userName: string, preAuthKey: string): Promise<any> {
export async function removePreAuthKey(userID: string, preAuthKey: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -416,7 +416,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userName,
user: userID,
key: preAuthKey
})
})
@ -434,15 +434,16 @@
});
}
export async function newDevice(key: string, userName: string): Promise<any> {
export async function newDevice(key: string, userId: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/machine/register';
let endpointURL = `/api/v1/node/register`;
await fetch(headscaleURL + endpointURL + '?user=' + userName + '&key=' + key, {
await fetch(headscaleURL + endpointURL + '?user=' + userId + '&key=' + key, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -463,20 +464,24 @@
});
}
export async function moveDevice(deviceID: string, user: string): Promise<any> {
export async function moveDevice(deviceID: string, userID: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/machine/' + deviceID + '/user?user=' + user;
let endpointURL = `/api/v1/node/${deviceID}/user`;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
}
},
body: JSON.stringify({
user: parseInt(userID)
})
})
.then((response) => {
if (response.ok) {
@ -493,12 +498,13 @@
}
export async function renameDevice(deviceID: string, name: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/machine/' + deviceID + '/rename/' + name;
let endpointURL = `/api/v1/node/${deviceID}/rename/${name}`;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -522,12 +528,13 @@
}
export async function removeDevice(deviceID: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for removing devices
let endpointURL = '/api/v1/machine/' + deviceID;
let endpointURL = `/api/v1/node/${deviceID}`;
await fetch(headscaleURL + endpointURL, {
method: 'DELETE',

View file

@ -1,71 +1,64 @@
export class Device {
public id: string = '';
public name: string = '';
public givenName: string = '';
public lastSeen: string = '';
public ipAddresses: string[] = []
public forcedTags: string[] = []
public validTags: string[] = []
public invalidTags: string[] = []
public user: { name: string } = { name: '' }
public id: string = '';
public name: string = '';
public givenName: string = '';
public lastSeen: string = '';
public ipAddresses: string[] = [];
public forcedTags: string[] = [];
public validTags: string[] = [];
public invalidTags: string[] = [];
public approvedRoutes: string[] = [];
public availableRoutes: string[] = [];
public subnetRoutes: string[] = [];
public user: User = new User();
public online?: boolean;
public constructor(init?: Partial<Device>) {
Object.assign(this, init);
}
public constructor(init?: Partial<Device>) {
Object.assign(this, init);
}
}
export class ACL {
public groups: {[key: string]: [string]} = {}
public groups: { [key: string]: [string] } = {};
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
}
export class Route {
// current (hs 18+) method of handling a route
advertised: boolean = true;
prefix: string = "";
enabled: boolean = false;
id: number = 0;
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
public constructor(init?: Partial<ACL>) {
Object.assign(this, init);
}
}
export class APIKey {
id: string = '';
prefix: string = '';
expiration: string = '';
createdAt: string = '';
lastSeen: string = '';
id: string = '';
prefix: string = '';
expiration: string = '';
createdAt: string = '';
lastSeen: string = '';
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
public constructor(init?: Partial<APIKey>) {
Object.assign(this, init);
}
}
export class PreAuthKey {
public user: string = '';
public id: string = '';
public key: string = '';
public createdAt: string = '';
public expiration: string = '';
public reusable: boolean = false;
public ephemeral: boolean = false;
public used: boolean = false;
public user: string = '';
public id: string = '';
public key: string = '';
public createdAt: string = '';
public expiration: string = '';
public reusable: boolean = false;
public ephemeral: boolean = false;
public used: boolean = false;
public constructor(init?: Partial<PreAuthKey>) {
Object.assign(this, init);
}
public constructor(init?: Partial<PreAuthKey>) {
Object.assign(this, init);
}
}
export class User {
public id: string = '';
public name: string = '';
public createdAt: string = '';
public constructor(init?: Partial<User>) {
Object.assign(this, init);
}
}
public id: string = '';
public name: string = '';
public email: string = '';
public createdAt: string = '';
public constructor(init?: Partial<User>) {
Object.assign(this, init);
}
}

View file

@ -31,7 +31,7 @@
<!-- let the page initialize before showing the nav bar -->
{#if componentLoaded}
<nav class="bg-base-200 flex shadow-xl w-14 h-screen sticky top-0" class:navCollapsed={$navExpanded == 'collapsed'} class:navExpanded={$navExpanded == 'expanded'} transition:fade>
<nav class="bg-base-200 flex shadow-xl w-14 h-screen sticky top-0" class:navCollapsed={$navExpanded == 'collapsed'} class:navExpanded={$navExpanded == 'expanded'} transition:fade|global>
<!-- links on top of sidebar -->
<div class="absolute top-0 w-full">
<button class="w-full nav-item" on:click={() => ($navExpanded == 'collapsed' ? ($navExpanded = 'expanded') : ($navExpanded = 'collapsed'))}>

View file

@ -7,10 +7,9 @@
export function filterUsers() {
// only run if we have search contents set
if (get(userSearchStore)) {
let options: Fuse.IFuseOptions<User> = {
let searcher = new Fuse(get(userStore), {
keys: ['id', 'name']
};
let searcher = new Fuse(get(userStore), options);
});
// search using the searchstore term, and take the resultant array contents and set it to userFilterStore
userFilterStore.set(searcher.search(get(userSearchStore)).map((a) => a.item));
@ -23,10 +22,9 @@
export function filterDevices() {
// only run if we have search contents set
if (get(deviceSearchStore)) {
let options: Fuse.IFuseOptions<Device> = {
let searcher = new Fuse(get(deviceStore), {
keys: ['id', 'givenName', 'name', 'forcedTags', 'validTags', 'user.name']
};
let searcher = new Fuse(get(deviceStore), options);
});
// search using the searchstore term, and take the resultant array contents and set it to userFilterStore
deviceFilterStore.set(searcher.search(get(deviceSearchStore)).map((a) => a.item));

View file

@ -1,14 +1,16 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { userStore, deviceStore } from '$lib/common/stores';
import { userStore } from '$lib/common/stores';
import { getDevices, newDevice } from '$lib/common/apiFunctions.svelte';
import { alertStore } from '$lib/common/stores.js';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { goto } from "$app/navigation";
// whether the new card html element is visible
export let newDeviceCardVisible = false;
export let newDeviceKey = '';
let newDeviceForm: HTMLFormElement;
let newDeviceKey = '';
let selectedUser = '';
let tabs = ['Default Configuration', 'With Preauth Keys', 'With OIDC'];
@ -20,8 +22,15 @@
.then((response) => {
newDeviceCardVisible = false;
newDeviceKey = '';
// refresh devices after editing
getDevices();
// Clear device key in url
if ($page.url.searchParams.get('nodekey')) {
$page.url.searchParams.delete('nodekey');
goto(`?${$page.url.searchParams.toString()}`);
}
})
.catch((error) => {
$alertStore = error;
@ -35,7 +44,7 @@
<!-- html -->
{#if newDeviceCardVisible == true}
<div in:fade out:fade={{ duration: newDeviceCardVisible ? 0 : 500 }} class="p-2 max-w-screen-lg border border-dashed border-base-content mx-4 rounded-md text-sm text-base-content shadow mb-10">
<div in:fade|global out:fade|global={{ duration: newDeviceCardVisible ? 0 : 500 }} class="p-2 max-w-screen-lg border border-dashed border-base-content mx-4 rounded-md text-sm text-base-content shadow mb-10">
<div class="tabs">
{#each tabs as tab, index}
<button class="tab tab-bordered h-fit w-1/3" class:tab-active={activeTab == index} on:click={() => (activeTab = index)}>{tab}</button>
@ -43,20 +52,20 @@
</div>
<!-- Default Configuration -->
{#if activeTab == 0}
<div in:fade class="m-2">
<div in:fade|global class="m-2">
<p>Install Tailscale with the client pointing to your domain (see <a target="_blank" rel="noreferrer" class="link link-primary" href="https://github.com/juanfont/headscale/tree/main/docs">headscale client documentation</a>). Log in using the tray icon, and your browser should give you instructions with a key.</p>
<div class="m-2"><code>headscale -u USER nodes register --key &lt;your device key&gt;</code></div>
<div class="my-2"><p>Copy the key below:</p></div>
<form class="flex flex-wrap" bind:this={newDeviceForm} on:submit|preventDefault={newDeviceAction}>
<div class="flex-none mr-4">
<label class="block text-secondary text-sm font-bold mb-2" for="text">Device Key</label>
<input bind:value={newDeviceKey} minlength="54" class="card-input" type="text" required placeholder="******************" />
<input bind:value={newDeviceKey} class="card-input" type="text" required placeholder="******************" />
</div>
<div class="flex-none">
<label class="block text-secondary text-sm font-bold mb-2" for="select">Select User</label>
<select class="card-select mr-3" required bind:value={selectedUser}>
{#each $userStore as user}
<option>{user.name}</option>
<option>{user.name.length > 1 ? user.name : user.email}</option>
{/each}
</select>
</div>
@ -82,7 +91,7 @@
{/if}
<!-- With Preauth Keys -->
{#if activeTab == 1}
<div in:fade class="m-2">
<div in:fade|global class="m-2">
<p>Preauth Keys provide the capability to install tailscale using a pre-registered key (see the <code class="bg-base-200 px-2 rounded">--authkey</code> flag in the <a target="_blank" rel="noreferrer" class="link link-primary" href="https://tailscale.com/kb/1080/cli/">tailscale command line documentation</a>)</p>
<p>Preauth Keys are especially useful for deploying headscale as an always-on VPN (see the <code class="bg-base-200 px-2 rounded">TS_UNATTENDEDMODE</code> install option in the <a target="_blank" rel="noreferrer" class="link link-primary" href="https://tailscale.com/kb/1189/install-windows-msi/">tailscale documentation</a>) or router-level VPN.</p>
<div class="bg-base-200 p-4 m-2 rounded-xl">
@ -95,7 +104,7 @@
{/if}
<!-- With OIDC -->
{#if activeTab == 2}
<div in:fade class="m-2">
<div in:fade|global class="m-2">
<p>OIDC provides the ability to register an external authentication provider (such as <a target="_blank" rel="noreferrer" class="link link-primary" href="https://www.keycloak.org/">keycloak</a>) to authenticate devices to headscale.</p>
<br />
<p>Configure Headscale to register with an authentication provider (see <a target="_blank" rel="noreferrer" class="link link-primary" href="https://github.com/juanfont/headscale/blob/main/config-example.yaml">headscale configuration documentation</a>). Once configured, successfully authenticated devices will automatically self-register</p>

View file

@ -20,6 +20,21 @@
} else if (timeDifference < 86400) {
return 'bg-warning';
}
return 'bg-error';
}
// return button colour based on online status
function onlineBackground(online: boolean) {
return online ? 'bg-success' : 'bg-error';
}
function getBadgeColour(date: Date, online?: boolean) {
if (online !== undefined) {
return onlineBackground(online);
}
return timeDifference(date);
}
// returns time last seen in human readable format
@ -57,11 +72,16 @@
}
</script>
<div class="card-primary">
<div on:keypress on:click={() => (cardExpanded = !cardExpanded)} class="flex">
<div class="card-primary bg-base-200">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:keypress on:click={() => (cardExpanded = !cardExpanded)} class="flex items-center">
<span class="min-w-64 w-1/2 font-bold">
{#if cardEditing == false}
<span class="badge badge-xs tooltip {timeDifference(new Date(device.lastSeen))}" data-tip={timeSince(new Date(device.lastSeen))} /> {device.id}: {device.givenName}
{#if device.online}
<span class="badge badge-xs tooltip {getBadgeColour(new Date(device.lastSeen), device.online)}" data-tip=online /> {device.id}: {device.givenName}
{:else}
<span class="badge badge-xs tooltip {getBadgeColour(new Date(device.lastSeen), device.online)}" data-tip={timeSince(new Date(device.lastSeen))} /> {device.id}: {device.givenName}
{/if}
{/if}
<RenameDevice bind:cardEditing {device} />
</span>
@ -85,7 +105,7 @@
</div>
{#if cardExpanded}
<!-- we put a conditional on the outro transition so page changes do not trigger the animation -->
<div in:slide out:slide={{ duration: cardExpanded ? 0 : 500 }} class="pt-2 pl-2">
<div in:slide|global out:slide|global={{ duration: cardExpanded ? 0 : 500 }} class="mt-2 pt-2 pl-2">
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<tbody>

View file

@ -1,67 +1,18 @@
<script lang="ts">
import { getDeviceRoutes, modifyDeviceRoutes } from './DeviceRoutesAPI.svelte';
import { Device, Route } from '$lib/common/classes';
import { onMount } from 'svelte';
import { alertStore } from '$lib/common/stores';
import { Device } from '$lib/common/classes';
import DeviceRoute from './DeviceRoutes/DeviceRoute.svelte';
export let device = new Device();
let routesList: Route[] = [];
let routeID = 0;
onMount(async () => {
getDeviceRoutesAction();
});
function getDeviceRoutesAction() {
getDeviceRoutes(device.id)
.then((routes) => {
routesList = routes;
})
.catch((error) => {
$alertStore = error;
});
}
function modifyDeviceRoutesAction() {
modifyDeviceRoutes(device.id, routesList, routeID)
.then((response) => {
getDeviceRoutesAction();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
<th>Device Routes</th>
<td
><ul class="list-disc list-inside">
{#each routesList as route, index}
{#each device.availableRoutes as route}
<li>
{route.prefix}
{#if route.enabled}
<button
on:click={() => {
routesList[index].enabled = false;
routeID = route.id;
modifyDeviceRoutesAction();
}}
type="button"
class="btn btn-xs tooltip capitalize bg-success text-success-content mx-1"
data-tip="press to disable route">active</button
>
{:else}
<button
on:click={() => {
routesList[index].enabled = true;
routeID = route.id
modifyDeviceRoutesAction();
}}
type="button"
class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1"
data-tip="press to enable route">pending</button
>
{/if}
<DeviceRoute {route} {device}></DeviceRoute>
</li>
{/each}
</ul></td

View file

@ -0,0 +1,60 @@
<script>
import { getDevices } from '$lib/common/apiFunctions.svelte';
import { Device } from '$lib/common/classes';
import { alertStore } from '$lib/common/stores';
import { approveDeviceRoute } from './DeviceRouteAPI.svelte';
export let route = '';
export let device = new Device();
let routeDisabled = false;
function approveRouteAction() {
approveDeviceRoute(device.id, [...device.approvedRoutes, route])
.then(() => {
// refresh users after editing
getDevices();
})
.catch((error) => {
$alertStore = error;
});
}
function removeRouteAction() {
approveDeviceRoute(device.id, device.approvedRoutes.filter((r) => r !== route))
.then(() => {
// refresh users after editing
getDevices();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
{route}
{#if device.approvedRoutes.includes(route)}
<button
on:click={() => {
routeDisabled = true;
removeRouteAction();
routeDisabled = false;
}}
type="button"
class="btn btn-xs tooltip capitalize bg-success text-success-content mx-1">active</button
>
{:else}
<button
on:click={() => {
routeDisabled = true;
approveRouteAction();
routeDisabled = false;
}}
type="button"
class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1"
class:disabled={routeDisabled}
data-tip="click to enable route">pending</button
>
{/if}
{#if device.subnetRoutes.includes(route)}
<button type="button" class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1">subnet</button>
{/if}

View file

@ -0,0 +1,34 @@
<script context="module" lang="ts">
export async function approveDeviceRoute(deviceID: string, routes: string[]): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let endpointURL = `/api/v1/node/${deviceID}/approve_routes`;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
routes: routes
})
})
.then((response) => {
if (response.ok) {
// return the api data
return response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
</script>

View file

@ -1,83 +0,0 @@
<script context="module" lang="ts">
import type { Route } from '$lib/common/classes';
export async function getDeviceRoutes(deviceID: string): Promise<Route[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for getting users
let endpointURL = '/api/v1/machine/' + deviceID + '/routes';
//returning variables
let headscaleRouteList: Route[] = [];
let headscaleDeviceResponse: Response = new Response();
await fetch(headscaleURL + endpointURL, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
}
})
.then((response) => {
if (response.ok) {
// return the api data
headscaleDeviceResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
await headscaleDeviceResponse.json().then((data) => {
headscaleRouteList = data.routes;
});
return headscaleRouteList;
}
export async function modifyDeviceRoutes(deviceID: string, routeList: Route[], routeID: number): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let endpointURL = '';
routeList.forEach((route) => {
if (route.id == routeID) {
endpointURL = `/api/v1/routes/${routeID}/`;
if (route.enabled) {
endpointURL += 'enable';
} else {
endpointURL += 'disable';
}
}
});
//returning variables
let headscaleDeviceResponse: Response = new Response();
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
}
})
.then((response) => {
if (response.ok) {
// return the api data
headscaleDeviceResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
</script>

View file

@ -20,21 +20,19 @@
}
</script>
<span><NewDeviceTag {device}/></span>
<div class="flex gap-1">
<span><NewDeviceTag {device}/></span>
{#each device.forcedTags as tag}
<span class="mb-1 mr-1 btn btn-xs btn-primary normal-case">{tag.replace("tag:","")}
<!-- Cancel symbol -->
<button on:click|stopPropagation={() => {updateTagsAction(tag)}}
class="ml-1"
><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
</span>
{/each}
{#each device.validTags as tag}
<span class="mb-1 mr-1 btn btn-xs btn-secondary normal-case">{tag.replace("tag:","")}</span>
{/each}
{#each device.forcedTags as tag}
<span class="btn btn-xs btn-primary normal-case">{tag.replace("tag:","")}
<!-- Cancel symbol -->
<button on:click|stopPropagation={() => {updateTagsAction(tag)}}
class="ml-1"
><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
</span>
{/each}
</div>

View file

@ -13,6 +13,8 @@
tagList.push(`tag:${newTag}`);
// remove duplicates
tagList = [...new Set(tagList)];
// force lowercase
tagList = tagList.map(str => str.toLowerCase());
updateTags(device.id, tagList)
.then((response) => {
@ -39,7 +41,7 @@
<!-- svelte-ignore a11y-autofocus -->
<form on:submit|preventDefault={updateTagsAction}>
<input bind:value={newTag} autofocus required class="bg-primary w-16" />
<button in:fade class="ml-1">
<button in:fade|global class="ml-1">
<!-- checkmark symbol -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
@ -48,7 +50,7 @@
<!-- Delete cancel symbol -->
<button
type="button"
in:fade
in:fade|global
on:click|stopPropagation={() => {
editingTag = false;
newTag = '';

View file

@ -6,7 +6,7 @@
export let device = new Device();
let deviceMoving = false;
let selectedUser = device.user.name;
let selectedUser = device.user.id;
function moveDeviceAction() {
moveDevice(device.id, selectedUser)
@ -39,17 +39,17 @@
<form on:submit|preventDefault={moveDeviceAction}>
<select class="card-select mr-3" required bind:value={selectedUser}>
{#each $userStore as user}
<option>{user.name}</option>
<option value={user.id}>{user.name.length > 1 ? user.name : user.email}</option>
{/each}
</select>
<!-- edit accept symbol -->
<button in:fade class=""
<button in:fade|global class=""
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<!-- edit cancel symbol -->
<button type="button" in:fade on:click|stopPropagation={() => (deviceMoving = false)}
<button type="button" in:fade|global on:click|stopPropagation={() => (deviceMoving = false)}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -29,16 +29,16 @@
>
{:else}
<!-- Delete Warning -->
<span in:fade class="font-bold text-red-400">Deleting {device.name}. Confirm </span>
<span in:fade|global class="font-bold text-red-400">Deleting {device.name}. Confirm </span>
<!-- Delete confirm symbol -->
<button in:fade on:click|stopPropagation={() => removeDeviceAction()}
<button in:fade|global on:click|stopPropagation={() => removeDeviceAction()}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<span in:fade class="font-bold text-red-400">or Cancel </span>
<span in:fade|global class="font-bold text-red-400">or Cancel </span>
<!-- Delete cancel symbol -->
<button in:fade on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
<button in:fade|global on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -41,15 +41,15 @@
{:else}
<form bind:this={editUserForm} on:submit|preventDefault={renameDeviceAction}>
<!-- Input has to be lower case, but we will force lower case on submit -->
<input in:slide on:click|stopPropagation bind:value={newDeviceName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
<input in:slide|global on:click|stopPropagation bind:value={newDeviceName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
<!-- edit accept symbol -->
<button in:fade on:click|stopPropagation class=""
<button in:fade|global on:click|stopPropagation class=""
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<!-- edit cancel symbol -->
<button type="button" in:fade on:click|stopPropagation={() => (cardEditing = false)}
<button type="button" in:fade|global on:click|stopPropagation={() => (cardEditing = false)}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -7,7 +7,7 @@
<div class="inline-block"><h1 class="text-2xl bold text-primary mb-4">Developer Flags<input type="checkbox" class="toggle toggle-sm tooltip ml-2 align-middle" data-tip="To enable development features. Only check this if you're a developer or like being confused" bind:checked={showDevSettings} /></h1></div>
{#if showDevSettings}
<div in:fade>
<div in:fade|global>
<h2 class="text-xl bold text-secondary mb-2 ml-2">ACL Pages <input bind:checked={$showACLPagesStore} type="checkbox" class="toggle toggle-sm ml-2 align-middle" /></h2>
</div>
{/if}

View file

@ -47,7 +47,7 @@
{/if}
</label>
<div class="flex relative">
<input bind:value={$APIKeyStore} {...{ type: apiKeyInputState }} minlength="54" maxlength="54" class="form-input" disabled='{apiStatus == 'succeeded'}' required placeholder="******************" />
<input bind:value={$APIKeyStore} {...{ type: apiKeyInputState }} class="form-input" disabled='{apiStatus == 'succeeded'}' required placeholder="******************" />
<button
type="button"
class="absolute right-40"
@ -79,12 +79,12 @@
<button on:click={() => ClearServerSettings()} class="btn btn-sm btn-primary capitalize" type="button">Clear Server Settings</button>
<button on:click={() => TestServerSettings()} class="btn btn-sm btn-secondary capitalize" type="button">Test Server Settings</button>
{#if apiStatus === 'succeeded'}
<svg in:fly={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="green" stroke-width="2">
<svg in:fly|global={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="green" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/if}
{#if apiStatus === 'failed'}
<svg in:fly={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="red" stroke-width="2">
<svg in:fly|global={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="red" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}

View file

@ -30,10 +30,10 @@
<!-- html -->
{#if newUserCardVisible}
<div in:fade out:fade={{ duration: newUserCardVisible ? 0 : 500 }} class="card-pending">
<div in:fade|global out:fade|global={{ duration: newUserCardVisible ? 0 : 500 }} class="card-pending">
<form on:submit|preventDefault={newUserAction} class="relative" bind:this={newUserForm}>
<!-- Input has to be lower case, but we will force lower case on submit -->
<input bind:value={newUserName} class="card-input lowercase" required pattern="[a-zA-Z\-\.]+" placeholder="name" />
<input bind:value={newUserName} class="card-input lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
</form>
<div>
<button on:click={() => newUserAction()}

View file

@ -11,7 +11,8 @@
let cardExpanded = false;
</script>
<div in:fade class="card-primary">
<div in:fade|global class="card-primary bg-base-200">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:keypress on:click={() => (cardExpanded = !cardExpanded)} class="flex justify-between">
<div>
<EditUser {user} />
@ -34,10 +35,14 @@
</div>
{#if cardExpanded}
<!-- we put a conditional on the outro transition so page changes do not trigger the animation -->
<div in:slide out:slide={{ duration: cardExpanded ? 0 : 500 }} class="pt-2 pl-2">
<div in:slide|global out:slide|global={{ duration: cardExpanded ? 0 : 500 }} class="mt-2 pt-2 pl-2">
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<tbody>
<tr>
<th>Email</th>
<td>{user.email}</td>
</tr>
<tr>
<th>User Creation Date</th>
<td>{new Date(user.createdAt)}</td>

View file

@ -22,7 +22,7 @@
}
function getPreauthKeysAction() {
getPreauthKeys(user.name)
getPreauthKeys(user.id)
.then((keys) => {
keyList = keys;
})
@ -62,7 +62,9 @@
type="checkbox"
bind:checked={($preAuthHideStore)}
class="checkbox checkbox-xs text-base-content"
/><span
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
on:keypress on:click={() => {
$preAuthHideStore = !$preAuthHideStore
}}
@ -105,7 +107,7 @@
<!-- Allow ability to expire if not expired -->
{#if new Date(key.expiration).getTime() > new Date().getTime() && (!key.used || key.reusable)}
<!-- trash symbol -->
<button class="mr-2" on:click={() => {expirePreAuthKeyAction(user.name, key.key)}}
<button class="mr-2" on:click={() => {expirePreAuthKeyAction(user.id, key.key)}}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg></button

View file

@ -15,7 +15,7 @@
function NewPreAuthKeyAction() {
let formattedDate = new Date(expiry).toISOString();
newPreAuthKey(user.name, formattedDate, reusable, ephemeral)
newPreAuthKey(user.id, formattedDate, reusable, ephemeral)
.then(() => {
newPreAuthKeyShow = false;
getPreauthKeysAction();
@ -26,7 +26,7 @@
}
function getPreauthKeysAction() {
getPreauthKeys(user.name)
getPreauthKeys(user.id)
.then((keys) => {
keyList = keys;
})
@ -36,7 +36,7 @@
}
</script>
<div in:fade class="card-pending">
<div in:fade|global class="card-pending">
<form on:submit|preventDefault={NewPreAuthKeyAction}>
<table class="table table-compact w-full">
<tbody>

View file

@ -8,7 +8,7 @@
let cardDeleting = false;
function removeUserAction() {
removeUser(user.name)
removeUser(user.id)
.then((response) => {
cardDeleting = false;
// refresh users after editing
@ -29,16 +29,16 @@
>
{:else}
<!-- Delete Warning -->
<span in:fade class="font-bold text-red-400">Deleting {user.name}. Confirm </span>
<span in:fade|global class="font-bold text-red-400">Deleting {user.name}. Confirm </span>
<!-- Delete confirm symbol -->
<button in:fade on:click|stopPropagation={() => removeUserAction()}
<button in:fade|global on:click|stopPropagation={() => removeUserAction()}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<span in:fade class="font-bold text-red-400">or Cancel </span>
<span in:fade|global class="font-bold text-red-400">or Cancel </span>
<!-- Delete cancel symbol -->
<button in:fade on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
<button in:fade|global on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -16,7 +16,7 @@
function renameUserAction() {
if (editUserForm.reportValidity()) {
editUser(user.name, newUserName)
editUser(user.id, newUserName)
.then((response) => {
cardEditing = false;
// refresh users after editing
@ -32,7 +32,7 @@
</script>
{#if !cardEditing}
<span class="font-bold">{user.id}: {user.name}</span>
<span class="font-bold">{user.id}: {user.name.length > 1 ? user.name : user.email}</span>
<!-- edit symbol -->
<button type="button" on:click|stopPropagation={() => editingUser()} class="ml-2"
><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@ -42,15 +42,15 @@
{:else}
<form bind:this={editUserForm} on:submit|preventDefault={renameUserAction}>
<!-- Input has to be lower case, but we will force lower case on submit -->
<input in:slide on:click|stopPropagation bind:value={newUserName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z\-\.]+" placeholder="name" />
<input in:slide|global on:click|stopPropagation bind:value={newUserName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
<!-- edit accept symbol -->
<button in:fade on:click|stopPropagation class=""
<button in:fade|global on:click|stopPropagation class=""
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<!-- edit cancel symbol -->
<button type="button" in:fade on:click|stopPropagation={() => (cardEditing = false)}
<button type="button" in:fade|global on:click|stopPropagation={() => (cardEditing = false)}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -1,6 +1,7 @@
<!-- typescript -->
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { getDevices, getUsers } from '$lib/common/apiFunctions.svelte';
import { apiTestStore, deviceFilterStore, deviceStore } from '$lib/common/stores.js';
import CreateDevice from '$lib/devices/CreateDevice.svelte';
@ -10,6 +11,8 @@
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
let newDeviceKey = '';
let newDeviceCardVisible = false;
//
@ -22,6 +25,11 @@
// We define the meat of our script in onMount as doing so forces client side rendering.
// Doing so also does not perform any actions until components are initialized
onMount(async () => {
// Handle nodekey
newDeviceKey = $page.url.searchParams.get('nodekey') ?? ''
newDeviceCardVisible = newDeviceKey.length > 0 ? true : false
// update user list
getUsers();
// attempt to pull list of devices
@ -33,8 +41,8 @@
<!-- html -->
{#if componentLoaded}
<div in:fade>
<div in:fade class="px-4 pt-4">
<div in:fade|global>
<div in:fade|global class="px-4 pt-4">
<h1 class="text-2xl bold text-primary">Device View</h1>
</div>
{#if $apiTestStore === 'succeeded'}
@ -54,16 +62,18 @@
>
</table>
<CreateDevice bind:newDeviceCardVisible />
<CreateDevice bind:newDeviceCardVisible bind:newDeviceKey />
{#each $deviceStore as device}
{#if $deviceFilterStore.includes(device)}
<DeviceCard {device} />
{/if}
{/each}
<div class="flex flex-col gap-2">
{#each $deviceStore as device}
{#if $deviceFilterStore.includes(device)}
<DeviceCard {device} />
{/if}
{/each}
</div>
{/if}
{#if $apiTestStore === 'failed'}
<div in:fade class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<div in:fade|global class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<p>API test did not succeed.<br />Headscale might be down or API settings may need to be set<br />change server settings in the <a href="{base}/settings.html" class="link link-primary">settings</a> page</p>
</div>
{/if}

View file

@ -16,7 +16,7 @@
<body>
{#if showACLPagesStore}
<div hidden={!componentLoaded} in:fade class="px-4 py-4 w-4/5 max-w-screen-lg">
<div hidden={!componentLoaded} in:fade|global class="px-4 py-4 w-4/5 max-w-screen-lg">
<h1 class="text-2xl bold text-primary">Group View</h1>
</div>
{/if}

View file

@ -20,7 +20,7 @@
<!-- html -->
<body>
<div hidden={!componentLoaded} in:fade class="px-4 py-4 w-4/5 max-w-screen-lg">
<div hidden={!componentLoaded} in:fade|global class="px-4 py-4 w-4/5 max-w-screen-lg">
<ServerSettings />
<div class="p-4" />
<ThemeSettings />

View file

@ -31,7 +31,7 @@
<!-- html -->
{#if componentLoaded}
<div in:fade>
<div in:fade|global>
<div class="px-4 pt-4">
<h1 class="text-2xl bold text-primary">User View</h1>
</div>
@ -52,14 +52,17 @@
>
</table>
<CreateUser bind:newUserCardVisible />
{#each $userStore as user}
{#if $userFilterStore.includes(user)}
<UserCard {user} />
{/if}
{/each}
<div class="flex flex-col gap-2">
{#each $userStore as user}
{#if $userFilterStore.includes(user)}
<UserCard {user} />
{/if}
{/each}
</div>
{/if}
{#if $apiTestStore === 'failed'}
<div in:fade class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<div in:fade|global class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<p>API test did not succeed.<br />Headscale might be down or API settings may need to be set<br />change server settings in the <a href="{base}/settings.html" class="link link-primary">settings</a> page</p>
</div>
{/if}

View file

@ -1,11 +1,10 @@
// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import basicSsl from '@vitejs/plugin-basic-ssl'
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit(), basicSsl()],
plugins: [sveltekit()],
};
export default config;