mirror of
https://github.com/gurucomputing/headscale-ui.git
synced 2026-01-23 18:46:14 +00:00
Compare commits
65 commits
2023.01.30
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f79824bf | ||
|
|
3c22ed935d | ||
|
|
c48c0a54fb | ||
|
|
fbb8b2b968 | ||
|
|
baffa0024c | ||
|
|
15c4dd9575 | ||
|
|
7dd92ab4ad | ||
|
|
5e97c52303 | ||
|
|
9516a519b8 | ||
|
|
625647ed19 | ||
|
|
907ab6af57 | ||
|
|
adef58f27d | ||
|
|
38e0de2696 | ||
|
|
84aec5f45a | ||
|
|
b9cc07d9de | ||
|
|
2508507644 | ||
|
|
cf682bddbb | ||
|
|
f3c4a6afbf | ||
|
|
722eaa64ed | ||
|
|
f5df8899ee | ||
|
|
7bb7d1f7cb | ||
|
|
d7a7136897 | ||
|
|
4bc00fbbfc | ||
|
|
3bf4bfa2ea | ||
|
|
2631785f9f | ||
|
|
216a49ec52 | ||
|
|
21075b994a | ||
|
|
25ac133d86 | ||
|
|
b8042250ad | ||
|
|
3e5651189e | ||
|
|
7a1725daa6 | ||
|
|
0c56afac23 | ||
|
|
a387c3fc62 | ||
|
|
5698becc6f | ||
|
|
19d3dab7be | ||
|
|
9bc6a9346c | ||
|
|
ec79e4c908 | ||
|
|
64323e49c5 | ||
|
|
c16a381755 | ||
|
|
bcf9ef1e31 | ||
|
|
beb4a4b8fc | ||
|
|
297ae51eaa | ||
|
|
d340d7dd4c | ||
|
|
26ab0848da | ||
|
|
a2cd992778 | ||
|
|
a6e53bae9c | ||
|
|
f91f88cdda | ||
|
|
ae481f54cc | ||
|
|
8e54c94a8d | ||
|
|
89b113a223 | ||
|
|
c4650bd59b | ||
|
|
31943c23da | ||
|
|
2d9b1c03ef | ||
|
|
8ee2a6e9aa | ||
|
|
61fef46e48 | ||
|
|
eead9859f9 | ||
|
|
a9db179089 | ||
|
|
1f73a7bf8a | ||
|
|
88012147d3 | ||
|
|
61bd8ab49b | ||
|
|
dd944a45c1 | ||
|
|
fab13597d8 | ||
|
|
1a6b7a6626 | ||
|
|
e629197b36 | ||
|
|
63041fd673 |
53 changed files with 2381 additions and 2981 deletions
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
30
.github/workflows/publish-dev-image.yaml
vendored
30
.github/workflows/publish-dev-image.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
78
.github/workflows/publish-release.yaml
vendored
78
.github/workflows/publish-release.yaml
vendored
|
|
@ -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
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
{
|
||||
skip_install_trust
|
||||
}
|
||||
:443 {
|
||||
:8080 {
|
||||
redir / /web
|
||||
uri strip_prefix /web
|
||||
tls internal {
|
||||
on_demand
|
||||
}
|
||||
file_server {
|
||||
root ./build
|
||||
}
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -4,6 +4,9 @@ A web frontend for the [headscale](https://github.com/juanfont/headscale) Tailsc
|
|||

|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
15
docker/development/scripts/install-openvscode-server.sh
Normal file
15
docker/development/scripts/install-openvscode-server.sh
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
```
|
||||
|
|
|
|||
27
documentation/route_queries.md
Normal file
27
documentation/route_queries.md
Normal 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
4187
package-lock.json
generated
File diff suppressed because it is too large
Load diff
39
package.json
39
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))}>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 <your device key></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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
src/lib/devices/DeviceCard/DeviceRoutes/DeviceRoute.svelte
Normal file
60
src/lib/devices/DeviceCard/DeviceRoutes/DeviceRoute.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue