Compare commits

...

400 Commits
v0.0.4 ... main

Author SHA1 Message Date
David Gillespie
dbdb759a7e update tailwind
Some checks failed
Build / native (push) Has been cancelled
Build / nix (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
Automated / flake-inputs (push) Has been cancelled
2025-05-09 23:44:57 -06:00
github-actions[bot]
6716e5f0b0
chore: update flake.lock (#202)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 15:25:02 -04:00
github-actions[bot]
346b44ec69
chore: update flake.lock (#196)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-03 23:46:31 -04:00
George Ntoutsos
faa61b0f1d
feat: add filtering by container label for Docker integration (#194) 2025-04-24 19:03:33 -04:00
github-actions[bot]
6b63fe209f
chore: update flake.lock (#190)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-20 15:38:03 -04:00
github-actions[bot]
c8507cff7c
chore: update flake.lock (#180)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-15 11:00:11 -04:00
Federico Cerutti
b86f4461c0
feat: restart with headscale, update server.js path in systemd example (#184) 2025-04-10 09:08:43 -04:00
Aarnav Tale
77b510c927
docs: oops 2025-04-05 13:14:00 -04:00
Aarnav Tale
5adcb8c582
ci: skip ci build on certain paths 2025-04-05 12:07:31 -04:00
Aarnav Tale
524c5eb639
docs: docker version tags had an extra char 2025-04-05 11:58:32 -04:00
Aarnav Tale
2894c664d3
docs: fix tag typo 2025-04-05 11:56:59 -04:00
Aarnav Tale
5c2d08decd
chore: update dep tooling 2025-04-04 16:09:10 -04:00
Aarnav Tale
f2e8c6ae4c
chore: update dep tooling 2025-04-04 16:06:22 -04:00
Aarnav Tale
c3e727842a
docs: mention versioning policy 2025-04-04 15:44:16 -04:00
Aarnav Tale
96345ab0a6
chore: switch from nightly to pr based incubration 2025-04-04 10:19:37 -04:00
Aarnav Tale
fe2d7cb57a
chore: v0.5.10 2025-04-03 23:44:19 -04:00
Aarnav Tale
66c7d9a327
chore: v0.5.9 2025-04-03 23:24:13 -04:00
Aarnav Tale
f2747ada94
fix: suppress date and button hydration warnings 2025-04-03 16:54:12 -04:00
Aarnav Tale
0ad578e651
fix: prevent user rename if they are an oidc user 2025-04-03 16:25:30 -04:00
Aarnav Tale
8d1132606a
fix: add aria-label to radio groups 2025-04-03 16:22:52 -04:00
Aarnav Tale
5e332c4a5c
fix: filter out empty users in auth-keys, potential headscale bug 2025-04-03 16:10:39 -04:00
Aarnav Tale
69c6fc4847
chore: v0.5.8 2025-04-03 13:11:33 -04:00
Aarnav Tale
9b09b13b5f
feat: do not login loop if disable_api_key_login is true 2025-04-03 12:57:55 -04:00
Aarnav Tale
cf90f3dd32
chore: auto-create /var/lib/headscale in docker 2025-04-03 12:57:55 -04:00
Aarnav Tale
72c1174cb3 fix: make the hidden URL link aria compatible 2025-04-03 12:57:06 -04:00
Aarnav Tale
63bfad77ce fix: add api-error file 2025-04-03 12:57:06 -04:00
Aarnav Tale
41223b89b3 chore: update to headscale 0.25.1 2025-04-03 12:57:06 -04:00
Aarnav Tale
93d5c2ed29 chore: update actions to include next branch 2025-04-03 12:57:06 -04:00
Aarnav Tale
6a94e815f2 feat: improve error returning and parsing logic 2025-04-03 12:57:06 -04:00
Aarnav Tale
234020eec5 feat: support acl capabilities check 2025-04-03 12:57:06 -04:00
Aarnav Tale
58cc7b742c feat: make machine actions permission locked 2025-04-03 12:57:06 -04:00
Aarnav Tale
259d150fc4 feat: add capabilities enforcement on users 2025-04-03 12:57:06 -04:00
Aarnav Tale
5d3fada266 feat: add permissions check on dns page 2025-04-03 12:57:06 -04:00
Aarnav Tale
9d046a0cf6 fix: use new logger on oidc utils 2025-04-03 12:57:06 -04:00
Aarnav Tale
1fb084451d fix: fix integrations not loading 2025-04-03 12:57:06 -04:00
Aarnav Tale
d5fb8a2966 feat: support skipping onboarding 2025-04-03 12:57:06 -04:00
Aarnav Tale
7b1340c93e fix: make reassign dialog unactionable if editing owner 2025-04-03 12:57:06 -04:00
Aarnav Tale
16a8122f85 fix: only use basename in ssr build 2025-04-03 12:57:06 -04:00
Aarnav Tale
7d61ad50c4 feat: oops commit the user role change page 2025-04-03 12:57:06 -04:00
Aarnav Tale
103a826178 chore: remove unused code in the user overview 2025-04-03 12:57:06 -04:00
Aarnav Tale
090dec1ca6
chore: update docs 2025-04-02 17:03:58 -04:00
Aarnav Tale
8596a56375
chore: v0.5.7 2025-04-02 14:47:50 -04:00
Aarnav Tale
000ec620b4
fix: serve css and js assets correctly 2025-04-02 14:27:23 -04:00
Aarnav Tale
83a69792ea
chore: v0.5.6 2025-04-02 13:47:45 -04:00
Aarnav Tale
fee5f423a6
chore: update nix hash 2025-04-02 13:40:50 -04:00
Aarnav Tale
d698cf5478
fix: open file in a+ to avoid read issues 2025-04-02 13:40:01 -04:00
Aarnav Tale
80c987f383
feat: implement onboarding for non-registered users 2025-04-02 13:26:58 -04:00
Aarnav Tale
17d477bf0f
fix: join copied commands without a space 2025-04-02 13:25:33 -04:00
Aarnav Tale
5e5c7c4c7a
fix: remove unreleased feature and ignore invalid keys in config 2025-04-02 10:16:34 -04:00
Aarnav Tale
2e383ddabe
feat: reimplement user actions 2025-04-01 12:27:44 -04:00
Aarnav Tale
2299907932
chore: document user login state 2025-03-29 14:28:11 -04:00
Aarnav Tale
3771890f98
fix: disable live data fetching when a dialog is open 2025-03-29 14:27:56 -04:00
Aarnav Tale
bf02015dc7
feat: begin working on user auth 2025-03-29 14:12:15 -04:00
Aarnav Tale
8429b19c4a
docs: use git tags for bare metal 2025-03-29 14:10:46 -04:00
Aarnav Tale
9a5952adcb
fix: split email for username if preferred_username is unavailable 2025-03-29 12:16:38 -04:00
Aarnav Tale
222ac7a279
chore: make prefixes.v4/6 optional 2025-03-27 12:20:47 -04:00
Aarnav Tale
6f40f9cfac
feat: rework github actions 2025-03-24 16:49:54 -04:00
Aarnav Tale
457cbc45e6
fix: do not bundle ssr deps in dev 2025-03-24 16:26:47 -04:00
Aarnav Tale
aac8a9ef12 fix: update nix hash 2025-03-24 16:15:38 -04:00
Aarnav Tale
b8d22beb17 feat: bundle node_modules into the server 2025-03-24 16:15:38 -04:00
Aarnav Tale
cac64a6fbe chore: remove old server code 2025-03-24 16:15:38 -04:00
Aarnav Tale
5918d0e501 fix: allow hostname passthrough for hono node 2025-03-24 16:15:38 -04:00
Aarnav Tale
03acebb23e fix: env variables did not resolve in prod 2025-03-24 16:15:38 -04:00
Aarnav Tale
73ea35980d feat: switch agent fetching to the server side
this brings the benefit of fitting in the revalidator lifecycle we have
created via the useLiveData hook.
2025-03-24 16:15:38 -04:00
Aarnav Tale
9a1051b9af feat: reimplement websocket to use hono 2025-03-24 16:15:38 -04:00
Aarnav Tale
c066b3064d fix: make useLiveData a context that is pausable 2025-03-24 16:15:38 -04:00
Aarnav Tale
98d02bb595 chore: migrate patching to HeadscaleConfig 2025-03-24 16:15:38 -04:00
Aarnav Tale
2964ff295e fix: set config after loading 2025-03-24 16:15:38 -04:00
Aarnav Tale
b0a3f9d5fd chore: update deps 2025-03-24 16:15:38 -04:00
Aarnav Tale
34cfee7cff feat: reach an initial working stage 2025-03-24 16:15:38 -04:00
Aarnav Tale
8db323b63f style: use biome in zed settings 2025-03-24 16:15:38 -04:00
Aarnav Tale
08c25caca3 chore: add deps and include 2025-03-24 16:15:38 -04:00
Aarnav Tale
cbbd64e91a feat: initial server side systems 2025-03-24 16:15:38 -04:00
Aarnav Tale
48fc0f7ef3
fix: remove unnecessary title in templates 2025-03-18 12:25:19 -04:00
Aarnav Tale
23fd2bbda2
chore: update nix sha 2025-03-18 01:59:16 -04:00
Aarnav Tale
05837963c4
chore: FUNDING 2025-03-18 01:56:17 -04:00
Aarnav Tale
2a16115e69
feat: add issue templates 2025-03-18 01:54:36 -04:00
Aarnav Tale
5675ecdeac
chore: add 'community' things 2025-03-18 01:32:05 -04:00
Aarnav Tale
29424366a8
chore: v0.5.5 2025-03-18 00:46:54 -04:00
Aarnav Tale
c47346df52
fix: do not require authkey 2025-03-18 00:46:35 -04:00
Aarnav Tale
92dedf51aa
fix: ignore ws_agents if there are no agents connected 2025-03-18 00:43:52 -04:00
Aarnav Tale
8f7a85f47d
chore: v0.5.4 2025-03-18 00:34:49 -04:00
Aarnav Tale
ed1cf188dc
style: format github actions 2025-03-18 00:33:14 -04:00
Igor Ramazanov
03a7e51384
feat: add a nix flake (#132)
* feat: wip `nix`

Add initial code to be used when working with `nix` and `NixOS`.

* a Nix flake
* building a package
* a NixOS module

* feat: build `hp_agent` with `nix`

* feat: add `hp_agent` as a flake output

* feat: nix: start `headplane` after `headscale`

* feat: do not rely on `git` for versioning

It causes lots of pain when building with `nix` for a local `flake.nix`.

Not sure if it's a good general solution:
* now it requires a manual step of updating `./version` on each release.
* we're losing commit hash abbreviation, like `0.5.3-5-gbe5a291`

I guess, this can be fixed by installing git-pre-commit-hook, but even
then it'd be wrong, because the output of `git describe --tags --always`
won't be the same before and after commit.

* feat: include `hp_agent` to the NixOS module

* fix: version error message

* fix: use relative path imports in `nix`

* fix: NixOS module: generate `/etc/headplane/config.yaml` from `services.headplane.settings`

* fix: NixOS module: allow passing `bool` in `services.headplane.settings.*`

* fix: NixOS module: fix `/etc/headplane/config.yaml` generation

* docs: add Nix/NixOS docs

* feat: nix: read version from `package.json`

* fix: nix: fix `agent` env vars configuration

* feat: nix: add `services.headplane.agent.debug` option

* fix: delete unnecessary `version` file

* fix: nix: remove unnecessary `sed` substitutions

A left over from previous versions.
See: https://github.com/tale/headplane/issues/95#issue-2807487849

* feat: nix: do not hardcode `headplane-agent` configuration environment variables

To make the module more flexible and to reduce the dependency on the
config API.

* docs: improve `Nix` documentation

* Reflect recent changes.
* Link `Nix` in README

* feat: nix: setup Nix CI

* feat: nix: CI without depending on flakehub.com

* chore: begin bundling all deps into the server

* fix: loosen headscale config validation

* fix: navigate back to machines page on node deletion

* fix: slice off trailing slash if present on headscale url

* feat: switch to a central singleton handler

This also adds support for Headscale TLS installations

* fix: shamefully-hoist dependencies

* fix: handle localized number fields

* fix: revert dependency bundling for now

* chore: cleanup and remove from readme for now

---------

Co-authored-by: Aarnav Tale <aarnavtale@icloud.com>
2025-03-18 00:31:37 -04:00
Aarnav Tale
483f86c457
fix: revert dependency bundling for now 2025-03-18 00:24:25 -04:00
Aarnav Tale
a8a4d2a6f8
fix: handle localized number fields 2025-03-17 22:45:31 -04:00
Aarnav Tale
eef5bd0648
fix: shamefully-hoist dependencies 2025-03-17 22:25:41 -04:00
Aarnav Tale
6108de52e7
feat: switch to a central singleton handler
This also adds support for Headscale TLS installations
2025-03-17 22:21:16 -04:00
Aarnav Tale
43e06987ad
fix: slice off trailing slash if present on headscale url 2025-03-17 16:47:27 -04:00
Aarnav Tale
7741ab88bf
fix: navigate back to machines page on node deletion 2025-03-17 10:46:31 -04:00
Aarnav Tale
3bdbfdc033
fix: loosen headscale config validation 2025-03-17 10:44:13 -04:00
Aarnav Tale
e701e16112
chore: begin bundling all deps into the server 2025-03-17 10:39:38 -04:00
Aarnav Tale
79d83537d5
chore: track semver in package.json 2025-03-11 22:44:23 -04:00
Aarnav Tale
251c16ca48
fix: handle ws upgrade smoothly 2025-03-11 22:39:49 -04:00
Aarnav Tale
134b38ceda
chore: update agent var scheme 2025-03-11 22:39:05 -04:00
Aarnav Tale
b8fb1fa40f
chore: switch to x-headplane-tailnet-id 2025-03-11 22:35:52 -04:00
Aarnav Tale
951a96fad6
fix: set id ref to none to fetch agent data on first change 2025-03-11 22:35:02 -04:00
Aarnav Tale
5b9dc7cfc6
feat: ingest data from new agent system 2025-03-11 22:34:30 -04:00
Aarnav Tale
e36352b7f3
fix: define globals for context 2025-03-11 21:59:26 -04:00
Aarnav Tale
296e4d489c
fix: set a default for global dns nameservers 2025-03-11 21:57:40 -04:00
Aarnav Tale
c3ddac42a0
fix: memoize agent query params to prevent infinite refetching 2025-03-11 18:16:47 -04:00
Aarnav Tale
01f432cedc
feat: support oidc client_secret_path with env interpolation 2025-03-11 18:15:56 -04:00
Aarnav Tale
9c8a2c0120
feat: support oidc picture if available 2025-03-11 15:35:11 -04:00
Aarnav Tale
5f6460f42f
fix: use the userinfo endpoint for oidc 2025-03-11 15:29:45 -04:00
Aarnav Tale
d9314887fb
feat: keep track of agent ids 2025-03-11 15:29:45 -04:00
Aarnav Tale
5dd4c41291
feat: use a new ws implementation thats encapsulated 2025-03-11 15:29:45 -04:00
bitbronze
45537620a6
fix: stop checking for mkey prefix on registration (#131)
String "mkey:" was being appended to the node registration http request causing it to fail. Removed mkey presence check and changed default hint. Form validation is based on length instead.
2025-03-11 15:24:31 -04:00
Gage Orsburn
21af5c4a4d
fix: handle missing split dns config (#129)
Closes #127
2025-03-11 15:12:49 -04:00
Steve Wills
983356611e
docs: fix mistake in env docs (#119) 2025-03-03 14:32:05 -05:00
Aarnav Tale
41ca4c40ad
chore: v0.5.3 2025-03-01 10:37:18 -05:00
Aarnav Tale
f7c9a14e92
chore: temporarily halt full form validation 2025-03-01 10:36:58 -05:00
Aarnav Tale
6e514a9064
fix: handle oidc scope types correctly 2025-03-01 10:36:58 -05:00
Aarnav Tale
631ea15895
docs: warn on breaking change 2025-03-01 10:36:58 -05:00
nerdlich
762bc6a793
fix: cosmetic docs/config fixes (#112)
* fix config path

* fix typo
2025-03-01 10:30:15 -05:00
Aarnav Tale
6c83e96341
chore: v0.5.2 2025-02-28 13:07:14 -05:00
Aarnav Tale
03e6a26b3a
fix: don't reload the prod bundle on refresh 2025-02-28 13:06:38 -05:00
Aarnav Tale
5b716ab5ce
chore: update docs to reflect 0.5.1 2025-02-28 12:22:20 -05:00
Aarnav Tale
a7e4f3e4d2
chore: v0.5.1 2025-02-28 12:21:00 -05:00
Aarnav Tale
40ddb69bc9
fix: resolve latest tags being set 2025-02-28 12:19:14 -05:00
Aarnav Tale
6aa6e77611
chore: fix globalThis typings 2025-02-28 12:17:58 -05:00
Aarnav Tale
c5c2f8a93b
fix: use context for k8s config 2025-02-28 11:29:31 -05:00
Aarnav Tale
acd042b4de
fix: define globals correctly 2025-02-28 11:29:11 -05:00
Aarnav Tale
a563d4c0b3
chore: v0.5.0 2025-02-27 21:52:42 -05:00
Aarnav Tale
7117060863
chore: stop publishing latest tag 2025-02-27 21:52:22 -05:00
Aarnav Tale
30a1153fa5
docs: cleanup integrated docker formatting 2025-02-27 15:33:06 -05:00
Aarnav Tale
870680b6c5
docs: redo docs 2025-02-27 15:31:16 -05:00
Aarnav Tale
25e6410c65
feat: reimplement integrations 2025-02-27 13:42:36 -05:00
Aarnav Tale
f982217dd0
fix: use globals to avoid loader race conditions 2025-02-20 10:45:52 -05:00
Aarnav Tale
f5436f5ee3
feat: respect context in server 2025-02-19 18:09:42 -05:00
Leo Wilkin
06049169a2
chore(docs): Fix broken link to Linux setup on Headscale docs! (#107)
* Update Headscale install link

* Update Native.md
2025-02-19 13:06:08 -05:00
Aarnav Tale
774fdb7be2
fix: use new headscale user api routes 2025-02-19 13:01:20 -05:00
Aarnav Tale
e6580eed2c
fix: no top level await 2025-02-19 13:00:21 -05:00
Aarnav Tale
56b660b30c
chore: switch to 0.25.0 in dev 2025-02-19 10:37:10 -05:00
Aarnav Tale
85c31ca5bf
feat: use new config env 2025-02-14 00:04:01 -05:00
Aarnav Tale
44d08169a9
fix: use ring instead of outline on components 2025-02-14 00:03:46 -05:00
Aarnav Tale
76d263b7e6
feat: switch to config file system 2025-02-13 12:35:12 -05:00
Aarnav Tale
5be3cb345e
feat: switch to normal form actions for dns 2025-02-13 12:29:16 -05:00
Aarnav Tale
2a1c795d46
feat: update to node 22, pnpm 10 2025-02-04 17:25:39 -05:00
Aarnav Tale
316e5b501f
fix: pin to pnpm 9 2025-02-04 17:21:57 -05:00
Aarnav Tale
287ac2dff0
feat: unify all colors 2025-02-04 17:21:03 -05:00
Aarnav Tale
d1f6c450c0
feat: remove react-aria-components 2025-02-04 11:42:57 -05:00
Aarnav Tale
0a14533756
fix: copyable code cannot be a button 2025-02-04 11:39:17 -05:00
Aarnav Tale
347c6698ee
chore: fix aria label warnings 2025-02-04 11:27:29 -05:00
Aarnav Tale
771b87ae41
chore: only invoke useLiveData in root 2025-02-02 00:54:42 -05:00
Aarnav Tale
31ac25d510
feat: redesign code and attribute 2025-02-01 18:11:33 -05:00
Aarnav Tale
68babd7fe9
feat: redesign tooltips 2025-02-01 15:28:04 -05:00
Aarnav Tale
c9d8052e39
chore: remove cn non-default exports 2025-01-28 17:46:16 -05:00
Aarnav Tale
843cfc4d4f
chore: fix build issues 2025-01-28 16:18:51 -05:00
Aarnav Tale
6922548317
chore: switch to headscale 24 in dev 2025-01-28 16:07:44 -05:00
Aarnav Tale
07cee17501
style: temporarily disable some naggy lints 2025-01-28 16:07:15 -05:00
Aarnav Tale
2c8880c84d
feat: switch to new toast provider 2025-01-28 16:06:41 -05:00
Aarnav Tale
a19eb6bcda
feat: add new menu 2025-01-28 16:04:42 -05:00
Aarnav Tale
28e40eecbf
fix: switch to new aria components 2025-01-28 16:02:47 -05:00
Aarnav Tale
741f9aa6b5
feat: switch to new dialog across all code 2025-01-26 15:04:13 -05:00
Aarnav Tale
0f75636342
feat: add react-scan in dev 2025-01-25 12:51:32 -05:00
Aarnav Tale
665509e710
chore: extract tags to shared chip component 2025-01-23 11:28:42 -05:00
Aarnav Tale
ac937f9014
fix: dedupe machine tags 2025-01-23 11:16:56 -05:00
Aarnav Tale
8d3f31e7f9
chore: add lefthook for lint staged 2025-01-20 08:26:00 +00:00
Aarnav Tale
2d47f1b952
chore: switch tailwind fonts 2025-01-20 08:15:14 +00:00
Aarnav Tale
c24cd34925
feat: switch to new dialog gutter 2025-01-20 08:14:42 +00:00
Aarnav Tale
af248df648
feat: redesign buttons and share them in dialogs 2025-01-19 17:31:46 +00:00
Aarnav Tale
b9a708b2e9
chore: update deps and add react-aria 2025-01-19 15:24:14 +00:00
Aarnav Tale
ec36876b9f
feat: begin redesign and component unification 2025-01-19 15:24:03 +00:00
Aarnav Tale
ed0cdbdf4d
chore: add lucide-react icons 2025-01-18 18:42:57 +00:00
Aarnav Tale
fd506ba474
feat: redesign header and footer 2025-01-18 18:42:49 +00:00
Aarnav Tale
9734f0c704
chore: remove old username in dns option 2025-01-18 13:22:43 +00:00
Aarnav Tale
156d7c9ee7
chore: v0.4.1 2025-01-18 08:18:28 +00:00
Aarnav Tale
bd11453593
fix: store oidc redirect_uri in session for parity 2025-01-18 08:18:19 +00:00
Aarnav Tale
0f92f09796
chore: v0.4.0 2025-01-18 07:52:04 +00:00
kevinf100
9bb20024fa
fix: Reusable Pre-Auth Keys no longer show expired when used (#88) 2025-01-18 07:48:50 +00:00
Aarnav Tale
698122068d
fix: center user icon in profile 2025-01-18 07:48:05 +00:00
Aarnav Tale
e6a66d5804
fix: don't propagate errors on user page to root 2025-01-18 07:41:08 +00:00
Aarnav Tale
d524f927a4
fix: don't throw when session manager isn't available 2025-01-18 07:39:12 +00:00
Aarnav Tale
539c76dfb3
chore: switch to shared healtcheck func 2025-01-18 07:38:55 +00:00
Aarnav Tale
51fa7c14d0
fix: handle prod config for server correctly 2025-01-18 07:37:51 +00:00
Aarnav Tale
b92c4ce2e8
chore: remove broken vite plugin import 2025-01-18 07:37:26 +00:00
Aarnav Tale
325e9ba43d
chore: push disabled local-agent code 2025-01-18 07:37:03 +00:00
Aarnav Tale
de9a938da2
chore: use healthcheck method in healthz 2025-01-17 11:46:36 +00:00
Aarnav Tale
1b45b0917f
fix: handle headscale unavailability gracefully 2025-01-17 11:45:36 +00:00
Aarnav Tale
e01fd8d50c
fix: handle undefined websocket in context 2025-01-17 08:34:19 +00:00
Aarnav Tale
c9603ba38a
chore: switch to vite for server dev 2025-01-17 08:33:22 +00:00
Aarnav Tale
377641265b
fix: require auth for agent ws 2025-01-15 15:14:37 +05:30
Aarnav Tale
5bc313e5fe
feat: containerize agent 2025-01-15 10:17:30 +05:30
Aarnav Tale
af919b2d34
fix: support the agent querying itself 2025-01-10 14:49:13 +05:30
Aarnav Tale
f464c42802
fix: format hostinfo in overview correctly 2025-01-10 14:41:05 +05:30
Aarnav Tale
e667e112cb
chore: add @types/ws 2025-01-10 14:24:23 +05:30
Aarnav Tale
39546849ae
fix: mix cached and uncached data on the ws-agent 2025-01-10 14:24:15 +05:30
Aarnav Tale
eb922c9318
fix: implement write-lock for agent cache 2025-01-10 14:06:22 +05:30
Aarnav Tale
5569ba4660
feat: rework oidc to be more resilient
This includes setting a custom redirect URI, handling errors, and using a better library.
As an API decision I've also disabled per session API keys as it clutters up too much.
2025-01-10 13:55:24 +05:30
Aarnav Tale
dfd03e77bb
fix: use headscale health route for healthz 2025-01-10 10:55:35 +05:30
Aarnav Tale
e33504016b
feat: integrate hostinfo into ui 2025-01-08 14:34:53 +05:30
Aarnav Tale
7d4da73141
fix: relax config requirement for paths by using nullish 2025-01-06 08:49:26 +05:30
Aarnav Tale
1316439786
fix: read aggregate and error cause bubbles 2025-01-06 08:47:24 +05:30
Aarnav Tale
888c1a5fb6
fix: resolve css issues with button icons 2025-01-06 08:39:15 +05:30
Aarnav Tale
328302cd81
fix: show toasts above the footer 2025-01-06 08:35:25 +05:30
Aarnav Tale
b060700dfe
fix: blur footer URL for screenshots 2025-01-06 08:31:49 +05:30
Aarnav Tale
69783f0d05
chore: re-stage entry files 2025-01-06 08:19:41 +05:30
Aarnav Tale
55c832c536
feat: do partial client method validation 2025-01-06 08:19:41 +05:30
Aarnav Tale
fc5028994a
chore: remove old import for fs-routes 2025-01-06 08:19:41 +05:30
Aarnav Tale
6745ee8529
fix: resolve type errors and lint components/routes 2025-01-06 08:19:41 +05:30
Aarnav Tale
414b95c293
fix: separate vite builds for server and framework 2025-01-06 08:19:41 +05:30
Aarnav Tale
7217659720
fix: prevent cookie manager from racing env init
this fixes a bug where using LOAD_ENV_FILE did not work with COOKIE_SECRET
2025-01-06 08:19:40 +05:30
Aarnav Tale
a4bb3cce5f
feat: fix build scripts and server 2025-01-06 08:19:40 +05:30
Aarnav Tale
f3c9d8b54c
style: run biome lint 2025-01-06 08:19:40 +05:30
Aarnav Tale
aa9872a45b
chore: switch to react-router v7 2025-01-06 08:19:40 +05:30
Artur Motyka
39504e2487
chore: update outdated docs links (#74) 2025-01-03 10:31:12 +05:30
Aarnav Tale
719d7a5e76
feat: create initial agent for localapi 2024-12-30 13:48:49 +05:30
Aarnav Tale
6156d78907
feat: add ws server for agent 2024-12-30 13:48:18 +05:30
Aarnav Tale
1c2c374ada
feat: switch to server in dev & prod 2024-12-23 10:27:05 -05:00
Aarnav Tale
6cf343d623
feat: refactor all dashboard routes 2024-12-08 13:52:02 -05:00
Aarnav Tale
50e43bc0c3
feat: refactor/reorganize auth routes 2024-12-08 13:27:51 -05:00
Aarnav Tale
9d64a5beac
chore: switch to react-router routes.ts config 2024-12-08 13:11:26 -05:00
Aarnav Tale
240d8d6197
chore: v0.3.9 2024-12-06 19:30:12 -05:00
Aarnav Tale
b80bb0cc09
fix: await testOidc 2024-12-06 19:29:44 -05:00
Aarnav Tale
69cc6985b4
chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale
f9b38939ba
chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale
4cfa1e5209
chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale
e713dae91b
fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale
c9bcc1d7c6
chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale
401731fd09
fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale
f623e7bc66
feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale
1af292a5b0
fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale
21778a43f1
feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale
33762e53b5
chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale
8867cca494
chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
Aarnav Tale
41b1d3c847
chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale
712fc28683
feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale
320dab1d4f
fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale
da0ee1382b
feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale
a7d127c7bf
feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale
b433e607e2
chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale
8aad883c21
fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale
3cd28d2136
feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale
9d9cbd8e0e
chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale
feb8b8bba5
chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale
c304effcdf
fix: externalize process.env 2024-11-08 12:03:08 -05:00
Aarnav Tale
982d811d53
chore: v0.3.4 2024-11-07 13:09:35 -05:00
Aarnav Tale
d369e36fa4
fix: potentially catch undefined bearer errors 2024-11-07 13:08:43 -05:00
Aarnav Tale
a2e659f36c
feat(TALE-36): redesign machines page to account for exit nodes 2024-11-07 13:04:53 -05:00
Aarnav Tale
1490406784
feat(TALE-36): show exit node status on machine page 2024-11-06 17:01:36 -05:00
Aarnav Tale
e1c87412d4
feat(TALE-36): support exit node enabling/disabling 2024-11-06 16:48:14 -05:00
Aarnav Tale
6e55f442fd
fix: make the last acl tag removable on machines 2024-11-06 16:10:39 -05:00
Aarnav Tale
09e1b1f261
fix: address a bug that prevented remove split dns records 2024-11-06 15:57:43 -05:00
Aarnav Tale
3e83f8617b
fix: use correct vite config in dev 2024-11-06 15:57:27 -05:00
Aarnav Tale
1d6472765a
docs: better explain the native integration 2024-11-06 15:18:21 -05:00
Aarnav Tale
2bc85085f5
fix: handle non-prefixed routes on the production server 2024-11-06 14:29:04 -05:00
Aarnav Tale
5c949e2da5
feat: use all native node deps for the server 2024-11-04 22:05:46 -05:00
Aarnav Tale
12754bd0aa
fix: resolve type errors across the codebase 2024-11-04 18:13:10 -05:00
Aarnav Tale
10bb4eba97
fix: skip loading env file if context is loaded 2024-11-04 18:00:50 -05:00
Aarnav Tale
13a734923d
feat: ditch remix-serve for custom-baked server 2024-11-04 18:00:35 -05:00
Aarnav Tale
d5ee8ae0f3
fix: clicking a machine name in users page redirects 2024-11-04 14:59:13 -05:00
Aarnav Tale
331a9c8dcf
chore: v0.3.3 2024-10-28 15:10:20 -04:00
Aarnav Tale
808b01c5d7
chore: update react 19 compiler to next beta 2024-10-27 13:13:34 -04:00
Aarnav Tale
a8abd37e3a
fix: handle errors and fix api logic with acl updating 2024-10-27 13:10:32 -04:00
Aarnav Tale
d1fa76971b
feat: switch back to codemirror with jsonc support 2024-10-22 00:41:44 -04:00
Aarnav Tale
a9e8394dec
fix: do not pass the text value as a form field
This fixes an issue where on ephemeral key creation, it would fail due to localized dates
2024-10-20 18:34:43 -04:00
Aarnav Tale
9dafd8e8b0
feat: load from dotenv if specified 2024-10-16 23:07:41 -04:00
Václav Šmejkal
0c1f6969da
feat: hide api key on login page (#39) 2024-10-16 13:23:02 -04:00
Aarnav Tale
4214e14ba8
chore: reorganize machines page to prep for routing data 2024-10-13 00:32:21 -04:00
Aarnav Tale
447a31f91e
chore: v0.3.2 2024-10-11 03:09:56 -04:00
Aarnav Tale
9e21823163
chore: mention that settings is incomplete 2024-10-11 03:04:45 -04:00
Aarnav Tale
aa7e2a3128
feat(TALE-35): implement pre-auth key management 2024-10-11 03:02:33 -04:00
Aarnav Tale
ecef45c98a
fix(TALE-35): use the correct machine registry endpoint 2024-10-10 11:12:35 -04:00
Aarnav Tale
d165264876
fix: handle nullable expiry values from a db 2024-10-10 10:41:41 -04:00
Aarnav Tale
dd479d4117
fix: remove older references to magic_dns via usernames 2024-10-10 10:03:43 -04:00
Akira Yamazaki
29d91785fb
fix: build oidc callback url in a more proper way (#28) 2024-10-07 23:23:31 -04:00
Aarnav Tale
98d1cb1333
docs: mention 0.3.1 as the latest 2024-10-03 15:36:20 -04:00
Aarnav Tale
2229f547a9
chore: v0.3.1 2024-10-03 15:31:03 -04:00
Aarnav Tale
65cc278a59
docs(TALE-33): document debug 2024-10-03 15:30:24 -04:00
Aarnav Tale
1555846df2
fix(TALE-34): use the http coded URL for socket 2024-10-03 15:15:56 -04:00
Aarnav Tale
e8c1cadf54
feat(TALE-35): add initial machine key authorization 2024-10-03 11:58:05 -04:00
Aarnav Tale
d867769025
chore: update browserlist 2024-10-02 13:41:45 -04:00
Aarnav Tale
1d6066d3f0
feat(TALE-33): add debug logging with DEBUG env 2024-10-02 13:33:39 -04:00
Aarnav Tale
1d821251a9
fix(TALE-34): setting url.protocol does not work anymore 2024-10-02 12:59:42 -04:00
Aarnav Tale
a0d6905123
chore: v0.3.0 2024-09-25 16:32:58 -04:00
Aarnav Tale
4095ed2a68
docs: version the docker images 2024-09-25 16:26:19 -04:00
Aarnav Tale
58e98278d1
chore: support dns use_username_in_magic_dns config 2024-09-25 16:22:00 -04:00
Aarnav Tale
90f0bf2555
chore: v0.2.4 2024-08-24 10:35:05 -04:00
Aarnav Tale
ea2ffdf0c1
feat: support removing config values via null 2024-08-24 10:33:30 -04:00
Aarnav Tale
9aedd9baad
chore: use beta2 on the dev env 2024-08-24 10:19:07 -04:00
Aarnav Tale
690b52d8c6
chore(TALE-29): remove acl from integration/context 2024-08-24 10:19:07 -04:00
Aarnav Tale
a72a3d6e5f
chore(TALE-29): remove references to ACL_FILE 2024-08-24 10:19:06 -04:00
Akira Yamazaki
c4c1fd8aab
feat: make secure flag of cookie configurable (#26) 2024-08-24 10:18:38 -04:00
Aarnav Tale
9801ef453d
fix(TALE-29): remove all old ACL_FILE handling
No longer required if the minimum is beta2
2024-08-23 16:12:46 -04:00
Aarnav Tale
d041a62fcd
chore: 0.2.3 2024-08-23 15:20:27 -04:00
Aarnav Tale
0e6b5ea6d0
chore: update docs for minimum beta requirement 2024-08-23 15:18:32 -04:00
Aarnav Tale
a2054786f8
fix(TALE-31): use oidc variables first over config 2024-08-23 15:12:53 -04:00
Aarnav Tale
5a46fd0a97
feat(TALE-30): add support for new DNS configs
This is a breaking change to support 0.23-beta2
2024-08-22 16:55:05 -04:00
Aarnav Tale
b8999161a2
feat(TALE-29): handle no ACL configurations being available 2024-08-04 17:30:41 -04:00
Aarnav Tale
224cbbdcaf
chore: update to headscale beta for compose dev 2024-08-04 11:33:11 -04:00
Aarnav Tale
75ba3a3dc7
feat(TALE-29): support the headscale policy api changes 2024-08-04 11:32:29 -04:00
Aarnav Tale
4f57fdb43b
chore: v0.2.2 2024-08-02 16:15:08 -04:00
Aarnav Tale
b170e11dd6
fix(TALE-7): don't destructure context otherwise it won't set properly 2024-08-02 16:14:05 -04:00
Aarnav Tale
6b278309ed
fix(TALE-7): use the new integration methods in the code 2024-07-10 19:38:44 -04:00
Aarnav Tale
ea8ecfb28f
chore: use new logger 2024-07-10 19:36:13 -04:00
Aarnav Tale
099bd3bcb8
feat(TALE-7): add proper integration logging 2024-07-10 19:26:59 -04:00
Aarnav Tale
0aa0406ea6
feat(TALE-7): reimplement integration system 2024-07-09 22:53:00 -04:00
Aarnav Tale
3cc726320a
fix(TALE-5): remove deployment check and only use pod spec 2024-07-08 14:40:38 -04:00
Aarnav Tale
bda151f4e8
chore: simplify documentation 2024-07-08 14:31:53 -04:00
Aarnav Tale
6d411853d5
feat(TALE-5): implement k8s integration 2024-07-08 13:23:53 -04:00
Aarnav Tale
dc4d05a2d9
fix: filter out undefined proc pids 2024-07-08 01:17:28 -04:00
Aarnav Tale
361859f374
chore: v0.2.1 2024-07-07 14:57:33 -04:00
Aarnav Tale
c60652e39f
fix(TALE-4): prevent duplicate ACL tags from being added 2024-07-07 14:57:16 -04:00
Aarnav Tale
1db385e716
chore(TALE-11): remove unnecessary component in dns 2024-07-07 14:53:16 -04:00
Aarnav Tale
ab0cb7b782
feat(TALE-11): support custom DNS records 2024-07-07 14:52:47 -04:00
Aarnav Tale
fd73832879
fix: use acl diff editor and make discard button work 2024-07-07 14:28:28 -04:00
Aarnav Tale
7804d83181
feat(TALE-4): make acl tags editable from the menu 2024-07-07 14:20:39 -04:00
Aarnav Tale
fe40a5734e
chore: update deps 2024-07-07 14:19:53 -04:00
Aarnav Tale
c0bdf91f6b
chore: v0.2.0 2024-06-23 18:21:37 -04:00
Aarnav Tale
bd46f97121
chore: update deps 2024-06-23 18:18:01 -04:00
Aarnav Tale
d71e55d7af
feat(TALE-10): edit machine owner via dropdown 2024-06-23 17:25:33 -04:00
Aarnav Tale
ddd20fe027
feat: create select component 2024-06-23 17:25:22 -04:00
Aarnav Tale
dca5f9d149
fix(TALE-6): make menu dropdown actions available on machines page 2024-06-23 01:05:03 -04:00
Aarnav Tale
be8ce7a2fe
fix: set defaults with HEADSCALE_UNSTRICT 2024-06-18 23:59:06 -04:00
Aarnav Tale
6fa0e265fe
feat: update images 2024-06-03 23:33:38 -04:00
Aarnav Tale
74e87bd80e
chore: v0.1.9 2024-06-03 22:52:26 -04:00
Aarnav Tale
d01699009d
chore: update packages 2024-06-03 22:51:45 -04:00
Aarnav Tale
08db192db1
fix: use magic domain for machine domain attribute 2024-06-03 22:43:38 -04:00
Aarnav Tale
3ea5fed8f6
fix: support jsonc comments and switch to monaco for acls 2024-06-02 22:40:23 -04:00
Aarnav Tale
dd9d6cd550
fix: set edge tag to main 2024-06-02 16:52:29 -04:00
Aarnav Tale
f82244f85a
fix: github actions work this time? 2024-06-02 14:33:43 -04:00
Aarnav Tale
896aaad61e
fix: push action had syntax error 2024-06-02 14:29:32 -04:00
Aarnav Tale
e5dece5b7b
feat: add build and nightly 2024-06-02 14:28:42 -04:00
Aarnav Tale
2797525969
fix: expired check 2.0? 2024-06-02 14:03:25 -04:00
Aarnav Tale
f0f8a6f01b
chore: v0.1.8 2024-06-02 01:40:05 -04:00
Aarnav Tale
3bd5cc99aa
fix: handle 0001-01-01 00:00:00 as the no-expiry timestamp 2024-06-02 01:37:31 -04:00
Aarnav Tale
937fa6f6fa
fix: use proper route ids for key mapping 2024-06-02 01:34:58 -04:00
Aarnav Tale
3ffbabd7fc
feat: add functional machine overview page and fix types 2024-06-02 01:33:40 -04:00
Aarnav Tale
c08203cc76
fix: prevent user deletion if they have machines owned 2024-06-02 00:54:20 -04:00
Aarnav Tale
57f045eaf6
fix: support top level await on the server 2024-05-30 11:41:36 -04:00
Aarnav Tale
faa583bb38
chore: v0.1.7 2024-05-30 10:41:55 -04:00
Aarnav Tale
98ea2cb06f
feat: add a way to escape hatch from strict config checking 2024-05-30 10:41:40 -04:00
Aarnav Tale
868d85bbeb
chore: log about context loading 2024-05-30 10:28:37 -04:00
Aarnav Tale
dbd9d39da9
style: cleanup vite.config.ts 2024-05-30 10:20:53 -04:00
Aarnav Tale
72cc9d8974
fix: flexible duration and go boolean handling 2024-05-30 10:20:41 -04:00
Aarnav Tale
ed50c48965
fix: handle configs better and propagate errors 2024-05-30 10:15:02 -04:00
Aarnav Tale
e19dbda5ed
fix: headscale expiry can be a literal 0 2024-05-26 19:41:56 -04:00
Aarnav Tale
a72cc1bb1c
chore: document the new integration providers 2024-05-26 19:40:46 -04:00
Aarnav Tale
f0e4868252
feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
2024-05-26 19:23:24 -04:00
Aarnav Tale
1a30185047
chore: v0.1.6 2024-05-22 14:15:05 -04:00
Aarnav Tale
c7e59b137c
docs: solidify DOCKER_SOCK and ROOT_API_KEY usage 2024-05-22 12:43:42 -04:00
Aarnav Tale
694b22f205
style: cleanup oidc spaghetti 2024-05-21 23:57:03 -04:00
Aarnav Tale
84855d9d51
chore: update pnpm and enforce engines 2024-05-21 23:50:43 -04:00
Aarnav Tale
6bea3b2a55
fix: user grid on mobile should be full width 2024-05-21 17:23:33 -04:00
Aarnav Tale
2ee1adf142
chore: delete entry files because they aren't modified 2024-05-21 17:22:11 -04:00
Aarnav Tale
06d7d1ccad
feat: add support for split dns 2024-05-21 17:21:14 -04:00
Aarnav Tale
2aae6bfc06
chore: v0.1.5 2024-05-20 14:08:10 -04:00
Aarnav Tale
6794b1b2dd
fix: don't log the context 2024-05-20 14:06:38 -04:00
Aarnav Tale
a6582ef835
fix: button responders on acl page never worked 2024-05-20 14:05:27 -04:00
Aarnav Tale
3e51e4861d
feat: use strictly typed configs and context 2024-05-20 14:05:09 -04:00
Aarnav Tale
0a12cdb3d6
fix: call button onPress even without state handler 2024-05-20 14:02:04 -04:00
Aarnav Tale
78698dcbba
fix: DISABLE_API_KEY_LOGIN should only work when set to true 2024-05-19 19:15:29 -04:00
Aarnav Tale
260fb870e1
fix: when renaming users start with old names 2024-05-15 23:46:15 -04:00
Aarnav Tale
2b1e7be193
chore: v0.1.4 (attempt 2) 2024-05-15 22:27:03 -04:00
Aarnav Tale
65a76a7fa2
fix: docker needs the patches dir 2024-05-15 22:26:36 -04:00
Aarnav Tale
3d554a2434
chore: v0.1.4 2024-05-15 22:23:19 -04:00
Aarnav Tale
d57ee2677c
feat: react 19 to take advantage of compiler 2024-05-15 22:22:38 -04:00
Aarnav Tale
2f02ccc362
style: eslint changes 2024-05-15 21:54:40 -04:00
Aarnav Tale
37a31e30c7
chore: update eslint package 2024-05-15 21:54:34 -04:00
Aarnav Tale
e7c6271322
chore: cleanup login page 2024-05-15 21:54:02 -04:00
Aarnav Tale
3deec75715
fix: Promise.all on concurrent promises 2024-05-15 21:53:39 -04:00
Aarnav Tale
18999357a8
fix: return the revalidator in useLiveData 2024-05-15 21:53:12 -04:00
Aarnav Tale
e6eba645c4
feat: implement complete user control 2024-05-15 21:52:19 -04:00
Aarnav Tale
f563335fab
feat: add loading indicator 2024-05-11 23:07:24 -04:00
Aarnav Tale
6102fabfcb
fix: add fallback values from the config 2024-05-11 23:07:06 -04:00
Aarnav Tale
87430ccf7b
feat: continue moving older components to aria 2024-05-05 23:38:41 -04:00
Aarnav Tale
ee42016a67
fix: patch @react-aria/overlays to support scroll gutter 2024-05-04 16:21:35 -04:00
Aarnav Tale
9f5ac6a9ef
chore: v0.1.3 2024-05-04 15:07:27 -04:00
Aarnav Tale
7f55ad826c
chore: add link to headscale acl faq on acl page 2024-05-04 15:04:55 -04:00
Aarnav Tale
2cc65e8783
chore: do a feature check for scrollbar-gutter-stable 2024-05-04 15:04:40 -04:00
Aarnav Tale
5ff09e44d9
feat: cleanup header and extract to component 2024-05-04 15:04:22 -04:00
Aarnav Tale
c6cdcf35eb
chore: switch from heroicons to primer icons 2024-05-04 15:03:21 -04:00
Aarnav Tale
61ae161496
chore: update readme 2024-05-01 02:51:03 -04:00
Aarnav Tale
82f5e17207
feat: add various machine operations 2024-05-01 02:47:45 -04:00
Aarnav Tale
a63f4e4d52
fix: API_KEY is no longer required for login to work 2024-05-01 02:47:36 -04:00
Aarnav Tale
a57e777a6b
feat: completely nuke headlessui and consolidate to aria 2024-05-01 02:21:54 -04:00
Aarnav Tale
8205f2b99b
feat: replace the old modal completely 2024-04-30 11:03:53 -04:00
Aarnav Tale
c95218b8dd
fix: prune an image for readme 2024-04-29 20:33:51 -04:00
Aarnav Tale
769556efda
fix: make the toaster click-throughable 2024-04-29 20:32:14 -04:00
Aarnav Tale
ea47bb5fec
chore: cleanup and use aria components in places 2024-04-29 20:32:00 -04:00
Aarnav Tale
3abd617a90
feat: switch to react aria and replace many components 2024-04-29 20:31:29 -04:00
Aarnav Tale
cbecf85979
chore: update readme images 2024-04-23 01:15:31 -04:00
Aarnav Tale
0ebadc26bc
fix: oidc will fail in the dev config 2024-04-22 14:34:03 -04:00
Aarnav Tale
252e78d618
feat: facelift overall machines page and refactor 2024-04-22 14:33:51 -04:00
Aarnav Tale
c6930732ee
fix: oidc config variables were named incorrectly 2024-04-22 13:21:48 -04:00
Aarnav Tale
f04b17109b
fix: only sighup if we have docker 2024-04-17 17:44:12 -04:00
Aarnav Tale
0ff9e6fdc3
feat: implement better ssr fallbacks 2024-04-17 17:42:44 -04:00
Aarnav Tale
94174ebcce
feat: add dumb yaml detection 2024-04-17 17:20:35 -04:00
Aarnav Tale
c2fe69ec17
chore: add acl file instructions 2024-04-15 04:06:18 -04:00
Aarnav Tale
b285753b24
fix: omg why do i forget these things here 2024-04-15 04:02:23 -04:00
Aarnav Tale
8eac733a5d
fix: we shouldn't return early if the env var isn't there 2024-04-15 03:52:51 -04:00
Aarnav Tale
89a7cb5aae
chore: stop prefixing tags with v 2024-04-15 03:41:28 -04:00
Aarnav Tale
39868b5043
feat: implement raw acl editing on the web ui 2024-04-15 03:40:52 -04:00
Aarnav Tale
ca32590e54
fix: string constructor in js causes oidc fast track 2024-04-07 14:01:38 -04:00
Aarnav Tale
a846249be1
chore: add a warning to the compose.yaml 2024-04-05 18:32:12 -04:00
Aarnav Tale
bcf00beb75
fix: add publish archs 2024-04-02 15:08:47 -04:00
Aarnav Tale
6dae5d647a
chore: various fixes 2024-04-01 19:41:39 -04:00
Aarnav Tale
d787b8517e
fix: protect the restart endpoint with auth 2024-04-01 19:33:22 -04:00
Aarnav Tale
bdb00b6cd7
feat: make the modal and dropdown more workable 2024-03-31 19:06:06 -04:00
Aarnav Tale
f1347803a4
fix: logout is a button 2024-03-31 18:14:28 -04:00
212 changed files with 19300 additions and 8979 deletions

View File

@ -1,7 +0,0 @@
API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
HOST=0.0.0.0
PORT=3000
CONFIG_FILE=/etc/headscale/config.yaml

1
.envrc Normal file
View File

@ -0,0 +1 @@
use_flake

View File

@ -1,3 +0,0 @@
{
"extends": "tale"
}

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: tale
ko_fi: atale

31
.github/ISSUE_TEMPLATE/bug-report.yaml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -0,0 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true

33
.github/workflows/automated.yaml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Automated
on:
schedule:
- cron: "0 8 * * 0"
workflow_dispatch:
concurrency:
group: automation-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
jobs:
flake-inputs:
name: flake-inputs
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
automated

76
.github/workflows/build.yaml vendored Normal file
View File

@ -0,0 +1,76 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
jobs:
native:
name: native
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
nix:
name: nix
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@main
- name: Check flake outputs
run: nix flake check --all-systems

55
.github/workflows/next.yaml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Pre-release (next)
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=next
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

View File

@ -1,17 +1,34 @@
name: Publish Docker Image
name: Release
on:
push:
tags:
- 'v*'
- "*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
name: Build and Publish
docker:
name: Docker Release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -25,12 +42,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
@ -39,3 +50,4 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules
/.react-router
/.cache
/build
/test
.env

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
pnpm 10.4.0
node 22

17
.zed/settings.json Normal file
View File

@ -0,0 +1,17 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}

239
CHANGELOG.md Normal file
View File

@ -0,0 +1,239 @@
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
### 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
### 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
### 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented *proper* authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
- Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
### 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
- OIDC auth was not using information from the `user_info` endpoint.
- Support the picture of the user who is logged in via OIDC if available.
- Rewrote the Agent implementation to better utilize disk space and perform better (coming soon).
- Loosened checking for the Headscale configuration as it was too strict and required certain optional fields.
- Deleting a node will now correctly redirect back to the nodes page (fixes [#137](https://github.com/tale/headplane/issues/137))
- Supports connecting to Headscale via TLS and can accept a certificate file (partially fixes [#82](https://github.com/tale/headplane/issues/82))
- Add support for running Headplane through Nix, though currently unsupported (via [#132](https://github.com/tale/headplane/pull/132))
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
### 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
### 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
### 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
### 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
- Completely redesigned the UI from the ground up for accessibility and performance.
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
- Added support for Headscale 0.25.0 (this drops support for any older versions).
- Fixed issues where renaming, deleting, and changing node owners via users was not possible (fixes [#91](https://github.com/tale/headplane/issues/91))
- Operations now have significantly less moving parts and better error handling.
- Updated to `pnpm` 10 and Node.js 22.
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
### 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
### 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
- The footer now blurs your Headscale URL as a privacy measure
- Updated to the next stable beta of the React Compiler
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
- Redo the OIDC integration for better error handling and configuration
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI
### 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
### 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
### 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information.
- Fixed an issue where the /proc integration found `undefined` PIDs.
### 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
### 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
### 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all.
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
### 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
### 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
- Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
### 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
- Fixed button responders not actually being invoked (should fix the ACL page).
### 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership.
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
- Implemented some fixes that should stop dialogs from hanging a webpage.
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
- Upgrade other dependencies
### 0.1.3 (May 4, 2024)
- Switched to a better icon set for the UI.
- Support stable scrollbar gutter if supported by the browser.
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
### 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine.
- Implemented an expiry check for machines which now reflect on the machine table.
- Fixed an issue where `HEADSCALE_CONTAINER` was needed to start even without the Docker integration.
- Removed the requirement for the root `API_KEY` unless OIDC was being used for authentication.
- Switched to [React Aria](https://react-spectrum.adobe.com/react-aria/) for better accessibility support.
- Cleaned up various different UI inconsistencies and copied components that could've been abstracted.
- Added a changelog for any new versions going forward.

View File

@ -1,21 +1,18 @@
FROM node:20-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN pnpm prune --prod
FROM node:20-alpine
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
WORKDIR /app
COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules
RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
CMD [ "node", "./build/server/index.js" ]

View File

@ -1,24 +1,89 @@
# Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
> A feature-complete web UI for [Headscale](https://headscale.net)
![Preview](/assets/main-preview.png)
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png"
>
<img
alt="Preview"
src="./assets/preview-dark.png"
>
</picture>
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
This is a relatively tiny Remix app that aims to provide a usable GUI for the Headscale server.
It's still very early in it's development, however these are some of the features that are planned.
Headscale is the de-facto self-hosted version of Tailscale, a popular Wireguard
based VPN service. By default, it does not ship with a web UI, which is where
Headplane comes in. Headplane is a feature-complete web UI for Headscale, allowing
you to manage your nodes, networks, and ACLs with ease.
- [ ] Editable tags, machine names, users, etc
- [ ] ACL control through Docker integration
- [x] OIDC based login for the web UI
- [x] Automated API key regeneration
- [x] Editable headscale configuration
Headplane aims to replicate the functionality offered by the official Tailscale
product and dashboard, being one of the most feature complete Headscale UIs available.
These are some of the features that Headplane offers:
- Machine management, including expiry, network routing, name, and owner management
- Access Control List (ACL) and tagging configuration for ACL enforcement
- Support for OpenID Connect (OIDC) as a login provider
- The ability to edit DNS settings and automatically provision Headscale
- Configurability for Headscale's settings
## Deployment
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server.
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
> Copyright (c) 2024 Aarnav Tale
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
### Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png"
>
<img
alt="ACLs"
src="./assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png"
>
<img
alt="Machine Management"
src="./assets/machine-dark.png"
>
</picture>
> Copyright (c) 2025 Aarnav Tale

15
agent.Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]

View File

@ -0,0 +1,40 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}

56
agent/config/config.go Normal file
View File

@ -0,0 +1,56 @@
package config
import (
"os"
_ "github.com/joho/godotenv/autoload"
)
// Config represents the configuration for the agent.
type Config struct {
Debug bool
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
}
const (
DebugEnv = "HEADPLANE_AGENT_DEBUG"
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
func Load() (*Config, error) {
c := &Config{
Debug: false,
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
c.Debug = true
}
if err := validateRequired(c); err != nil {
return nil, err
}
if err := validateTSReady(c); err != nil {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}

73
agent/config/preflight.go Normal file
View File

@ -0,0 +1,73 @@
package config
import (
"fmt"
"net/http"
"strings"
)
// Checks to make sure all required environment variables are set
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
}
if config.TSControlURL == "" {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
}
return nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
}
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}

83
agent/hpagent/handler.go Normal file
View File

@ -0,0 +1,83 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}

11
agent/hpagent/sender.go Normal file
View File

@ -0,0 +1,11 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}

View File

@ -0,0 +1,63 @@
package hpagent
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}

47
agent/tsnet/peers.go Normal file
View File

@ -0,0 +1,47 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}

61
agent/tsnet/server.go Normal file
View File

@ -0,0 +1,61 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}

View File

@ -1,39 +1,75 @@
import { ClipboardIcon } from '@heroicons/react/24/outline'
import toast from 'react-hot-toast/headless'
import { Check, Copy } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
type Properties = {
readonly name: string;
readonly value: string;
readonly isCopyable?: boolean;
export interface AttributeProps {
name: string;
value: string;
isCopyable?: boolean;
link?: string;
suppressHydrationWarning?: boolean;
}
export default function Attribute({ name, value, isCopyable }: Properties) {
const canCopy = isCopyable ?? false
export default function Attribute({
name,
value,
link,
isCopyable,
suppressHydrationWarning,
}: AttributeProps) {
return (
<dl className='flex gap-1 text-sm w-full'>
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'>
{name}
<dl className="flex items-center w-full gap-x-1">
<dt className="font-semibold w-1/4 shrink-0 text-sm">
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
</dt>
<dd
suppressHydrationWarning={suppressHydrationWarning}
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
>
{isCopyable ? (
<button
type="button"
className="w-full flex items-center gap-x-1 outline-none"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
{(canCopy ?? false) ? (
<button
type='button'
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
</button>
) : (
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
)}
await navigator.clipboard.writeText(value);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
{value}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
) : (
value
)}
</dd>
</dl>
)
);
}

View File

@ -1,26 +1,43 @@
import clsx from 'clsx'
import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react'
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
type Properties = {
readonly variant?: 'emphasized' | 'normal' | 'destructive';
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function Action(properties: Properties) {
return (
<button
type='button'
{...properties}
className={clsx(
'focus:outline-none focus:ring focus:ring-1',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.className,
properties.disabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'destructive' ? 'text-red-700 dark:text-red-500' : '',
properties.variant === 'emphasized' ? 'rounded-lg px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white' : '',
!properties.variant || properties.variant === 'normal' ? 'text-blue-700 dark:text-blue-400' : ''
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{properties.children}
{props.children}
</button>
)
);
}

View File

@ -1,18 +1,28 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
type Properties = HTMLProps<HTMLDivElement>
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
}
export default function Card(properties: Properties) {
function Card({ variant = 'raised', ...props }: Props) {
return (
<div
{...properties}
className={clsx(
'p-4 md:p-6 border dark:border-zinc-700 rounded-lg',
properties.className
{...props}
className={cn(
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
export default Object.assign(Card, { Title, Text });

32
app/components/Chip.tsx Normal file
View File

@ -0,0 +1,32 @@
import React from 'react';
import cn from '~/utils/cn';
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}

View File

@ -1,12 +1,50 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
type Properties = HTMLProps<HTMLSpanElement>
export default function Code(properties: Properties) {
return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children}
</code>
)
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
}
export default function Code({ isCopyable, children, className }: CodeProps) {
return (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-none focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<button
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
)}
</code>
);
}

194
app/components/Dialog.tsx Normal file
View File

@ -0,0 +1,194 @@
import React, { cloneElement, useEffect, useRef } from 'react';
import {
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import Card from '~/components/Card';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
return (
<Form
{...dialogProps}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
</>
)}
</div>
</Card>
</Form>
);
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
)}
>
{children}
</div>
</Overlay>
);
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
});

View File

@ -1,84 +0,0 @@
import { Menu, type MenuButtonProps, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { Fragment, type HTMLProps, type ReactNode } from 'react'
type Properties = {
readonly children: ReactNode;
readonly button: ReactNode;
// eslint-disable-next-line unicorn/no-keyword-prefix
readonly className?: string;
}
function Dropdown(properties: Properties) {
return (
<div className={clsx('relative', properties.className)}>
<Menu>
<Button className='flex flex-col items-center'>
{properties.button}
</Button>
<Transition
as={Fragment}
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
>
<Menu.Items className={clsx(
'absolute right-0 w-fit max-w-36 mt-2 rounded-md',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-zinc-800 text-right',
'overflow-hidden z-50',
'border border-gray-200 dark:border-zinc-700',
'divide-y divide-gray-200 dark:divide-zinc-700'
)}
>
{properties.children}
</Menu.Items>
</Transition>
</Menu>
</div>
)
}
function Button(properties: MenuButtonProps<'button'>) {
return (
<Menu.Button
{...properties}
className={clsx(
properties.className
)}
>
{properties.children}
</Menu.Button>
)
}
type ItemProperties = HTMLProps<HTMLDivElement> & {
variant?: 'static' | 'normal';
}
function Item(properties: ItemProperties) {
return (
<Menu.Item>
{({ active }) => (
<div
{...properties}
className={clsx(
'px-4 py-2 w-full text-right',
'focus:outline-none focus:ring',
'focus:ring-gray-300 dark:focus:ring-zinc-700',
properties.className,
properties.variant !== 'static' && active
? 'bg-gray-100 dark:bg-zinc-500' : ''
)}
>
{properties.children}
</div>
)}
</Menu.Item>
)
}
export default Object.assign(Dropdown, { Item })

View File

@ -1,66 +1,89 @@
import { Transition } from '@headlessui/react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import clsx from 'clsx'
import { Fragment, useEffect, useState } from 'react'
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
interface Props {
type?: 'full' | 'embedded';
}
export function ErrorPopup({ type = 'full' }: Properties) {
const [isOpen, setIsOpen] = useState(false)
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
console.error(error)
function getMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
// Debounce the error modal so it doesn't show up for a split second
// when the user navigates to a new page.
useEffect(() => {
setTimeout(() => {
setIsOpen(true)
}, 150)
}, [])
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
}
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getMessage(error);
return (
<Transition as={Fragment} show={isOpen}>
<Transition.Child
as={Fragment}
enter='ease-out duration-150'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
>
<div className={clsx(
'flex items-center justify-center overflow-clip',
type === 'full' ? 'min-h-screen' : 'mt-24'
)}
>
<div className={clsx(
'flex flex-col items-center justify-center space-y-2 w-full sm:w-1/2 xl:w-1/3',
'bg-white dark:bg-zinc-800 rounded-lg py-8 px-4 md:px-16',
'border border-gray-200 dark:border-zinc-700 text-center'
)}
>
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
<h1 className='text-2xl font-semibold text-gray-800 dark:text-gray-100'>
{routing ? error.status : 'Error'}
</h1>
{routing ? (
<p className='text-gray-500 dark:text-gray-400'>
{error.statusText}
</p>
) : (
<Code className='text-sm'>
{message}
</Code>
)}
</div>
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : title}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
</Transition.Child>
</Transition>
)
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
</Card.Text>
</Card>
</div>
);
}

49
app/components/Footer.tsx Normal file
View File

@ -0,0 +1,49 @@
import Link from '~/components/Link';
import cn from '~/utils/cn';
interface FooterProps {
url: string;
debug: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}

192
app/components/Header.tsx Normal file
View File

@ -0,0 +1,192 @@
import {
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
interface Props {
configAvailable: boolean;
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-none focus:ring',
isActive ? 'after:visible' : 'after:invisible',
)
}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-none focus:ring rounded-md',
)}
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800',
'shadow-inner',
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PlaneTakeoff />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
>
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
onAction={(key) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-headplane-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
) : undefined}
{data.access.users ? (
<TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
/>
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}

View File

@ -0,0 +1,46 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
}

View File

@ -1,26 +1,84 @@
import clsx from 'clsx'
import { type DetailedHTMLProps, type InputHTMLAttributes } from 'react'
import { Asterisk } from 'lucide-react';
import { useRef } from 'react';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
type Properties = {
readonly variant?: 'embedded' | 'normal';
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
export default function Input(properties: Properties) {
return (
<input
{...properties}
className={clsx(
'block w-full dark:text-gray-300',
'border-gray-300 dark:border-zinc-700',
'focus:outline-none focus:ring',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.variant === 'embedded' ? 'bg-transparent' : 'dark:bg-zinc-800',
properties.variant === 'embedded' ? 'p-0' : 'px-2.5 py-1.5',
properties.variant === 'embedded' ? 'border-none' : 'border',
properties.variant === 'embedded' ? 'focus:ring-0' : 'focus:ring-1',
properties.variant === 'embedded' ? 'rounded-none' : 'rounded-lg',
properties.className
)}
/>
)
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const { label, labelHidden, className } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
return (
<div className="flex flex-col w-full" aria-label={label}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}

35
app/components/Link.tsx Normal file
View File

@ -0,0 +1,35 @@
import { ExternalLink } from 'lucide-react';
import cn from '~/utils/cn';
export interface LinkProps {
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({
to,
name: alt,
children,
className,
}: LinkProps) {
return (
<a
href={to}
aria-label={alt}
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-none focus:ring rounded-md',
className,
)}
>
{children}
<ExternalLink className="w-3.5" />
</a>
);
}

170
app/components/Menu.tsx Normal file
View File

@ -0,0 +1,170 @@
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import {
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn(
'mx-2 mt-1 mb-1 border-t',
'border-headplane-200 dark:border-headplane-800',
)}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-none select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
)}
>
{item.rendered}
</li>
);
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
});

View File

@ -1,135 +0,0 @@
import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { Fragment, type ReactNode, type SetStateAction, useState } from 'react'
import Button from './Button'
type HookParameters = {
title: string;
description?: string;
buttonText?: string;
variant?: 'danger' | 'confirm';
children?: ReactNode;
// Optional because the button submits
onConfirm?: () => void | Promise<void>;
}
type Properties = {
readonly isOpen: boolean;
readonly setIsOpen: (value: SetStateAction<boolean>) => void;
readonly parameters: HookParameters;
}
export default function useModal(properties: HookParameters) {
const [isOpen, setIsOpen] = useState(false)
return {
Modal: (
<Modal
isOpen={isOpen}
setIsOpen={setIsOpen}
parameters={properties}
/>
),
open: () => {
setIsOpen(true)
},
close: () => {
setIsOpen(false)
}
}
}
function Modal({ parameters, isOpen, setIsOpen }: Properties) {
return (
<Transition
show={isOpen}
as={Fragment}
>
<Dialog
as='div'
className='relative z-50'
onClose={() => {
setIsOpen(false)
}}
>
<Transition.Child
enter='ease-out duration-100'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-75'
leaveFrom='opacity-100'
leaveTo='opacity-0'
as={Fragment}
>
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
</Transition.Child>
<div className='fixed inset-0 flex w-screen items-center justify-center'>
<Transition.Child
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
as={Fragment}
>
<Dialog.Panel className={clsx(
'rounded-lg p-4 w-full max-w-md',
'bg-white dark:bg-black relative',
'border border-gray-200 dark:border-zinc-800'
)}
>
<XMarkIcon
className={clsx(
'absolute top-3 right-3 rounded-lg p-1.5',
'w-8 h-8 text-gray-500 dark:text-gray-400',
'hover:bg-gray-100 dark:hover:bg-zinc-800'
)}
onClick={() => {
setIsOpen(false)
}}
/>
<Dialog.Title className='text-xl font-bold'>
{parameters.title}
</Dialog.Title>
{parameters.description ? (
<Dialog.Description className='text-gray-500 dark:text-gray-400 mt-1'>
{parameters.description}
</Dialog.Description>
) : undefined}
{parameters.children ? (
<div className='mt-12 w-full'>
{parameters.children}
</div>
) : undefined}
<Button
variant='emphasized'
type='submit'
className={clsx(
'w-full',
parameters.children ? 'mt-4' : 'mt-12',
parameters.variant === 'danger'
? 'bg-red-800 dark:bg-red-500 focus:ring-red-500 dark:focus:ring-red-500'
: ''
)}
onClick={async () => {
if (parameters.onConfirm) {
await parameters.onConfirm()
}
setIsOpen(false)
}}
>
{parameters.buttonText ?? 'Confirm'}
</Button>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
)
}

View File

@ -1,16 +1,16 @@
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { type ReactNode } from 'react'
import { CircleSlash2 } from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
export default function Notice({ children }: { readonly children: ReactNode }) {
return (
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
)}
>
<InformationCircleIcon className='h-6 w-6 text-white'/>
{children}
</div>
)
export interface NoticeProps {
children: React.ReactNode;
}
export default function Notice({ children }: NoticeProps) {
return (
<Card className="flex w-full max-w-full gap-4 font-semibold">
<CircleSlash2 />
{children}
</Card>
);
}

View File

@ -0,0 +1,102 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });

View File

@ -0,0 +1,49 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-sm rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}

View File

@ -0,0 +1,26 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
export interface ProgressBarProps {
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
}

View File

@ -0,0 +1,83 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });

172
app/components/Select.tsx Normal file
View File

@ -0,0 +1,172 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });

