mirror of
https://github.com/gurucomputing/headscale-ui.git
synced 2026-01-23 18:46:14 +00:00
Compare commits
128 commits
2022.08.13
...
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 | ||
|
|
b7d914f289 | ||
|
|
17de726347 | ||
|
|
f8733ade2d | ||
|
|
cd316c14af | ||
|
|
462125ef09 | ||
|
|
4e98db256d | ||
|
|
359fab1419 | ||
|
|
5fad4877a9 | ||
|
|
913ef22263 | ||
|
|
3e05713ae6 | ||
|
|
acb4211a89 | ||
|
|
9058a821c5 | ||
|
|
fb83efb950 | ||
|
|
62e362ebc6 | ||
|
|
271e1045a0 | ||
|
|
84c8ed5bd5 | ||
|
|
055a810463 | ||
|
|
8664048ffc | ||
|
|
02e243a96e | ||
|
|
56d75f9b04 | ||
|
|
a227c3ccb5 | ||
|
|
ebe5c13bad | ||
|
|
b3d93c09df | ||
|
|
dadacef625 | ||
|
|
2a12f3c6f5 | ||
|
|
aa6758b09a | ||
|
|
2a57342aef | ||
|
|
813d66c948 | ||
|
|
fe2844d5c8 | ||
|
|
f9468ef159 | ||
|
|
de3f3e76fc | ||
|
|
3792920b1b | ||
|
|
b823c50079 | ||
|
|
12e0891fa1 | ||
|
|
a843a192c0 | ||
|
|
db7b072822 | ||
|
|
95e2964191 | ||
|
|
3a1311f2cd | ||
|
|
b1bff1f8e5 | ||
|
|
add4bfd948 | ||
|
|
bfb2e7e7ce | ||
|
|
c8fdafe323 | ||
|
|
df2737e0ef | ||
|
|
438639804f | ||
|
|
a5ed48bda0 | ||
|
|
b29c79fe90 | ||
|
|
fa28d334e0 | ||
|
|
2fdfd824bf | ||
|
|
f6eade9e01 | ||
|
|
0dad084baa | ||
|
|
b0dedd3cb9 | ||
|
|
649b34aeec | ||
|
|
65e762b498 | ||
|
|
60697dcc6a | ||
|
|
42e117deb1 | ||
|
|
7d6f0203ae | ||
|
|
0203623702 | ||
|
|
2ccc4a4fa6 | ||
|
|
97237606f5 | ||
|
|
2d192b78b4 | ||
|
|
f03d3a07ce | ||
|
|
6cfa522eb6 | ||
|
|
9dd99e205a |
61 changed files with 2976 additions and 4011 deletions
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
** Supporting Details **
|
||||
Provide the following:
|
||||
* Browser Version:
|
||||
* 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
|
||||
20
.github/workflows/publish-dev-image.yaml
vendored
20
.github/workflows/publish-dev-image.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
82
.github/workflows/publish-release.yaml
vendored
82
.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,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
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
{
|
||||
skip_install_trust
|
||||
}
|
||||
:443 {
|
||||
:8080 {
|
||||
redir / /web
|
||||
uri strip_prefix /web
|
||||
tls internal {
|
||||
on_demand
|
||||
}
|
||||
file_server {
|
||||
root ./build
|
||||
}
|
||||
|
|
|
|||
53
README.md
53
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-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,36 @@ 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:
|
||||
|
||||
* The current stable version of headscale
|
||||
* Chrome/Chrome Mobile
|
||||
* Firefox/Firefox Mobile
|
||||
|
||||
Note that while mobile is checked for functionality, the web experience is not mobile optimised.
|
||||
|
||||
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 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`.
|
||||
|
||||
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
12
SECURITY.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: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
|
||||
|
|
|
|||
|
|
@ -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,8 +14,7 @@ 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.svelte
|
||||
sed -i "s/insert-version/${VERSION}/g" ./src/routes/settings.html/+page.svelte
|
||||
|
||||
# build the project
|
||||
npm run build
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
```
|
||||
|
|
|
|||
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 {
|
||||
|
||||
...
|
||||
```
|
||||
5456
package-lock.json
generated
5456
package-lock.json
generated
File diff suppressed because it is too large
Load diff
43
package.json
43
package.json
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "headscale-ui",
|
||||
"version": "2022.08.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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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 = '';
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { deviceSortStore, deviceSortDirectionStore, userSortStore, sortDirectionStore, themeStore } 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';
|
||||
|
|
@ -24,7 +24,8 @@
|
|||
|
||||
// stores URL and API key
|
||||
URLStore.set(localStorage.getItem('headscaleURL') || '');
|
||||
URLStore.subscribe((val) => localStorage.setItem('headscaleURL', val));
|
||||
// remove trailing slashes when storing the URL
|
||||
URLStore.subscribe((val) => localStorage.setItem('headscaleURL', val.replace(/\/+$/, '')));
|
||||
APIKeyStore.set(localStorage.getItem('headscaleAPIKey') || '');
|
||||
APIKeyStore.subscribe((val) => localStorage.setItem('headscaleAPIKey', val));
|
||||
|
||||
|
|
@ -32,5 +33,9 @@
|
|||
preAuthHideStore.set((localStorage.getItem('headscalePreAuthHide') || 'false') == 'true');
|
||||
preAuthHideStore.subscribe((val) => localStorage.setItem('headscalePreAuthHide', val ? 'true' : 'false'));
|
||||
|
||||
// dev setting stores
|
||||
showACLPagesStore.set((localStorage.getItem('showACLPages') || 'false') == 'true');
|
||||
showACLPagesStore.subscribe((val) => localStorage.setItem('showACLPages', val ? 'true' : 'false'));
|
||||
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
<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';
|
||||
|
||||
export async function getUsers(): Promise<any> {
|
||||
export async function getUsers() {
|
||||
// variables in local storage
|
||||
let headscaleURL = localStorage.getItem('headscaleURL') || '';
|
||||
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
|
||||
let sortKey = localStorage.getItem('headscaleUserSort') || '';
|
||||
let sortDirection = localStorage.getItem('headscaleUserSortDirection') || '';
|
||||
|
||||
// endpoint url for getting users
|
||||
let endpointURL = '/api/v1/namespace';
|
||||
let endpointURL = '/api/v1/user';
|
||||
|
||||
//returning variables
|
||||
let headscaleUsers = [new User()];
|
||||
|
|
@ -41,11 +40,9 @@
|
|||
});
|
||||
|
||||
await headscaleUsersResponse.json().then((data) => {
|
||||
if (sortDirection == 'ascending') {
|
||||
headscaleUsers = data.namespaces.sort((a: User, b: User) => (a[sortKey as keyof User] < b[sortKey as keyof User] ? -1 : 1));
|
||||
} else {
|
||||
headscaleUsers = data.namespaces.sort((a: User, b: User) => (a[sortKey as keyof User] > b[sortKey as keyof User] ? -1 : 1));
|
||||
}
|
||||
headscaleUsers = data.users;
|
||||
// sort the users
|
||||
headscaleUsers = sortUsers(headscaleUsers);
|
||||
});
|
||||
// Set the store
|
||||
apiTestStore.set('succeeded');
|
||||
|
|
@ -54,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',
|
||||
|
|
@ -155,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',
|
||||
|
|
@ -186,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',
|
||||
|
|
@ -221,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',
|
||||
|
|
@ -248,14 +246,13 @@
|
|||
}
|
||||
|
||||
export async function getDevices(): Promise<any> {
|
||||
|
||||
// variables in local storage
|
||||
let headscaleURL = localStorage.getItem('headscaleURL') || '';
|
||||
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
|
||||
let sortKey = localStorage.getItem('headscaleDeviceSort') || '';
|
||||
let sortDirection = localStorage.getItem('headscaleDeviceSortDirection') || '';
|
||||
|
||||
// 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()];
|
||||
|
|
@ -286,15 +283,8 @@
|
|||
});
|
||||
|
||||
await headscaleDeviceResponse.json().then((data) => {
|
||||
// flip the sort direction if based on lastSeen
|
||||
if(sortKey == "lastSeen") {
|
||||
sortDirection == 'ascending' ? sortDirection = 'descending' : sortDirection = 'ascending';
|
||||
}
|
||||
if (sortDirection == 'ascending') {
|
||||
headscaleDevices = data.machines.sort((a: Device, b: Device) => (a[sortKey as keyof Device] < b[sortKey as keyof Device] ? -1 : 1));
|
||||
} else {
|
||||
headscaleDevices = data.machines.sort((a: Device, b: Device) => (a[sortKey as keyof Device] > b[sortKey as keyof Device] ? -1 : 1));
|
||||
}
|
||||
headscaleDevices = data[`nodes`];
|
||||
headscaleDevices = sortDevices(headscaleDevices);
|
||||
});
|
||||
// set the stores
|
||||
apiTestStore.set('succeeded');
|
||||
|
|
@ -303,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') || '';
|
||||
|
|
@ -418,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') || '';
|
||||
|
|
@ -430,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',
|
||||
|
|
@ -456,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') || '';
|
||||
|
|
@ -470,7 +381,7 @@
|
|||
Authorization: `Bearer ${headscaleAPIKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namespace: userName,
|
||||
user: userID,
|
||||
expiration: expiry,
|
||||
reusable: reusable,
|
||||
ephemeral: ephemeral
|
||||
|
|
@ -490,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') || '';
|
||||
|
|
@ -505,7 +416,7 @@
|
|||
Authorization: `Bearer ${headscaleAPIKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namespace: userName,
|
||||
user: userID,
|
||||
key: preAuthKey
|
||||
})
|
||||
})
|
||||
|
|
@ -523,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',
|
||||
|
|
@ -552,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) {
|
||||
|
|
@ -582,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',
|
||||
|
|
@ -611,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,60 +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 Route {
|
||||
advertisedRoutes: string[] = [];
|
||||
enabledRoutes: string[] = [];
|
||||
export class ACL {
|
||||
public groups: { [key: string]: [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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { fade } from 'svelte/transition';
|
||||
import { writable } from 'svelte/store';
|
||||
import { base } from '$app/paths';
|
||||
import { showACLPagesStore } from './stores';
|
||||
|
||||
// navigation bar variables
|
||||
let navExpanded = writable('');
|
||||
|
|
@ -30,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 relative" 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'))}>
|
||||
|
|
@ -46,6 +47,18 @@
|
|||
</svg>
|
||||
<span class="indent-4">User View</span>
|
||||
</a>
|
||||
{#if $showACLPagesStore}
|
||||
<a href="{base}/groups.html" class="nav-item">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="indent-4">Group View</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="{base}/devices.html" class="nav-item">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
75
src/lib/common/sorting.svelte
Normal file
75
src/lib/common/sorting.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script context="module" lang="ts">
|
||||
import type { Device, User } from './classes';
|
||||
|
||||
export function sortUsers(users: User[]): User[] {
|
||||
let sortKey = localStorage.getItem('headscaleUserSort') || '';
|
||||
let sortDirection = localStorage.getItem('headscaleUserSortDirection') || '';
|
||||
let sortedUsers = users;
|
||||
|
||||
let collator = new Intl.Collator([], { numeric: true });
|
||||
if (sortDirection == 'ascending') {
|
||||
switch (sortKey) {
|
||||
case 'id':
|
||||
sortedUsers = users.sort((a: User, b: User) => collator.compare(a.id, b.id));
|
||||
break;
|
||||
case 'createdAt':
|
||||
sortedUsers = users.sort((a: User, b: User) => -collator.compare(a.createdAt, b.createdAt));
|
||||
break;
|
||||
case 'name':
|
||||
sortedUsers = users.sort((a: User, b: User) => collator.compare(a.name, b.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sortDirection == 'descending') {
|
||||
switch (sortKey) {
|
||||
case 'id':
|
||||
sortedUsers = users.sort((a: User, b: User) => -collator.compare(a.id, b.id));
|
||||
break;
|
||||
case 'createdAt':
|
||||
sortedUsers = users.sort((a: User, b: User) => collator.compare(a.createdAt, b.createdAt));
|
||||
break;
|
||||
case 'name':
|
||||
sortedUsers = users.sort((a: User, b: User) => -collator.compare(a.name, b.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sortedUsers;
|
||||
}
|
||||
|
||||
export function sortDevices(devices: Device[]): Device[] {
|
||||
let sortKey = localStorage.getItem('headscaleDeviceSort') || '';
|
||||
let sortDirection = localStorage.getItem('headscaleDeviceSortDirection') || '';
|
||||
let sortedDevices = devices;
|
||||
|
||||
let collator = new Intl.Collator([], { numeric: true });
|
||||
if (sortDirection == 'ascending') {
|
||||
switch (sortKey) {
|
||||
case 'id':
|
||||
sortedDevices = devices.sort((a: Device, b: Device) => collator.compare(a.id, b.id));
|
||||
break;
|
||||
case 'lastSeen':
|
||||
sortedDevices = devices.sort((a: Device, b: Device) => -collator.compare(a.lastSeen, b.lastSeen));
|
||||
break;
|
||||
case 'givenName':
|
||||
sortedDevices = devices.sort((a: Device, b: Device) => collator.compare(a.givenName, b.givenName));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sortDirection == 'descending') {
|
||||
switch (sortKey) {
|
||||
case 'id':
|
||||
sortedDevices = devices.sort((a: Device, b: Device) => -collator.compare(a.id, b.id));
|
||||
break;
|
||||
case 'lastSeen':
|
||||
sortedDevices = devices.sort((a: Device, b: Device) => collator.compare(a.lastSeen, b.lastSeen));
|
||||
break;
|
||||
case 'givenName':
|
||||
sortedDevices = devices.sort((a: Device, b: Device) => -collator.compare(a.givenName, b.givenName));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sortedDevices;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,27 +1,41 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { Device, User } from '$lib/common/classes';
|
||||
import { Device, User, ACL } from '$lib/common/classes';
|
||||
|
||||
//
|
||||
// localStorage Stores (global scope, saves to the browser)
|
||||
//
|
||||
|
||||
// used to store the value of an alert across all components
|
||||
export const alertStore = writable('');
|
||||
// used to determine if the API is functioning
|
||||
export const apiTestStore = writable('');
|
||||
// stores the theme
|
||||
export const themeStore = writable('');
|
||||
// stores URL and API Key
|
||||
export const URLStore = writable('');
|
||||
export const APIKeyStore = writable('');
|
||||
// stores sorting preferences
|
||||
export const deviceSortStore = writable('id');
|
||||
export const deviceSortDirectionStore = writable('ascending');
|
||||
export const userSortStore = writable('id');
|
||||
export const sortDirectionStore = writable('ascending');
|
||||
// stores preauth key preference
|
||||
export const preAuthHideStore = writable(false);
|
||||
|
||||
// Dev Setting Stores
|
||||
// Shows or Hides ACL Settings
|
||||
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()]);
|
||||
export const deviceStore = writable([new Device()]);
|
||||
export const deviceFilterStore = writable([new Device()]);
|
||||
// stores ACL object
|
||||
export const aclStore = writable(new ACL());
|
||||
// used to store the value of an alert across all components
|
||||
export const alertStore = writable('');
|
||||
// used to determine if the API is functioning
|
||||
export const apiTestStore = writable('');
|
||||
// stores search state
|
||||
export const userSearchStore = writable('');
|
||||
export const deviceSearchStore = writable('');
|
||||
// stores sorting preferences
|
||||
export const deviceSortStore = writable('id');
|
||||
export const deviceSortDirectionStore = writable('ascending');
|
||||
export const userSortStore = writable('id');
|
||||
export const sortDirectionStore = writable('ascending');
|
||||
export const deviceSearchStore = writable('');
|
||||
|
|
@ -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 <your device key></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 <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,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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -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.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<span class="flex">
|
||||
<button
|
||||
on:click={() => {
|
||||
on:keypress on:click={() => {
|
||||
sortAction();
|
||||
}}
|
||||
class="mx-1"
|
||||
|
|
|
|||
13
src/lib/settings/DevSettings.svelte
Normal file
13
src/lib/settings/DevSettings.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { showACLPagesStore } from '$lib/common/stores';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let showDevSettings = false;
|
||||
</script>
|
||||
|
||||
<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|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,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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
src/routes/+layout.js
Normal file
1
src/routes/+layout.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = true;
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<main data-theme={$themeStore} class="flex flex-col">
|
||||
<!-- initialize localStorage -->
|
||||
<Stores></Stores>
|
||||
<div class="flex h-screen">
|
||||
<div class="flex">
|
||||
<!-- sidebar -->
|
||||
<Nav />
|
||||
<!-- main window -->
|
||||
|
|
@ -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}
|
||||
23
src/routes/groups.html/+page.svelte
Normal file
23
src/routes/groups.html/+page.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
//
|
||||
// Imports
|
||||
//
|
||||
import { showACLPagesStore } from '$lib/common/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
// Set to true once component is initialized
|
||||
let componentLoaded = false;
|
||||
|
||||
onMount(async () => {
|
||||
componentLoaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<body>
|
||||
{#if showACLPagesStore}
|
||||
<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}
|
||||
</body>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
//
|
||||
// Imports
|
||||
//
|
||||
import DevSettings from '$lib/settings/DevSettings.svelte';
|
||||
import ServerSettings from '$lib/settings/ServerSettings.svelte';
|
||||
import ThemeSettings from '$lib/settings/ThemeSettings.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
|
@ -19,12 +20,14 @@
|
|||
|
||||
<!-- 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 />
|
||||
<div class="p-4" />
|
||||
<h1 class="text-2xl bold text-primary mb-4">Version</h1>
|
||||
<b>insert-version</b>
|
||||
<div class ="p-4"></div>
|
||||
<DevSettings></DevSettings>
|
||||
</div>
|
||||
</body>
|
||||
|
|
@ -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}
|
||||
|
|
@ -4,9 +4,6 @@ import preprocess from 'svelte-preprocess';
|
|||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
prerender: {
|
||||
default: true
|
||||
},
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
precompress: false
|
||||
|
|
|
|||
|
|
@ -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