Compare commits

..

97 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
routerino
b7d914f289
fix typo in release version 2023-01-31 09:37:37 +11:00
fede
17de726347 Additional update 2023-01-30 20:34:37 +11:00
fede
f8733ade2d Adding NGINX Proxy Manager and config.yaml changes 2023-01-30 20:34:37 +11:00
routerino
cd316c14af
Update package.json 2023-01-30 20:33:50 +11:00
Christopher Bisset
462125ef09 add headscale version 2023-01-30 20:32:52 +11:00
Christopher Bisset
4e98db256d comment fixup 2023-01-30 20:32:52 +11:00
Christopher Bisset
359fab1419 removed legacy API handles for routes 2023-01-30 20:32:52 +11:00
Christopher Bisset
5fad4877a9 replace all instances of namespace with user 2023-01-30 20:32:52 +11:00
Niek van der Maas
913ef22263
Documentation improvements (#91)
fair enough, seems appropriate.
2023-01-20 19:02:00 +11:00
routerino
3e05713ae6
re-publish due to caching issues 2022-12-24 10:56:29 +11:00
routerino
acb4211a89
remove caching from docker publish 2022-12-24 10:56:02 +11:00
Christopher Bisset
9058a821c5 Merge branch 'master' of https://github.com/gurucomputing/headscale-ui 2022-12-24 10:49:45 +11:00
Christopher Bisset
fb83efb950 update version 2022-12-24 10:49:43 +11:00
routerino
62e362ebc6
fixed regression in legacy api (#88) 2022-12-24 10:49:14 +11:00
Christopher Bisset
271e1045a0 fix platform tags in github actions 2022-12-23 18:40:07 +11:00
Christopher Bisset
84c8ed5bd5 fix broken image reference for prod docker builds 2022-12-23 18:35:31 +11:00
Christopher Bisset
055a810463 tests and version update 2022-12-23 18:31:22 +11:00
Adam Kliś
8664048ffc
add support for docker layer caching and arm64/arm32v7 images (#81) 2022-12-23 18:27:06 +11:00
routerino
02e243a96e
84-rewrite-routes-api-to-handle-both-legacy-and-changed-APIs-for-routes (#85)
* fix crash on receiving malformed API object

* split route specific API functions into separate file

* add object transformation from legacy to current routes

* refactored modifyDeviceRoutes function to work with both legacy and normal API

* adjustments for legacy API

* minor edit
2022-12-23 18:24:19 +11:00
Christopher Bisset
56d75f9b04 updated dependencies due to breaking change 2022-12-22 17:16:37 +11:00
Christopher Bisset
a227c3ccb5 fix sveltekit warning about on:keypress 2022-12-16 18:10:35 +11:00
Christopher Bisset
ebe5c13bad Move node version to LTS 2022-12-16 18:04:48 +11:00
Christopher Bisset
b3d93c09df update sveltekit to 1.0 plus dependencies 2022-12-16 17:19:44 +11:00
routerino
dadacef625
Merge pull request #75 from gurucomputing/74-update-dependencies-before-release
74 update dependencies before release
2022-11-05 16:36:52 +11:00
Christopher Bisset
2a12f3c6f5 update version 2022-11-05 16:36:04 +11:00
Christopher Bisset
aa6758b09a updated dependencies, fixed warnings where applicable 2022-11-05 16:34:50 +11:00
routerino
2a57342aef
Merge pull request #73 from gurucomputing/72-update-development-image
72-update-development-image
2022-11-05 16:14:33 +11:00
Christopher Bisset
813d66c948 update openvscode version 2022-11-05 16:13:54 +11:00
routerino
fe2844d5c8
Update README.md 2022-10-13 09:59:06 +11:00
routerino
f9468ef159
Merge pull request #69 from gurucomputing/68-add-securitymd-documentation
added SECURITY.md
2022-10-13 09:58:28 +11:00
Christopher Bisset
de3f3e76fc added SECURITY.md 2022-10-13 09:57:04 +11:00
Christopher Bisset
3792920b1b fix issue with updated sveltekit settings 2022-09-13 18:34:10 +10:00
58 changed files with 2755 additions and 4232 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,15 +16,15 @@ 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
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to the Container registry
uses: docker/login-action@v1
@ -34,13 +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 }}
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,85 +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@v3
- name: Set up Docker Buildx
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-alpine
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.
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
}
@ -88,6 +87,17 @@ https://hs.yourdomain.com.au {
### Other Configurations
See [Other Configurations](/documentation/configuration.md) for further proxy examples, such as Traefik
## Versioning
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 |
## Troubleshooting
Make sure you are using the latest version of headscale. Headscale-UI is only tested against:
@ -100,9 +110,12 @@ 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
## Development
see [development](/documentation/development.md) for details

12
SECURITY.md Normal file
View file

@ -0,0 +1,12 @@
### Authentication and Authorization
In the current client-only format, the headscale API secret is stored within the browser's `localStorage` area. While `localStorage` is not an ideal location for secrets storage, it is currently the *only* possible method of securing data to a browser without some sort of backend facilitation.
What this means to *you* is that your API credentials are tied to your browser profile. If you open an incognito window or another browser profile, your API key will *not* carry across.
`localStorage` secrets have the possibility of being exploited by XSS. This exploitation avenue is mitigated by the static nature of the site: all pages are protected by a hashsum CSP (content security protection) that prevent modifying or adding javascript from other sources.
The future state for `heascale-ui` is not to rely on `localStorage` at all, but due to the architecture, any other methods require tighter integration with the core `headscale` product. For now this is not on the headscale roadmap.
## Vulnerability Disclosure
If any method of bypassing or leaking the `localStorage` secrets is found, please contact myself directly at `chris@gurucomputing.com.au` rather than opening an issue.

View file

@ -1,24 +1,19 @@
FROM node:latest
FROM node:lts
# Arguments
ARG OPENVSCODE_VERSION="1.69.2"
# 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:current-alpine 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

@ -7,11 +7,6 @@ Headscale-UI uses the `static` adapter built into svelte-kit, meaning that sever
### Client Side Design
All Headscale-UI features and functions should be client side only. *Any* backend features should be considered to be implemented in a separate backend. This can be the [Headscale](https://github.com/juanfont/headscale) application itself (preferred), or potentially implementing a Backend-as-a-Service API such as [Supabase](https://supabase.com/).
### Authentication and Authorization
In the current alpha format, the headscale API secret is stored within the browser's `localStorage` area. This method of credential storage is not ideal as localStorage can potentially be exploited by XSS (cross-site scripting) vulnerabilities. The long term goal is to integrate Headscale-UI into Headscale's OIDC authentication capabilities, but discovery is required to implement this feature (as well as cooperation from the upstream project).
For now, it is recommended that credentials only be saved on trusted computers and to use short API key expiries where possible.
## Dependencies
Dependencies are kept to a minimum and kept to large, actively maintained repositories. Great care should be taken before suggesting or adding any additional dependencies: headscale is a sensitive tool and attack surfaces must be kept minimal.

View file

@ -1,50 +1,133 @@
## Traefik Configuration
(Thanks @DennisGaida)
# Traefik Configuration
(Thanks [DennisGaida](https://github.com/DennisGaida) and [Niek](https://github.com/Niek))
Below is a complete docker-compose example for bringing up Traefik + headscale + headscale-ui. Run with: `docker-compose up -d` and headscale-ui will be accessible at <http://localhost/web>.
```yaml
version: '3.9'
services:
headscale:
image: headscale/headscale:latest
pull_policy: always
container_name: headscale
restart: unless-stopped
networks:
- traefik_proxy
command: headscale serve
command: serve
volumes:
- $DOCKERDIR/headscale/config:/etc/headscale
- ./headscale/config:/etc/headscale
- ./headscale/data:/var/lib/headscale
labels:
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.headscale-rtr.entrypoints=https"
- "traefik.http.routers.headscale-rtr.rule=Host(`hs.${DOMAIN_PUBLIC}`)"
## Middlewares
- "traefik.http.routers.headscale-rtr.middlewares=chain-no-auth@file"
## HTTP Services
- "traefik.http.routers.headscale-rtr.service=headscale-svc"
- "traefik.http.services.headscale-svc.loadbalancer.server.port=8080"
- traefik.enable=true
- traefik.http.routers.headscale-rtr.rule=PathPrefix(`/`) # you might want to add: && Host(`your.domain.name`)"
- traefik.http.services.headscale-svc.loadbalancer.server.port=8080
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
pull_policy: always
container_name: headscale-ui
restart: unless-stopped
networks:
- traefik_proxy
labels:
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.headscale_ui-rtr.entrypoints=https"
- "traefik.http.routers.headscale_ui-rtr.rule=Host(`hs.${DOMAIN_PUBLIC}`) && PathPrefix(`/web`)"
## Middlewares
- "traefik.http.routers.headscale_ui-rtr.middlewares=chain-no-auth@file"
## HTTP Services
- "traefik.http.routers.headscale_ui-rtr.service=headscale_ui-svc"
- "traefik.http.services.headscale_ui-svc.loadbalancer.server.port=443"
- "traefik.http.services.headscale_ui-svc.loadbalancer.server.scheme=https"
- "traefik.http.services.headscale_ui-svc.loadbalancer.serversTransport=disableSSLCheck@file"
- 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=8080
traefik:
image: traefik:latest
pull_policy: always
restart: unless-stopped
container_name: traefik
command:
- --api.insecure=true # remove in production
- --providers.docker
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --global.sendAnonymousUsage=false
ports:
- 80:80
- 443:443
- 8080:8080 # web UI (enabled with api.insecure)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/certificates:/certificates
```
and `traefik.yaml`
# NGINX Proxy Manager Configuration
If running Headscale and Headscale UI outside of a consolidated docker-compose file (as above), NGINX Proxy Manager is another easy way to run all three. NGINX Proxy Manager is an easy way to run Headscale and Headscale UI behind a reverse proxy that can manager SSL certs automatically. This assumes the following:
1. Headscale is set up on your Docker host (or another location you can route to) per the instructions [here](https://github.com/juanfont/headscale).
2. NGINX Proxy Manager is running and you can use it to generate SSL certificates. More information on NGINX Proxy Manager are [here](https://github.com/NginxProxyManager/nginx-proxy-manager).
Use this simplified docker-compose file to run headscale-ui:
```yaml
http:
serversTransports:
disableSSLCheck:
insecureSkipVerify: true
```
version: '3.5'
services:
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
restart: unless-stopped
container_name: headscale-ui
ports:
- 8443:443 # Use the port of your choice, but map it to 443 on the container
```
Once all three services are running, set up Headscale and Headscale UI _by creating a proxy host_:
1. Details: Enter the FQDN you will be using for Headscale and Headscale UI, and enable Websockets Support and Block Common Exploits.
2. SSL: Select or create the SSL certificate you'll be using for the entire FQDN where both will run. Make sure to enable Force SSL, HTTP/2 Support, HSTS and HSTS Subdomains.
3. Advanced: In the text box, add the following to manage the Headscale UI path properly:
```json
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 {
...
```

5670
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"name": "headscale-ui",
"version": "2022.09.13-beta",
"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,25 +13,24 @@
"format": "prettier --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-static": "^1.0.0-next.34",
"@sveltejs/kit": "next",
"@tailwindcss/typography": "github:tailwindcss/typography",
"@vitejs/plugin-basic-ssl": "^0.1.1",
"autoprefixer": "^10.4.4",
"daisyui": "^2.19.0",
"fuse.js": "^6.6.2",
"postcss": "^8.4.12",
"postcss-load-config": "^3.1.4",
"prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^4.10.7",
"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",
"dependencies": {
}
"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,10 +30,11 @@
</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:click={() => {
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, Route, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore } from '$lib/common/stores.js';
import { APIKey, Device, PreAuthKey, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore} from '$lib/common/stores.js';
import { sortDevices, sortUsers } from '$lib/common/sorting.svelte';
import { filterDevices, filterUsers } from './searching.svelte';
@ -10,7 +10,7 @@
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for getting users
let endpointURL = '/api/v1/namespace';
let endpointURL = '/api/v1/user';
//returning variables
let headscaleUsers = [new User()];
@ -40,7 +40,7 @@
});
await headscaleUsersResponse.json().then((data) => {
headscaleUsers = data.namespaces
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/namespace/' + 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/namespace/' + currentUsername;
let endpointURL = '/api/v1/user/' + currentUserId;
await fetch(headscaleURL + endpointURL, {
method: 'DELETE',
@ -218,7 +219,7 @@
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/namespace';
let endpointURL = '/api/v1/user';
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -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,8 +283,8 @@
});
await headscaleDeviceResponse.json().then((data) => {
headscaleDevices = data.machines
headscaleDevices = sortDevices(headscaleDevices)
headscaleDevices = data[`nodes`];
headscaleDevices = sortDevices(headscaleDevices);
});
// set the stores
apiTestStore.set('succeeded');
@ -291,85 +293,6 @@
filterDevices();
}
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 headscaleRoute = new 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) => {
headscaleRoute = data.routes;
});
return headscaleRoute;
}
export async function enableDeviceRoute(deviceID: string, routes: string[]): 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/' +
deviceID +
'/routes?' +
routes
.map(encodeURIComponent)
.map((route) => `routes=${route}`)
.join('&');
//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;
});
}
export async function getAPIKeys(): Promise<APIKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
@ -406,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') || '';
@ -418,7 +341,7 @@
let headscalePreAuthKey = [new PreAuthKey()];
let headscalePreAuthKeyResponse: Response = new Response();
await fetch(headscaleURL + endpointURL + '?namespace=' + userName, {
await fetch(headscaleURL + endpointURL + '?user=' + userID, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -444,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') || '';
@ -458,7 +381,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
namespace: userName,
user: userID,
expiration: expiry,
reusable: reusable,
ephemeral: ephemeral
@ -478,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') || '';
@ -493,7 +416,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
namespace: userName,
user: userID,
key: preAuthKey
})
})
@ -511,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 + '?namespace=' + userName + '&key=' + key, {
await fetch(headscaleURL + endpointURL + '?user=' + userId + '&key=' + key, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -540,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 + '/namespace?namespace=' + 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) {
@ -570,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',
@ -599,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,68 +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 namespace: { 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 {
advertisedRoutes: string[] = [];
enabledRoutes: string[] = [];
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 namespace: 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> = {
keys: ['id', 'givenName', 'name', 'forcedTags', 'validTags', 'namespace.name']
};
let searcher = new Fuse(get(deviceStore), options);
let searcher = new Fuse(get(deviceStore), {
keys: ['id', 'givenName', 'name', 'forcedTags', 'validTags', 'user.name']
});
// 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

@ -25,7 +25,6 @@ export const showACLPagesStore = writable(false);
//
// Normal Stores (global scope, saves until refresh)
//
// stores user and device data
export const userStore = writable([new User()]);
export const userFilterStore = writable([new User()]);

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">
<p>Install Tailscale with the client pointing to your domain (see <a target="_blank" 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 -n NAMESPACE nodes register --key &lt;your device key&gt;</code></div>
<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,9 +91,9 @@
{/if}
<!-- With Preauth Keys -->
{#if activeTab == 1}
<div in:fade 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" 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" class="link link-primary" href="https://tailscale.com/kb/1189/install-windows-msi/">tailscale documentation</a>) or router-level VPN.</p>
<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">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@ -95,10 +104,10 @@
{/if}
<!-- With OIDC -->
{#if activeTab == 2}
<div in:fade class="m-2">
<p>OIDC provides the ability to register an external authentication provider (such as <a target="_blank" class="link link-primary" href="https://www.keycloak.org/">keycloak</a>) to authenticate devices to headscale.</p>
<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" 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>
<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>
</div>
{/if}
</div>

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: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,58 +1,18 @@
<script lang="ts">
import { getDeviceRoutes, enableDeviceRoute } from '$lib/common/apiFunctions.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 = new Route();
onMount(async () => {
getDeviceRoutesAction();
});
function getDeviceRoutesAction() {
getDeviceRoutes(device.id)
.then((routes) => {
routesList = routes;
})
.catch((error) => {
$alertStore = error;
});
}
function enableDeviceRouteAction(route: string) {
enableDeviceRoute(device.id, [route,...routesList.enabledRoutes])
.then((response) => {
getDeviceRoutesAction();
})
.catch((error) => {
$alertStore = error;
});
}
function disableDeviceRouteAction(route: string) {
enableDeviceRoute(device.id, [...routesList.enabledRoutes].filter(v=>v!=route))
.then((response) => {
getDeviceRoutesAction();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
<th>Device Routes</th>
<td
><ul class="list-disc list-inside">
{#each routesList.advertisedRoutes as route}
{#each device.availableRoutes as route}
<li>
{route}
{#if routesList.enabledRoutes.includes(route)}
<button on:click={() => {disableDeviceRouteAction(route)}} 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={() => {enableDeviceRouteAction(route)}} 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

@ -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.namespace.name;
let selectedUser = device.user.id;
function moveDeviceAction() {
moveDevice(device.id, selectedUser)
@ -23,7 +23,7 @@
<td>
{#if !deviceMoving}
{device.namespace.name}
{device.user.name}
<!-- edit symbol -->
<button
on:click={() => {
@ -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

@ -14,7 +14,7 @@
<span class="flex">
<button
on:click={() => {
on:keypress on:click={() => {
sortAction();
}}
class="mx-1"

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,8 +11,9 @@
let cardExpanded = false;
</script>
<div in:fade class="card-primary">
<div on:click={() => (cardExpanded = !cardExpanded)} class="flex justify-between">
<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} />
</div>
@ -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;
})
@ -40,7 +40,7 @@
<th>
<div>Preauth Keys
<button
on:click={() => {
on:keypress on:click={() => {
newPreAuthKeyShow = !newPreAuthKeyShow;
}}
>
@ -62,8 +62,10 @@
type="checkbox"
bind:checked={($preAuthHideStore)}
class="checkbox checkbox-xs text-base-content"
/><span
on:click={() => {
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
on:keypress on:click={() => {
$preAuthHideStore = !$preAuthHideStore
}}
class="font-normal ml-2">Hide Expired/Used Keys</span
@ -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

1
src/routes/+layout.js Normal file
View file

@ -0,0 +1 @@
export const prerender = true;

View file

@ -4,7 +4,6 @@
import Alert from '$lib/common/Alert.svelte';
import Stores from '$lib/common/Stores.svelte';
import { themeStore } from '$lib/common/stores.js'
export const prerender = true;
// NOTE: the element that is using one of the theme attributes must be in the DOM on mount

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;