View File

@ -1,23 +1,21 @@
import clsx from 'clsx'
import clsx from 'clsx';
type Properties = {
// eslint-disable-next-line unicorn/no-keyword-prefix
interface Props {
className?: string;
}
export default function Spinner(properties: Properties) {
export default function Spinner({ className }: Props) {
return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
<div className={clsx('inline-block align-middle mb-0.5', className)}>
<div
className={clsx(
'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent',
properties.className
className,
)}
role='status'
>
<span className='sr-only'>Loading...</span>
<span className="sr-only">Loading...</span>
</div>
</div>
)
);
}

View File

@ -1,24 +1,27 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import cn from '~/utils/cn';
type Properties = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
}
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
return (
<svg
className={clsx(
className,
className={cn(
isOnline
? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500'
? 'text-green-600 dark:text-green-500'
: 'text-headplane-200 dark:text-headplane-800',
className,
)}
viewBox='0 0 24 24'
fill='currentColor'
viewBox="0 0 24 24"
fill="currentColor"
>
<circle cx='12' cy='12' r='8'/>
<title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
</svg>
)
);
}

61
app/components/Switch.tsx Normal file
View File

@ -0,0 +1,61 @@
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
'aria-label': props.label,
},
state,
ref,
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input
{...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<div
aria-hidden
className={cn(
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
)}
/>
</div>
</label>
);
}

View File

@ -1,23 +0,0 @@
import { NavLink } from '@remix-run/react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
return (
<NavLink
to={to}
className={({ isActive, isPending }) => clsx(
'flex items-center gap-x-2 p-2 border-b-2 text-md',
isActive ? 'border-white' : 'border-transparent'
)}
>
{icon} {name}
</NavLink>
)
}

View File

@ -1,37 +1,34 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
function TableList(properties: HTMLProps<HTMLDivElement>) {
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
properties.className
{...props}
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
function Item(properties: HTMLProps<HTMLDivElement>) {
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
properties.className
{...props}
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
export default Object.assign(TableList, { Item })
export default Object.assign(TableList, { Item });

90
app/components/Tabs.tsx Normal file
View File

@ -0,0 +1,90 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });

11
app/components/Text.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TextProps {
children: React.ReactNode;
className?: string;
}
export default function Text({ children, className }: TextProps) {
return <p className={cn('text-md my-0', className)}>{children}</p>;
}

13
app/components/Title.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TitleProps {
children: React.ReactNode;
className?: string;
}
export default function Title({ children, className }: TitleProps) {
return (
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
);
}

View File

@ -0,0 +1,87 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
}

View File

@ -1,47 +0,0 @@
import { useToaster } from 'react-hot-toast/headless'
export default function Toaster() {
const { toasts, handlers } = useToaster()
const { startPause, endPause, calculateOffset, updateHeight } = handlers
return (
<div
className='fixed bottom-0 right-0 p-4 w-80 h-1/2 overflow-hidden'
onMouseEnter={startPause}
onMouseLeave={endPause}
>
{toasts.slice(0, 6).map(toast => {
const offset = calculateOffset(toast, {
reverseOrder: false,
gutter: -8
})
// eslint-disable-next-line @typescript-eslint/ban-types
const reference = (element: HTMLDivElement | null) => {
if (element && typeof toast.height !== 'number') {
const { height } = element.getBoundingClientRect()
updateHeight(toast.id, -height)
}
}
return (
<div
key={toast.id}
ref={reference}
className='fixed bottom-4 right-4 p-4 bg-gray-800 rounded-lg text-white transition-all duration-300'
{...toast.ariaProps}
style={{
transform: `translateY(${offset}px) translateX(${toast.visible ? 0 : 200}%)`
}}
>
{typeof toast.message === 'function' ? (
toast.message(toast)
) : (
toast.message
)}
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,83 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-none rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
});

View File

@ -1,18 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import { RemixBrowser } from '@remix-run/react'
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser/>
</StrictMode>
)
})
<HydratedRouter />
</StrictMode>,
);
});

View File

@ -1,137 +1,66 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
import { PassThrough } from 'node:stream'
import type { EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
// eslint-disable-next-line @typescript-eslint/naming-convention
const ABORT_DELAY = 5000
export default async function handleRequest(
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return isbot(request.headers.get('user-agent') ?? '')
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
}
async function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
routerContext: EntryContext,
loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
responseHeaders.set('Content-Type', 'text/html')
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? 'onAllReady'
: 'onShellReady';
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
status: responseStatusCode,
}),
);
pipe(body)
pipe(body);
},
onShellError(error: unknown) {
reject(error)
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500
// biome-ignore lint/style/noParameterAssign: Lazy
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
console.error(error);
}
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}
async function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
},
);
setTimeout(abort, ABORT_DELAY)
})
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

72
app/layouts/dashboard.tsx Normal file
View File

@ -0,0 +1,72 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
try {
await context.client.get('v1/apikey', session.get('api_key')!);
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
}
}
return {
healthy,
};
}
export default function Layout() {
const { healthy } = useLoaderData<typeof loader>();
return (
<>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

169
app/layouts/shell.tsx Normal file
View File

@ -0,0 +1,169 @@
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
return redirect('/login');
}
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
</>
);
}

View File

@ -1,73 +1,69 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node'
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import Toaster from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }
]
{
name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }
]
export async function loader() {
const context = await getContext()
registerConfigWatcher()
if (context.headscaleUrl.length === 0) {
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
}
if (!process.env.COOKIE_SECRET) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
if (!process.env.API_KEY) {
throw new Error('The API_KEY environment variable is required')
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('The HEADSCALE_CONTAINER environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
{ rel: 'stylesheet', href: stylesheet },
];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<html lang='en'>
<head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<Meta/>
<Links/>
</head>
<body className='overscroll-none dark:bg-zinc-900 dark:text-white'>
{children}
<Toaster/>
<ScrollRestoration/>
<Scripts/>
</body>
</html>
)
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
</LiveDataProvider>
);
}
export function ErrorBoundary() {
return <ErrorPopup/>
return <ErrorPopup />;
}
export default function App() {
return <Outlet/>
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
}

36
app/routes.ts Normal file
View File

@ -0,0 +1,36 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];

View File

@ -1,13 +0,0 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
<p className='text-lg mt-8'>
Access Control Lists are currently unavailable.
They will be available in a future release.
</p>
</div>
)
}

View File

@ -1,221 +0,0 @@
/* eslint-disable unicorn/no-keyword-prefix */
import {
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import {
restrictToParentElement,
restrictToVerticalAxis
} from '@dnd-kit/modifiers'
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import Button from '~/components/Button'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
type Properties = {
readonly baseDomain?: string;
readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains)
const [newDomain, setNewDomain] = useState('')
const fetcher = useFetcher()
useEffect(() => {
setLocalDomains(searchDomains)
}, [searchDomains])
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'>
Set custom DNS search domains for your Tailnet.
When using Magic DNS, your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={event => {
setActiveId(event.active.id)
}}
onDragEnd={event => {
// eslint-disable-next-line unicorn/no-null
setActiveId(null)
const { active, over } = event
if (!over) {
return
}
const activeItem = localDomains[active.id as number - 1]
const overItem = localDomains[over.id as number - 1]
if (!activeItem || !overItem) {
return
}
const oldIndex = localDomains.indexOf(activeItem)
const newIndex = localDomains.indexOf(overItem)
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
}
}}
>
<TableList>
{baseDomain ? (
<TableList.Item key='magic-dns-sd'>
<p className='font-mono text-sm'>{baseDomain}</p>
<LockClosedIcon className='h-4 w-4'/>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
// eslint-disable-next-line react/no-array-index-key
key={index}
domain={sd}
id={index + 1}
localDomains={localDomains}
disabled={disabled}
fetcher={fetcher}
/>
))}
<DragOverlay adjustScale>
{activeId ? <Domain
isDrag
domain={localDomains[activeId as number - 1]}
localDomains={localDomains}
id={activeId as number - 1}
disabled={disabled}
fetcher={fetcher}
/> : undefined}
</DragOverlay>
</SortableContext>
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNewDomain('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
)
}
type DomainProperties = {
readonly domain: string;
readonly id: number;
readonly isDrag?: boolean;
readonly localDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>;
}
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
// TODO: Figure out why TableList.Item breaks dndkit
return (
<div
ref={setNodeRef}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
)}
style={{
transform: CSS.Transform.toString(transform),
transition
}}
>
<p className='font-mono text-sm flex items-center gap-4'>
{disabled ? undefined : (
<Bars3Icon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
/>
)}
{domain}
</p>
{isDrag ? undefined : (
<Button
variant='destructive'
className='text-sm'
disabled={disabled}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
)}
</div>
)
}

View File

@ -1,49 +0,0 @@
import { useFetcher } from '@remix-run/react'
import Button from '~/components/Button'
import useModal from '~/components/Modal'
import Spinner from '~/components/Spinner'
type Properties = {
readonly isEnabled: boolean;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
variant: isEnabled ? 'danger' : 'confirm',
buttonText: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
description: 'Devices will no longer be accessible via your tailnet domain. The search domain will also be disabled.',
onConfirm: () => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
}
})
return (
<>
<Button
variant='emphasized'
className='w-fit text-sm'
disabled={disabled}
onClick={() => {
open()
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Button>
{Modal}
</>
)
}

View File

@ -1,84 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable unicorn/no-keyword-prefix */
import { Dialog } from '@headlessui/react'
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import useModal from '~/components/Modal'
import Spinner from '~/components/Spinner'
type Properties = {
readonly name: string;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: 'Rename Tailnet',
description: 'Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.',
buttonText: 'Rename',
children: (
<Input
type='text'
className='font-mono mt-4'
value={newName}
onChange={event => {
setNewName(event.target.value)
}}
/>
),
onConfirm: () => {
fetcher.submit({
'dns_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
}
})
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<Code>
[device].[user].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<Input
readOnly
className='font-mono text-sm my-4 w-1/2'
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<Button
variant='emphasized'
className='text-sm w-fit'
disabled={disabled}
onClick={() => {
open()
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Rename Tailnet...
</Button>
{Modal}
</div>
)
}

View File

@ -1,203 +0,0 @@
import { Switch } from '@headlessui/react'
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
import MagicModal from './magic'
import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const context = await getContext()
if (!context.hasConfig) {
throw new Error('No configuration is available')
}
const config = await getConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records
}
return {
...dns,
...context
}
}
export async function action({ request }: ActionFunctionArgs) {
const context = await getContext()
if (!context.hasConfigWrite) {
return json({ success: false })
}
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
return json({ success: true })
}
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('')
return (
<div className='flex flex-col gap-16 max-w-screen-lg'>
{data.hasConfigWrite ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<p className='text-gray-700 dark:text-gray-300'>
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
</p>
<div className='mt-4'>
<div className='flex items-center justify-between mb-2'>
<h2 className='text-md font-medium opacity-80'>
Global Nameservers
</h2>
<div className='flex gap-2 items-center'>
<span className='text-sm opacity-50'>
Override local DNS
</span>
<Switch
checked={localOverride}
disabled={!data.hasConfigWrite}
className={clsx(
localOverride ? 'bg-gray-800 dark:bg-gray-600' : 'bg-gray-200 dark:bg-gray-400',
'relative inline-flex h-4 w-9 items-center rounded-full'
)}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride
}, {
method: 'PATCH',
encType: 'application/json'
})
setLocalOverride(!localOverride)
}}
>
<span className='sr-only'>Override local DNS</span>
<span
className={clsx(
localOverride ? 'translate-x-6' : 'translate-x-1',
'inline-block h-2 w-2 transform rounded-full bg-white transition'
)}
/>
</Switch>
</div>
</div>
<TableList>
{data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<Button
variant='destructive'
className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
</TableList.Item>
))}
{data.hasConfigWrite ? (
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
) : undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
</div>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'>
Automaticall register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].{data.baseDomain}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
</div>
</div>
)
}

View File

@ -1,75 +0,0 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
return data.node
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
>
All Machines
</Link>
{' / '}
{data.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
/>
</Card>
</div>
)
}

View File

@ -1,171 +0,0 @@
/* eslint-disable unicorn/filename-case */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import { toast } from 'react-hot-toast/headless'
import Dropdown from '~/components/Dropdown'
import useModal from '~/components/Modal'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { del, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
return data.nodes
}
export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { id?: string }
if (!data.id) {
return json({ message: 'No ID provided' }, {
status: 400
})
}
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
})
}
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: 'Remove Machine',
description: [
'This action is irreversible and will disconnect the machine from the Headscale server.',
'All data associated with this machine including ACLs and tags will be lost.'
].join('\n'),
variant: 'danger',
buttonText: 'Remove',
onConfirm: () => {
fetcher.submit(
{
id: activeId!
},
{
method: 'DELETE',
encType: 'application/json'
}
)
}
})
return (
<>
{Modal}
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-sm font-bold'>
<th className='pl-4'>Name</th>
<th>IP Addresses</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody className={clsx(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
)}
>
{data.map(machine => {
const tags = [...machine.forcedTags, ...machine.validTags]
return (
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
<td className='py-2 pl-4'>
<Link to={`/machines/${machine.id}`}>
<h1>{machine.givenName}</h1>
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
{machine.name}
</span>
<div className='flex gap-1 mt-1'>
{tags.map(tag => (
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
{machine.ipAddresses.map((ip, index) => (
<button
key={ip}
type='button'
className='flex items-center gap-x-1 w-full'
onClick={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
{ip}
</span>
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
</button>
))}
</td>
<td className='py-2'>
<span
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
>
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
<p>
{machine.online
? 'Connected'
: new Date(
machine.lastSeen
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-4'>
<div className={clsx(
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<Dropdown
className='left-1/4 w-min'
button={(
<EllipsisHorizontalIcon className='w-5 h-5'/>
)}
>
<Dropdown.Item className='text-red-700'>
<button
type='button' onClick={() => {
setActiveId(machine.id)
open()
}}
>
Remove
</button>
</Dropdown.Item>
</Dropdown>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)
}

View File

@ -1,13 +0,0 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</div>
)
}

View File

@ -1,134 +0,0 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData, useRouteError } from '@remix-run/react'
import Dropdown from '~/components/Dropdown'
import { ErrorPopup } from '~/components/Error'
import TabLink from '~/components/TabLink'
import { getContext } from '~/utils/config'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
console.error(error)
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
// Otherwise propagate to boundary
throw error
}
const context = await getContext()
return {
...context,
user: session.get('user')
}
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
return (
<>
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
<nav className='container mx-auto'>
<div className='flex items-center justify-between mb-8 pt-4'>
<div className='flex items-center gap-x-2'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Download
</a>
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
GitHub
</a>
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<Dropdown
button={<UserCircleIcon className='w-8 h-8'/>}
>
<Dropdown.Item variant='static'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Dropdown.Item>
<Dropdown.Item className='text-red-700'>
<Form method='POST' action='/logout'>
<button type='submit'>
Logout
</button>
</Form>
</Dropdown.Item>
</Dropdown>
</div>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
{data.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
</div>
</nav>
</header>
<main className='container mx-auto overscroll-contain mb-24'>
<Outlet/>
</main>
</>
)
}
export function ErrorBoundary() {
const data = useLoaderData<typeof loader>()
const error = useRouteError()
if (!data) {
throw error
}
return (
<>
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
<nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
{data.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
</div>
</nav>
</header>
<ErrorPopup type='embedded'/>
</>
)
}

View File

@ -1,67 +0,0 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { toast } from 'react-hot-toast/headless'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, type User } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const users = new Map<string, Machine[]>()
for (const machine of data.nodes) {
const { user } = machine
if (!users.has(user.id)) {
users.set(user.id, [])
}
users.get(user.id)?.push(machine)
}
return [...users.values()].map(machines => {
const { user } = machines[0]
return {
...user,
machines
}
})
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
return (
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
{data.map(user => (
<Card key={user.id}>
<div className='flex items-center gap-4'>
<UserIcon className='w-6 h-6'/>
<span className='text-lg font-mono'>
{user.name}
</span>
</div>
<div className='py-4'>
{user.machines.map(machine => (
<div key={machine.id} className='flex items-center w-full gap-4'>
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
</div>
))}
</div>
</Card>
))}
</div>
)
}

View File

@ -1,5 +0,0 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}

View File

@ -0,0 +1,113 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
}
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
policy: policyData,
});
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -0,0 +1,64 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -0,0 +1,112 @@
import * as shopify from '@shopify/lang-jsonc';
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientOnly } from 'remix-utils/client-only';
import Fallback from './fallback';
interface EditorProps {
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
}
interface DifferProps {
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { AlertIcon } from '@primer/octicons-react';
import React from 'react';
import Card from '~/components/Card';
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to the ACL policy:
<br />
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);
}

View File

@ -0,0 +1,36 @@
import cn from '~/utils/cn';
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
return (
<div className="relative w-full h-editor flex">
<div
className={cn(
'h-full w-8 flex justify-center p-1',
'border-r border-headscale-400 dark:border-headscale-800',
)}
>
<div
aria-hidden
className={cn(
'h-5 w-5 animate-spin rounded-full',
'border-headplane-900 dark:border-headplane-100',
'border-2 border-t-transparent dark:border-t-transparent',
)}
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
value={acl}
/>
</div>
);
}

View File

@ -0,0 +1,173 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<NoticeView title="ACL Policy restricted">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</NoticeView>
) : !writable ? (
<NoticeView title="Read-only ACL Policy">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<ErrorView>{fetcher.data.error}</ErrorView>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}

186
app/routes/auth/login.tsx Normal file
View File

@ -0,0 +1,186 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}

28
app/routes/auth/logout.ts Normal file
View File

@ -0,0 +1,28 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
export async function loader() {
return redirect('/machines');
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login
? '/login?s=logout'
: '/login';
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}

View File

@ -0,0 +1,69 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
}
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}

View File

@ -0,0 +1,42 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
},
});
}

View File

@ -0,0 +1,205 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
}
export default function ManageDomains({
searchDomains,
isDisabled,
magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
'flex items-center gap-4',
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)}
>
<Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
>
<input type="hidden" name="action_id" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
}
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
}

View File

@ -0,0 +1,99 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function ManageNS({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input
type="hidden"
name="split_name"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddRecord from '../dialogs/add-record';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean;
}
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
)}
</TableList>
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
label="Tailnet name"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
/>
</Dialog.Panel>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,31 @@
import Dialog from '~/components/Dialog';
interface Props {
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,94 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input type="hidden" name="action_id" value="add_ns" />
<Input
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
isInvalid={isInvalid}
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,59 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder="101.101.101.101"
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
</div>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,232 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await context.integration?.onConfigChange(context.client);
}

110
app/routes/dns/overview.tsx Normal file
View File

@ -0,0 +1,110 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
import RenameTailnet from './components/rename-tailnet';
import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const check = await context.sessions.check(
request,
Capabilities.read_network,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_network,
);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
};
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
};
}
export async function action(data: ActionFunctionArgs) {
return dnsAction(data);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key];
}
allNs.global = data.nameservers;
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
/>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
Devices will be accessible at{' '}
<Code>
[device].
{data.baseDomain}
</Code>{' '}
when Magic DNS is enabled.
</p>
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
</div>
</div>
);
}

View File

@ -1,169 +0,0 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import Input from '~/components/Input'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const secret = context.oidcConfig?.secret
const normal = process.env.DISABLE_API_KEY_LOGIN
if (issuer && (!id || !secret)) {
throw new Error('An invalid OIDC configuration was provided')
}
const data = {
oidc: issuer,
apiKey: normal === undefined
}
if (!data.oidc && !data.apiKey) {
throw new Error('No authentication method is enabled')
}
if (data.oidc && !data.apiKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(data.oidc, id!, request)
}
return data
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = String(formData.get('oidc-start'))
if (oidcStart) {
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
// We know it exists here because this action only happens on OIDC
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(issuer!, id!, request)
}
const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie'))
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
if (!key) {
throw new Error('Invalid API key')
}
const expiry = new Date(key.expiration)
const expiresIn = expiry.getTime() - Date.now()
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24)
session.set('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays} days`
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn
})
}
})
} catch (error) {
console.error(error)
return json({
error: 'Invalid API key'
})
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className='flex min-h-screen items-center justify-center'>
<Card className='w-96'>
<h1 className='text-2xl mb-8'>Login</h1>
{data.apiKey ? (
<Form method='post'>
<p className='text-sm text-gray-500 mb-4'>
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</p>
{actionData?.error ? (
<p className='text-red-500 text-sm mb-2'>{actionData.error}</p>
) : undefined}
<Input
required
type='text'
name='api-key'
id='api-key'
className='border rounded-md p-2 w-full'
placeholder='API Key'
/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full mt-4'
>
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className='flex items-center gap-x-2 py-2'>
<hr className='flex-1 dark:border-zinc-700'/>
<span className='text-gray-500'>or</span>
<hr className='flex-1 dark:border-zinc-700'/>
</div>
) : undefined}
{data.oidc ? (
<Form method='POST'>
<input type='hidden' name='oidc-start' value='true'/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full'
>
Login with SSO
</Button>
</Form>
) : undefined}
</Card>
</div>
)
}

View File

@ -1,15 +0,0 @@
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const returnTo = new URL(request.url).pathname
return redirect(`/login?returnTo=${returnTo}`, {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}

View File

@ -0,0 +1,201 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo, Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
machine,
routes,
users,
isAgent,
magic,
stats,
isDisabled,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
const prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnetApproved: Route[];
subnet: Route[];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return (
<tr
key={machine.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm font-mono opacity-50">{machine.name}</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<CopyIcon className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p suppressHydrationWarning>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
isDisabled={isDisabled}
/>
</td>
</tr>
);
}

View File

@ -0,0 +1,142 @@
import { Cog, Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
magic,
users,
isFullButton,
isDisabled,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
return (
<>
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'rename' && (
<Rename
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'tags' && (
<Tags
machine={machine}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
<Menu.Item key="move">Change owner</Menu.Item>
</Menu.Section>
<Menu.Section>
{expired ? (
<></>
) : (
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More