Compare commits

...

285 commits

Author SHA1 Message Date
9364b4af81 fix: CSV import loading spinner, error display, modal close 2026-07-15 23:03:03 +01:00
6dedc6d432 fix: save original user data before edit, restore on recreate failure 2026-07-15 21:18:05 +01:00
4a9d5f7ede chore: update TOTP enforcement message, policy API integration ready 2026-07-15 20:51:10 +01:00
517c8849ec fix: Alpine package is 'sqlite' not 'sqlite3' 2026-07-15 19:15:23 +01:00
e1a4566cc3 feat: enforce TOTP enrollment via SQLite when email is saved 2026-07-15 14:11:39 +01:00
f4fe31c561 fix: retry recreate on edit user to avoid loss on SQLITE_BUSY 2026-07-14 06:26:29 +01:00
5cff118d0c feat: MFA enforcement - blocking overlay after email save until TFA is set up 2026-07-12 07:55:25 +01:00
44326c542d feat: MFA prompt in user settings page 2026-07-12 07:29:36 +01:00
48cda41d69 fix: add 1.5s delay between delete and recreate to avoid SQLITE_BUSY 2026-07-11 23:04:11 +01:00
6cf084b931 feat: edit user groups + email via delete+recreate 2026-07-11 22:55:08 +01:00
359a0dccb6 remove domain fallback defaults - domain must be explicitly configured 2026-07-11 22:13:47 +01:00
ae9b0b7a81 revert sender to SMTP_USER (post@nextwks.eu) - SMTP requires sender=login user 2026-07-11 22:10:36 +01:00
aa1be0d4cd fix: sender should be TLS_EMAIL (dns@nextwks.eu) not SMTP_USER 2026-07-11 22:07:26 +01:00
b32180c268 modernize Authelia config to v4.39 format, add watch:true 2026-07-11 22:01:08 +01:00
d82b1a9a6b fix: add watch:true to Authelia config so new users auto-reload 2026-07-11 21:55:10 +01:00
03ae910b3f refactor: groups simplified to users + admins only 2026-07-11 21:39:45 +01:00
1849c65c68 fix apiProxyHandler: stop stripping /api prefix, authelia-api needs it 2026-07-11 17:59:23 +01:00
a21e63c4a2 explicit AUTHELIA_API_LISTEN=0.0.0.0:8080 in compose 2026-07-11 17:51:11 +01:00
0d7defff4e fix network creation: use -f for rm, show errors on create 2026-07-11 10:14:10 +01:00
14e60151fd fixed IPs: nextwks-net on 172.18.0.0/24 with static addresses 2026-07-11 10:08:52 +01:00
62b42635b2 move AGENT.md out of repo to ~/development/, add to .gitignore 2026-07-11 09:24:47 +01:00
9f6c85ce60 v0.1.0.0033: update AGENT.md/README.md/CHANGELOG with final architecture docs 2026-07-11 09:20:29 +01:00
7303c94a3c fix bcrypt hash corruption: avoid bash -c re-expanding $ signs, auto-regenerate hash on update 2026-07-11 09:16:14 +01:00
f0c6aa1a2e always regenerate configs on --update too (picks up template changes like storage.encryption_key) 2026-07-11 09:13:22 +01:00
080341f8a8 stop containers before copying binary to avoid Text file busy 2026-07-11 09:11:27 +01:00
034e6688de firewall: apply redirects in all modes, persist across reboots 2026-07-11 09:09:48 +01:00
9ba62e4c8b add storage.encryption_key to Authelia config, fix certs directory 2026-07-11 09:08:31 +01:00
0dbb576d1b admin password: 24 chars mixed case + numbers via base64 2026-07-11 09:00:56 +01:00
336574571f admin password: 24 hex chars instead of 12 2026-07-11 09:00:08 +01:00
fc93201313 install mode: detect existing install and deflect to --update or --destroy 2026-07-11 08:57:36 +01:00
fc1a98cbfe fix .gitignore: scope nextworkspace to root to not ignore config/nextworkspace/ 2026-07-11 08:54:56 +01:00
3df375e047 refuse to run under sudo, update README/AGENT.md to match 2026-07-11 08:47:54 +01:00
b060e23a1d add tools/firewall-routing.sh, fix .env Permission denied, apply firewall from cloned repo 2026-07-11 08:42:19 +01:00
e92382266f rootless podman: ports 8080/8443, iptables redirect 80→8080 443→8443, no sudo for podman 2026-07-11 00:38:24 +01:00
3ca19403fa nextwks.sh: replace full root escalation with per-command sudo, files owned by user 2026-07-11 00:34:21 +01:00
d41d1443a8 fix launcher health check: install curl in alpine, update script + applySettings path 2026-07-11 00:28:44 +01:00
89ea56e255 nextwks.sh: chown target dirs to non-root user after deploy 2026-07-11 00:23:52 +01:00
1b24799026 nextwks.sh: add IMAP prompts, stop old containers in all modes, port conflict check, health check diagnostics 2026-07-11 00:21:21 +01:00
2999f5da47 nextwks.sh: fix SIGPIPE crash on password gen and Go version fetch under pipefail 2026-07-11 00:17:07 +01:00
d23b6c2909 nextwks.sh: auto-escalate to root via sudo instead of erroring out 2026-07-11 00:14:55 +01:00
6e95c088e4 nextwks.sh: add root check with clear error message 2026-07-11 00:13:01 +01:00
6113f62ad2 README: add explicit curl download command before install 2026-07-11 00:07:56 +01:00
81e5505f2a Unified install/update/destroy script with ephemeral build dir
- Replace deploy.sh and install.sh with tools/nextwks.sh
- Build in /tmp/nextwks-build (fresh clone every time), no more /opt/NextWks
- Script saves itself to ~/nextwks.sh on --install for easy future access
- Add AGENT.md with workflow rules for the new approach
- Secrets persisted in /opt/backup/.env (JWT, SESSION, password hash)
- Update README and CHANGELOG
2026-07-11 00:02:55 +01:00
07288f4e19 chore: bump to 0.1.0.0031 2026-07-10 23:34:19 +01:00
7b797f0785 fix: version display in admin header 2026-07-10 23:34:10 +01:00
2a255b55ec chore: bump to 0.1.0.0030 2026-07-10 23:31:06 +01:00
20cc74ae50 fix: translation loader path to /opt/nextworkspace/lng 2026-07-10 23:30:57 +01:00
b7c65d37cd chore: bump to 0.1.0.0029 2026-07-10 23:24:43 +01:00
a4b92feed9 fix: show git pull errors 2026-07-10 23:24:24 +01:00
54b2baf36c chore: bump to 0.1.0.0028 2026-07-10 23:21:42 +01:00
960ee5bc7c fix: smart update stops and removes old containers before redeploy 2026-07-10 23:21:33 +01:00
cd4c48b2f5 chore: bump to 0.1.0.0027 2026-07-10 23:03:31 +01:00
10d8d8bfc6 fix: inject AUTHELIA_SECRET into launcher compose env 2026-07-10 22:48:05 +01:00
87320e43e1 fix: auto-assign subnet, no static IPs (avoids conflicts) 2026-07-10 22:10:43 +01:00
9083119aca fix: use 10.200.0.0/24 subnet (172.16.0.0/16 is host network) 2026-07-10 22:09:46 +01:00
634cab3464 fix: static IPs 172.16.0.0/24 in stack.yaml 2026-07-10 22:05:30 +01:00
30b0b88660 fix: add :Z to caddy data volume for permission handling 2026-07-10 21:58:43 +01:00
5e3c75d564 fix: kill old Caddy processes, fix .env permissions 2026-07-10 16:47:06 +01:00
b24bc4246b fix: show podman network create errors (no silent failure) 2026-07-10 16:43:49 +01:00
12b2a787ab fix: single stack.yaml for all containers (avoids pod conflicts) 2026-07-10 16:40:39 +01:00
b555f153be fix: podman-compose down before up -d for clean state 2026-07-10 16:36:49 +01:00
414ead35d0 fix: pod cleanup loop, healthcheck in all compose files 2026-07-10 16:32:32 +01:00
8770476d61 fix: force clean all old pods before fresh deploy 2026-07-10 16:28:20 +01:00
8d2ffb503a fix: all 3 containers on internal network, NO host networking 2026-07-10 16:13:57 +01:00
11bcd78a13 fix: static binary build, debian base image, env_file 2026-07-10 15:35:35 +01:00
8561f59beb fix: use container names instead of static IPs 2026-07-10 15:26:46 +01:00
7e12e8d831 fix: simplify podman network creation (no subnet, non-silent) 2026-07-10 15:26:02 +01:00
956b9f2968 fix: add missing BINARY_NAME variable 2026-07-10 14:30:52 +01:00
079ca4bedb fix: proper teardown and health check for podman network 2026-07-10 13:58:58 +01:00
2ce600bb6d fix: podman network with fixed IPs, remove host networking 2026-07-10 13:54:33 +01:00
55e1cbece2 chore: bump to 0.1.0.0026 2026-07-10 13:20:18 +01:00
66eb350564 fix: inject Bearer token in API proxy 2026-07-10 13:19:45 +01:00
e39d1b9ebd chore: bump to 0.1.0.0025 2026-07-10 13:16:30 +01:00
31bb7771cd feat: user management in Access tab with modal 2026-07-10 13:15:48 +01:00
bb00b5cfb7 chore: bump to 0.1.0.0024 2026-07-10 13:10:44 +01:00
73bc5fa528 fix: populate Manager and TopUser dropdowns 2026-07-10 13:10:12 +01:00
b30ddacef0 chore: bump to 0.1.0.0023 2026-07-10 12:48:16 +01:00
5374473a59 fix: rename Department to Office Location 2026-07-10 12:44:59 +01:00
e34e71bf6a chore: bump to 0.1.0.0022 2026-07-10 12:37:12 +01:00
19817691b2 feat: people directory with rich profiles 2026-07-10 12:36:40 +01:00
d9f480be95 chore: bump to 0.1.0.0021 2026-07-10 12:27:28 +01:00
2e21aa8bea fix: register FuncMap for settings template 2026-07-10 12:27:19 +01:00
1e37c33603 feat: user settings page with profile, mail, preferences 2026-07-10 12:25:18 +01:00
e7566e66c7 chore: bump to 0.1.0.0020 2026-07-10 12:04:09 +01:00
ee29b28156 fix: add time import for net.DialTimeout 2026-07-10 12:03:37 +01:00
a1d4368a0a fix: chown data/caddy to master in install.sh 2026-07-10 11:59:15 +01:00
2026510572 fix: chown data/caddy to master after directory creation 2026-07-10 11:57:43 +01:00
a38e9fa7f3 chore: bump to 0.1.0.0019 2026-07-10 11:55:23 +01:00
5bff56dcda chore: bump to 0.1.0.0018 2026-07-10 11:45:06 +01:00
1ca2b028c8 feat: IMAP host/port in global settings 2026-07-10 11:44:31 +01:00
6b00f081c0 chore: bump to 0.1.0.0017 2026-07-10 11:30:32 +01:00
7868322af3 fix: run all containers as master user (rootless) 2026-07-10 11:18:02 +01:00
fc066da921 chore: bump to 0.1.0.0016 2026-07-10 10:42:00 +01:00
af85ea31e7 feat: service status indicators (Caddy, Authelia, Launcher) 2026-07-10 10:41:33 +01:00
60669b47a3 fix: add .Lang to all template translation calls 2026-07-10 10:37:40 +01:00
bde90a8186 feat: multi-language with FuncMap + lng/ files 2026-07-10 08:15:14 +01:00
45f2509fe4 chore: bump to 0.1.0.0015 2026-07-08 15:55:32 +01:00
3f3e929374 fix: use FuncMap for translations in admin template 2026-07-08 15:54:57 +01:00
207f7532be fix: roll back launcher template to simple version, no translations in templates 2026-07-08 15:53:45 +01:00
f6a18dd8e2 chore: bump to 0.1.0.0014 2026-07-08 15:51:20 +01:00
af8dc42dd6 fix: deploy lng directory, absolute translation path 2026-07-08 15:50:49 +01:00
bad04bf390 chore: bump to 0.1.0.0013 2026-07-08 15:32:25 +01:00
9790d9338a feat: global settings, multi-language (en/de), company branding on launcher + www 2026-07-08 15:32:17 +01:00
857ce833cf fix: global settings form inside admin panel sidebar 2026-07-08 15:20:41 +01:00
e1e8ad0f08 chore: bump to 0.1.0.0012 2026-07-08 15:11:07 +01:00
3e04d8f1c0 feat: SMTP config, install prompts, API proxy, global settings page 2026-07-08 15:07:59 +01:00
707eb9ec81 feat: admin panel dashboard layout with sidebar navigation 2026-07-08 14:35:50 +01:00
ee9e533485 fix: admin panel with vertical split layout (sidebar + content) 2026-07-08 14:26:29 +01:00
d699dd34c4 fix: AUTHELIA_SECRET added to .env in both greenfield and smart update 2026-07-08 14:03:52 +01:00
f5744a65d1 fix: add Remote-Groups to Caddy copy_headers 2026-07-08 14:00:39 +01:00
fcacc28f55 chore: bump to 0.1.0.0011 2026-07-08 13:52:53 +01:00
3332bc3d59 feat: settings page with admin panel link for admins 2026-07-08 13:52:45 +01:00
22958d09e7 chore: bump to 0.1.0.0010 2026-07-08 13:49:34 +01:00
f45132962c fix: admin panel — passwordless create, show generated password, user table 2026-07-08 13:49:26 +01:00
993514d92e chore: bump to 0.1.0.0009 2026-07-08 13:46:22 +01:00
594683dbf8 feat: RBAC with Authelia groups + filtered launcher + admin panel 2026-07-08 13:46:02 +01:00
7927addcf7 chore: bump to 0.1.0.0008 2026-07-08 13:34:16 +01:00
80a5579537 docs: readme + changelog for Caddy/Authelia milestone 2026-07-08 13:33:57 +01:00
a56a485f42 fix: podman pod cleanup before fresh deploy 2026-07-08 10:31:24 +01:00
f3d0259bd4 chore: bump to 0.1.0.0007 2026-07-08 10:27:26 +01:00
9b71a13197 fix: backup/restore Caddy certs to /opt/backup/certificates/ 2026-07-08 10:27:18 +01:00
f8468270af chore: bump to 0.1.0.0006 2026-07-08 10:17:57 +01:00
8f25bc5d63 fix: use git24hcom/authelia:latest image 2026-07-08 10:09:41 +01:00
11e893eb02 chore: bump to 0.1.0.0005 2026-07-08 10:07:12 +01:00
ab8e46b3a4 fix: sed delimiter | instead of / to avoid conflicts 2026-07-08 10:07:05 +01:00
07aff0ba28 chore: bump to 0.1.0.0004 2026-07-08 09:56:58 +01:00
c252fb17ed fix: install.sh creates Caddy/Authelia dirs instead of Zoraxy 2026-07-08 09:54:53 +01:00
a287a67fd1 feat: Caddy + Authelia replace Zoraxy 2026-07-08 09:54:39 +01:00
e52b7e6915 fix: CSRF extraction looks for specific zoraxy.csrf.Token 2026-07-08 09:28:54 +01:00
94ae185d47 chore: bump to 0.1.0.0003 2026-07-08 09:13:08 +01:00
61b1587cd6 fix: only app subdomain active 2026-07-08 09:12:47 +01:00
09a74425b3 fix: only app+dns active, www+auth empty, dynamic domain lists 2026-07-08 09:11:02 +01:00
b948399d1d fix: cert upload uses multipart/form-data with cert+key+domain 2026-07-08 09:09:11 +01:00
d420f179a2 chore: remove committed binary, add to gitignore 2026-07-08 08:51:31 +01:00
aa29d363ce fix: use Go tool for cert upload, fix apps.yaml YAML format 2026-07-08 08:51:23 +01:00
6ea3164d72 feat: stateless API token workflow for Zoraxy 2026-07-08 08:42:40 +01:00
abeae6cbbf chore: bump to 0.1.0.0002 2026-07-07 19:29:29 +01:00
5d30facbcc fix: verbose cert upload + health check debug 2026-07-07 19:29:05 +01:00
dea385a71c chore: version format 0.1.0.0001 2026-07-07 19:22:20 +01:00
7a44f112a5 chore: bump to v0.1.1, update changelog, versioning rule in AGENT.md 2026-07-07 19:20:57 +01:00
48241364aa fix: proper workflow — deploy Zoraxy minimal, upload certs, stop, write configs, restart 2026-07-07 19:17:54 +01:00
f44041f0d5 debug: add CSRF token debug output 2026-07-07 18:51:01 +01:00
b6938d3800 fix: clear cookie jar before each cert upload attempt 2026-07-07 18:44:12 +01:00
d6e0387b1c fix: install.sh sets up git repo + overlays current code 2026-07-07 18:40:21 +01:00
b0325267db fix: wait for Zoraxy before cert upload, verbose retry messages 2026-07-07 18:33:39 +01:00
b684242eb9 fix: hardcode subdomains to hub/noc/www/auth 2026-07-07 18:27:15 +01:00
ea82a51307 fix: cert upload timeout, email validation, completion message 2026-07-07 18:21:44 +01:00
efaa917013 feat: LE_DRY_RUN=true skips LE, generates self-signed 2026-07-07 18:20:21 +01:00
c426b711df feat: configurable subdomains via APP_SUBDOMAIN, DNS_SUBDOMAIN, etc. 2026-07-07 18:18:14 +01:00
a3b31ed2ab fix: reduce lego timeout to 30s 2026-07-07 18:11:59 +01:00
1705d1e32d fix: 60s lego timeout, stop on first failure, fall back to self-signed 2026-07-07 18:06:14 +01:00
1298589afa fix: handle existing non-git repo dir (from install.sh copy) 2026-07-07 18:00:44 +01:00
ec739975ec fix: add 2s delay between per-domain lego runs 2026-07-07 17:54:09 +01:00
8b2301f3a4 fix: request individual certs per domain instead of SAN cert 2026-07-07 17:53:48 +01:00
93f853bd40 fix: 12-char password, cert upload API with CSRF retry 2026-07-07 17:17:00 +01:00
47d66f476d fix: alphanumeric password only (no special chars), safe for .env and systemd 2026-07-07 17:11:58 +01:00
585d4e6ec8 fix: install.sh no presets, random password, copies local code instead of cloning 2026-07-07 17:06:54 +01:00
e03cb76017 fix: write BoltDB before Zoraxy starts (db not locked) + remove duplicate 2026-07-07 16:47:27 +01:00
fa9537bcb5 fix: longer wait for sys.db, fallback to touch + userCount check 2026-07-07 16:33:49 +01:00
c42807d076 refactor: configure Zoraxy via BoltDB + config files, no API/CSRF 2026-07-07 16:19:34 +01:00
90c8c57fbe debug: show login.html response when CSRF fetch fails 2026-07-07 16:12:30 +01:00
b1004e4514 fix: BoltDB fallback for admin creation if CSRF API fails 2026-07-07 16:12:05 +01:00
fd1fa7dbeb fix: CSRF token — clear jar before first fetch, preserve session across calls 2026-07-07 16:08:20 +01:00
710781bc47 fix: surface admin registration result instead of swallowing it 2026-07-07 16:05:31 +01:00
b2e3086e10 fix: upload existing LE certs to Zoraxy via API after deploy 2026-07-07 15:51:25 +01:00
13c1bd2d13 fix: set lego path to /opt/backup to avoid double certificates/ nesting 2026-07-07 15:36:23 +01:00
5f46d3c2b8 refactor: rename backup dir to /opt/backup/certificates/ 2026-07-07 15:33:22 +01:00
6d87718b43 refactor: lego outputs to /opt/backup/certs/, one backup directory 2026-07-07 15:27:30 +01:00
c7d25c0107 fix: fallback to lego SAN cert directory if backup missing 2026-07-07 15:24:03 +01:00
881b94099c fix: handle SAN certs from lego, check both backup and lego cache 2026-07-07 15:23:40 +01:00
1eb470fa12 fix: lego v5 flags — use --http.address, -m for email, -d for domains 2026-07-07 15:14:46 +01:00
d144fcdc28 fix: lego v5 = syntax for flags 2026-07-07 15:14:13 +01:00
fbbf1732b3 fix: lego v5 arg order (flags after run command) 2026-07-07 15:12:58 +01:00
5aca6025c5 chore: remove committed binary, add to gitignore 2026-07-07 15:05:59 +01:00
5f168d21f1 feat: replace autocert with lego CLI for reliable LE cert issuance 2026-07-07 15:05:41 +01:00
02bab22a5d fix: build tool from script directory (correct go.mod context) 2026-07-07 14:57:30 +01:00
533b6f4137 fix: full service teardown (stop+disable+remove) + port 80 wait 2026-07-07 14:53:00 +01:00
434b23aee2 fix: tool detects LE vs self-signed, retries LE for fallback certs 2026-07-07 14:45:45 +01:00
63099703a1 fix: stop Zoraxy before running tool to free port 80 for ACME 2026-07-07 14:44:13 +01:00
360112c7b7 fix: remove old zoraxy container before deploying 2026-07-07 14:33:28 +01:00
2e7a0e3645 refactor: /opt/backup/ vault for .env + certs, clean destroy flow 2026-07-07 14:30:48 +01:00
b0a3cd3206 fix: tool only manages backup, preservation handles cert deployment 2026-07-07 14:14:36 +01:00
25c0e7b9b7 docs: remove --destroy blocker — tool handles cert backup/restore 2026-07-07 12:57:58 +01:00
2398bdce83 feat: nextwks-tool for LE + DB + Zoraxy Auth 2026-07-07 12:53:57 +01:00
d73b67614e fix: proxy www through binary for ACME challenge support 2026-07-07 12:22:11 +01:00
038d923a11 fix: include www in LE cert auto-generation loop 2026-07-07 12:19:17 +01:00
4e50bf15dd fix: www files go to html/ subdirectory (Zoraxy static root) 2026-07-07 12:13:06 +01:00
ddef538795 feat: www landing page with component listing 2026-07-07 12:09:25 +01:00
7b3942658d docs: add 5s safety warning before --destroy 2026-07-07 12:06:12 +01:00
79bf23618d fix: proper Zoraxy CSRF/session handling (login.html + cookie jar) 2026-07-07 12:02:59 +01:00
d0f9854084 fix: bypass global TLS for dns subdomain (HTTP without redirect) 2026-07-07 11:04:12 +01:00
ccd362a964 fix: hoist COOKIE_JAR outside function to avoid unbound variable 2026-07-07 10:56:17 +01:00
acd9671359 fix: preserve Zoraxy certs across greenfield destroy 2026-07-07 10:52:16 +01:00
eabbcdaa2d fix: request LE certs for all subdomains (app + dns) on greenfield 2026-07-07 10:47:38 +01:00
22471e479b fix: unlock immutable files before destroy, timeout LE cert request 2026-07-07 10:34:36 +01:00
8ee09fb229 chore: version 0.1.0 with changelog 2026-07-07 10:29:59 +01:00
d2fa6c7365 fix: robust Zoraxy admin creation + LE automation with proper CSRF 2026-07-07 10:21:24 +01:00
47efc2e62d fix: lock Zoraxy proxy configs with immutable flag (chattr +i) 2026-07-07 09:56:39 +01:00
4416f15797 fix: Zoraxy config expansion — use full schema with origin IP 2026-07-07 09:53:29 +01:00
f9b1b3aa7b fix: preserve .env across greenfield destroy 2026-07-07 09:16:12 +01:00
8366164155 fix: restore dns config, Zoraxy admin, LE automation 2026-07-07 09:13:07 +01:00
49ac348694 feat: combined launcher + auth-proxy with path-based routing 2026-07-06 20:58:33 +01:00
c54c01d609 fix: write SSO redirect URL directly to BoltDB 2026-07-06 19:55:10 +01:00
9bc8310db1 fix: CSRF token handling with follow redirects and cookie jar 2026-07-06 19:04:39 +01:00
57defd7a63 feat: automated LE + ZorxAuth SSO via deploy API 2026-07-06 18:57:12 +01:00
91232a7beb fix: dns.nextwks.eu use AuthMethod 0 (no SSO) 2026-07-06 18:12:51 +01:00
7a4328040b feat: interactive install, .env secrets, Zoraxy admin API 2026-07-06 18:06:09 +01:00
2798485a2c fix: Zoraxy config path (conf/proxy/), origin IP, filename 2026-07-06 16:57:34 +01:00
7fdf6692df feat: Zoraxy proxy + launcher rewrite 2026-07-06 16:49:57 +01:00
811c0c75f7 feat(install): add podman to system deps 2026-07-06 15:57:48 +01:00
897275c659 fix: subdomain-based certs only, unknown subdomains return error 2026-07-06 15:52:51 +01:00
672653f731 feat: deploy modes (--destroy) + HTTPS with Let's Encrypt 2026-07-06 15:45:23 +01:00
29b9f3c159 feat(deploy): add --destroy flag and smart update modes 2026-07-06 15:36:48 +01:00
cd15d294a1 feat: subdomain router with certmagic integration 2026-07-06 15:25:21 +01:00
2d3832df0b feat: hello world pipeline proof 2026-07-06 10:28:20 +01:00
937250fa28 fix: serve greeting at root when Authelia is not deployed
- Root handler now serves the greeting boilerplate for all paths
- Auth gate (combinedAuth) preserved but suppressed for now
- When Authelia is deployed, re-enable by switching launcher handler
- Makes the app testable without its auth dependency
2026-07-06 09:20:12 +01:00
032c07beef fix(proxy): auth middleware only for exact /, unmatched paths get greeting
- Move combinedAuth inside the launcher handler path check
- Unmatched proxy paths (not exactly /) now correctly serve NotFoundHandler greeting
- Root / remains auth-gated as intended
2026-07-06 09:08:25 +01:00
e575b4eeb0 feat: self-signed TLS fallback on :443 + proxy as central router
- Add CertFile/KeyFile fields to TLSConfig (config.go)
- File-based TLS fallback: when cert_file+key_file set, ListenAndServeTLS on :443
  while keeping HTTP on configured port (certmagic ACME is non-fallback path)
- deploy.sh: enable TLS by default, generate self-signed cert during deploy
- deploy.sh: change default port 8080 → 80 (reverse proxy standard)
- deploy.sh: add cert_file/key_file to config template
- proxy as central router fix (handler.go: ServeHTTP + StaticHandler methods)
2026-07-06 08:59:12 +01:00
b069cab1e3 fix(deploy): default server port to 80 (reverse proxy standard)
- Change config template in deploy.sh from port 8080 to port 80
- A reverse proxy must listen on port 80 for public HTTP traffic
- CAP_NET_BIND_SERVICE allows non-root binding to low ports
2026-07-06 08:54:58 +01:00
1a7c99988b fix(install): align install path to /opt/nextworkspace/ (blueprint-compliant)
- Fix INSTALL_DIR in install.sh: /opt/nextwks → /opt/nextworkspace/
- All deployment scripts now consistently use /opt/nextworkspace/
2026-07-06 07:50:02 +01:00
1efeb05f4f deploy: production deploy script + path alignment to /opt/nextworkspace/
- Add deploy.sh: idempotent production deploy (scaffold, build, systemd, health check)
- Fix update.sh: align all paths from /opt/nextwks/ to /opt/nextworkspace/ (blueprint-compliant)
- deploy.sh safe for first-time setup and subsequent updates
- Creates full blueprint directory tree: config/, data/, logs/, src/core/
2026-07-05 18:19:33 +01:00
3560e9b5b6 chore: remove unwanted files 2026-07-05 10:06:22 +01:00
b8db1f87f8 fix(auth): state carries target URL, cross-subdomain cookie domain 2026-06-15 20:18:26 +00:00
94258749dc fix(auth): admin redirects to OIDC login when no session 2026-06-15 20:15:12 +00:00
bcfe28b16b chore: bump version to 2026.6.0007 2026-06-15 20:10:53 +00:00
43b32cc881 feat(auth): /access callback, nextwks.eu cookie domain 2026-06-15 20:06:34 +00:00
7d432d0af5 fix(apps): all tiles clickable, open in new tab 2026-06-15 20:01:10 +00:00
151ddc6f63 fix(apps): 4 tiles clickable (Admin + WorkSheets/TypeWriter/DeckCreator/NotesFiles) 2026-06-15 19:53:37 +00:00
5eb5399088 fix(apps): simple onclick with data-url, all tiles clickable 2026-06-15 19:42:10 +00:00
445c49e77c fix(apps): data-url onclick for tiles 2026-06-15 19:41:27 +00:00
29782180d9 feat(apps): auto-register app routes from DefaultApps 2026-06-15 19:34:36 +00:00
c24bdc4305 feat(apps): split Office into WorkSheets, TypeWriter, DeckCreator, NotesFiles 2026-06-15 19:33:51 +00:00
7c13808527 feat(apps): placeholder app pages for files, mail, calendar, office, contacts, tasks, chat 2026-06-15 19:12:36 +00:00
dd1e963357 chore: bump version to 2026.6.0006 2026-06-15 09:51:58 +00:00
a8172324b7 feat(proxy): NextWks proxies /auth/* to Authelia internally 2026-06-15 09:49:53 +00:00
95308c880b refactor(auth): single-domain setup, remove cross-domain cookie hackery 2026-06-15 09:27:34 +00:00
9f7e4c8a68 fix(auth): close div tag in logout template 2026-06-15 09:17:52 +00:00
b3d015a26a fix(authelia): bypass access control for /auth/logout to allow redirect 2026-06-15 09:08:23 +00:00
917dbb0844 feat(auth): logout via Authelia with redirect back to confirmation page 2026-06-15 08:32:26 +00:00
312aa388d4 feat(auth): custom logout page with Authelia sign-out and re-login button 2026-06-15 08:24:13 +00:00
594cf3efe2 fix(auth): only clear NextWks cookie on logout, cant cross-domain 2026-06-15 08:19:44 +00:00
d0dbe9b472 feat(auth): kill Authelia session cookie on logout 2026-06-15 08:15:57 +00:00
00a252b2e9 fix(auth): simple logout - clear cookie, redirect to workspace 2026-06-15 08:14:04 +00:00
6bec565b7a feat(auth): force re-login after logout via prompt=login 2026-06-15 08:12:32 +00:00
49c4254594 fix(auth): logout redirects to workspace, which redirects to Authelia login 2026-06-15 08:10:35 +00:00
2489ac589e fix(auth): force re-login via prompt=login for proper logout 2026-06-15 08:07:54 +00:00
bc7d546e64 fix(auth): redirect back to workspace after Authelia logout 2026-06-15 08:02:49 +00:00
b5ed7e2364 fix(auth): change SameSite to Lax for redirect compatibility 2026-06-15 07:08:24 +00:00
44fec75b42 fix(auth): redirect to Authelia /logout instead of end-session 2026-06-15 06:48:03 +00:00
8b2ab0c776 fix(auth): proper logout with domain cookie and Authelia end-session 2026-06-15 06:46:10 +00:00
2200ff657a fix(auth): set cookie domain for cross-subdomain OIDC flow 2026-06-15 06:44:10 +00:00
3b3cc7baa4 feat(email): SMTP welcome email on user creation 2026-06-15 06:38:11 +00:00
8bbdaf54ac feat(ui): show success message on user/group save, auto-refresh list 2026-06-15 06:35:02 +00:00
ecb3cbe442 fix(update): only wipe DB with --wipe-db flag, preserve data by default 2026-06-15 06:24:39 +00:00
8eca6d0ed4 feat(users): tag-style group selector with chips for user edit/create 2026-06-15 06:15:01 +00:00
19f1cf2c2b feat(users): edit form with role/group edit, delete on edit page 2026-06-15 06:04:58 +00:00
f193765dad feat(users): group checkboxes in create form, edit buttons, role dropdown 2026-06-15 05:59:35 +00:00
0e707cdae6 feat(groups): edit form with description update, delete on edit page 2026-06-15 05:52:13 +00:00
39b37c704e refactor(groups): remove app selection from group form, App→Group model later 2026-06-15 05:46:29 +00:00
ac72c77a85 feat(groups): add edit button placeholder to group rows 2026-06-15 05:43:50 +00:00
46474981f0 feat(groups): app permission checkboxes in create group form 2026-06-15 05:39:32 +00:00
9df43d8423 feat(admin): groups page with create/list/delete, split pane layout 2026-06-15 05:29:29 +00:00
b6e4345316 feat(admin): split-pane user management with list and detail form 2026-06-15 05:23:46 +00:00
56acb3b763 fix(ui): always render both DB divs with proper display style, fix JS toggle 2026-06-15 05:18:54 +00:00
10d6fd8a5b fix(ui): use if/else blocks for DB field visibility 2026-06-15 05:14:40 +00:00
9034cdd107 fix(ui): use server-rendered inline styles for DB fields visibility 2026-06-15 05:08:31 +00:00
be566b2a88 fix(ui): run toggleDBFields on page load to hide incorrect db fields 2026-06-15 05:07:34 +00:00
8c15dbed35 feat(config): OIDC auth section with callback URL, client ID, scopes 2026-06-15 05:04:24 +00:00
ef9510704f feat(config): database settings (SQLite/MariaDB) in global settings 2026-06-15 04:57:24 +00:00
6944223d35 feat(config): locale settings (language/timezone) in global page and config 2026-06-15 04:55:10 +00:00
5663364b12 refactor: remove proxy IP from installer, config, and global settings 2026-06-15 04:51:49 +00:00
9edd6f1038 fix(admin): read int values from YAML for port fields 2026-06-15 04:45:36 +00:00
64ddd71358 fix(admin): add IMAP and proxy to config, store during install 2026-06-15 04:42:05 +00:00
9813124ba7 feat(admin): global settings page for config editing 2026-06-15 04:34:49 +00:00
da575701e4 feat(admin): left sidebar layout, dark theme, role column 2026-06-14 18:54:47 +00:00
863b87ffb7 fix(ui): fix app click URL interpolation via data-url attribute 2026-06-14 18:51:08 +00:00
a12d860523 fix(ui): data-url attribute for app cards, openApp reads URL 2026-06-14 18:50:41 +00:00
73d3536ad6 fix(ui): admin opens in new tab via window.open 2026-06-14 18:49:04 +00:00
b7e5467369 feat(i18n): EN/DE translations, PageCtx, slide-out settings drawer 2026-06-14 18:21:54 +00:00
16b933bb07 fix(ui): slide-out settings drawer instead of modal 2026-06-14 18:15:13 +00:00
936c3e6146 fix(update): show commit hash in output to verify code changes 2026-06-14 18:12:52 +00:00
3e32968df8 feat(ui): settings modal (lang/timezone), fix app navigation, user dropdown 2026-06-14 18:10:15 +00:00
889329c52e fix(ui): user dropdown with logout, fix script injection, pink outlines 2026-06-14 18:04:58 +00:00
9527eb0ee4 fix(ui): use @ syntax for component calls, fix CSS/JS not rendering 2026-06-14 18:02:49 +00:00
ed1b196a75 fix(ui): force regenerate templates, pink outline to verify CSS updates 2026-06-14 18:00:18 +00:00
70 changed files with 3269 additions and 5860 deletions

6
.gitignore vendored
View file

@ -1,4 +1,5 @@
# Binaries
/nextworkspace
app/core
app/core.exe
app/data/*.db
@ -17,6 +18,9 @@ Thumbs.db
*.swp
*.swo
# AI / Agent config (stored at project root ~/development/)
AGENT.md
# Environment
.env
.env.local
@ -24,3 +28,5 @@ Thumbs.db
# Temp
tmp/
*.tmp
tools/nextwks-tool/nextwks-tool
tools/register-certs/register-certs

184
CHANGELOG.md Normal file
View file

@ -0,0 +1,184 @@
# Changelog
## 0.1.0.0048 — 2026-07-15
### Fixed
- CSV import: loading spinner with "Importing..." message during upload
- CSV import: better error display and proper modal close after completion
- Admin panel: Import modal shows results and allows closing on success/failure
## 0.1.0.0046 — 2026-07-11
### Added
- CSV bulk user import in Access tab — download template, fill data, upload
- `/api/templates/users.csv` — sample CSV template download
- `/api/users/import` — CSV import handler that parses and creates users via authelia-api
## 0.1.0.0045 — 2026-07-11
### Changed
- MFA enforcement: after saving email in settings, if TOTP is not enabled, a blocking overlay forces the user to set up two-factor on the Authelia portal before proceeding
## 0.1.0.0044 — 2026-07-11
### Added
- MFA/TOTP check on user settings page — shows setup prompt if no authenticator is configured
- `/api/user/mfa-status` endpoint — checks Authelia for TOTP enrollment status
## 0.1.0.0043 — 2026-07-11
### Added
- Edit user button in Access tab — admin can change email and groups (delete + recreate approach)
- Edit user modal with email, groups fields, and new password display
## 0.1.0.0039 — 2026-07-11
### Changed
- Modernized Authelia config format (fixes all deprecation warnings):
- `server.address: tcp://0.0.0.0:9091` (replaces `host` + `port`)
- `identity_validation.reset_password.jwt_secret` (replaces `jwt_secret`)
- `notifier.smtp.address: submission://...` (replaces `host` + `port`)
- `authentication_backend.file.watch: true` (auto-reload on user changes)
- `session.remember_me` (replaces `remember_me_duration`)
## 0.1.0.0038 — 2026-07-11
### Fixed
- Authelia `authentication_backend.file.watch: true` — YAML changes now auto-reload, so new users can log in immediately after creation
### Investigation: User Onboarding Emails
- SMTP config is correct (`notifier.smtp` → `smtp.openxchange.eu:587`)
- SMTP connection test passed (TLS handshake successful)
- authelia-api does NOT send onboarding emails — returns `placeholder_password` in API response instead
- This is an API feature gap, not a configuration issue
## 0.1.0.0037 — 2026-07-11
### Changed
- Simplified groups model: per-app groups (`drive`, `office`, `chat`, etc.) replaced with `users` + `admins` only
- `config/authelia/configuration.yml` — access_control rules reduced from 12 rules to 4
- `config/authelia/users_database.yml` — master user groups simplified to `admins`, `users`
- `config/nextworkspace/apps.yaml` — all user-facing apps use `groups: ["users"]`
- Admin panel user creation form — 9 checkboxes replaced with 2 (User + Admin)
## 0.1.0.0036 — 2026-07-11
### Fixed
- Admin panel user management: `apiProxyHandler` was stripping `/api` prefix before forwarding to authelia-api, causing 404 on all `/api/users` calls. Removed the `TrimPrefix` — authelia-api expects the full `/api/...` path.
## 0.1.0.0035 — 2026-07-11
### Added
- `AUTHELIA_API_LISTEN=0.0.0.0:8080` explicitly set in compose (default already correct)
## 0.1.0.0034 — 2026-07-11
### Added
- Fixed subnet `172.18.0.0/24` for `nextwks-net`
- Static IPv4 addresses for all containers (Caddy `.10`, Authelia `.11`, Launcher `.12`)
### Changed
- `compose/stack.yaml`: network config uses `ipv4_address` instead of flat list
- `tools/nextwks.sh`: network creation now uses `--subnet 172.18.0.0/24`
## 0.1.0.0033 — 2026-07-11
### Added
- `tools/firewall-routing.sh` — iptables redirects + VM firewall
- `storage.encryption_key` to Authelia config (required by v4.38+)
- Auto-detection of existing install in `--install` mode
### Changed
- **Rootless Podman**: all container commands run without sudo
- **Ports**: Caddy binds to 8080/8443, iptables redirects 80/443
- `.gitignore`: `/nextworkspace` (root-scoped) to track `config/nextworkspace/`
- Configs regenerated on every mode (install/update/destroy)
- `.env` values single-quoted, written via `tee -a` to preserve `$` in bcrypt hashes
- Admin password: now 24 mixed-case alphanumeric chars (base64)
- Containers stopped before binary copy to avoid "Text file busy"
- Firewall rules persisted via `netfilter-persistent save`
- Docs: AGENT.md, README.md fully updated
### Fixed
- `SSL_ERROR_INTERNAL_ERROR_ALERT` — Authelia now starts with proper config
- Password hash corruption — `$2a$...` no longer mangled by `bash -c`
- "Text file busy" during `--update` — containers stopped before copy
- `--update` skipped config regeneration (now always regenerates)
## 0.1.0.0032 — 2026-07-11
### Added
- `tools/nextwks.sh` — unified install/update/destroy script
- `AGENT.md` — workflow instructions for agents
### Changed
- Replaced `deploy.sh` and `install.sh` with single `tools/nextwks.sh`
- Build moved from `/opt/NextWks` (persistent git repo) to `/tmp/nextwks-build` (ephemeral clone)
- README.md updated for unified script workflow
- `SESSION_SECRET` persisted in `/opt/backup/.env` for idempotent `--destroy`
### Removed
- `deploy.sh` (replaced by `tools/nextwks.sh --update / --destroy`)
- `install.sh` (replaced by `tools/nextwks.sh --install`)
## 0.1.0.0007 — 2026-07-08
### Added
- Caddy reverse proxy (auto LE TLS, subdomain routing, forward auth)
- Authelia OIDC provider (2FA, identity store, user management)
- `compose/caddy.yaml` and `compose/authelia.yaml`
- `config/caddy/Caddyfile` with `{DOMAIN}` template
- `config/authelia/configuration.yml` with secret injection
- `tools/hash-password/` for bcrypt password hashing
- Certificate backup to `/opt/backup/certificates/` across destroys
- README.md with architecture overview
### Changed
- Replaced Zoraxy entirely with Caddy + Authelia
- Binary trusts `Remote-User` header from Caddy forward auth
- deploy.sh rewritten for Caddy/Authelia deployment
- install.sh creates Caddy/Authelia directories
### Removed
- Zoraxy compose, configs, proxy rules
- `tools/nextwks-tool` (no longer needed)
- `tools/register-certs` (no longer needed)
- BoltDB logic, `chattr +i`, CSRF handling
- All Zoraxy-specific deployment code
## 0.1.0.0001 — 2026-07-07
### Fixed
- Deploy workflow: start Zoraxy → upload certs via API → stop → write proxy configs → restart
- Lego runs per-domain instead of SAN cert
- Removed CSRF token issues by separating config phases
- `.env` quoting for special chars, email validation in install.sh
- Configurable subdomains (hub/noc/www/auth)
### Changed
- LE certs backed up to `/opt/backup/certificates/`, persistent across `--destroy`
- Helper tool `nextwks-tool` for LE (lego) + BoltDB operations
- Binary simplified: no login form, trusts `X-Forwarded-User` from Zoraxy
## 0.1.0 — 2026-07-06
### Added
- Interactive `install.sh` with prompts for domain, TLS email, admin username
- `.env` vault at `/opt/nextworkspace/.env` for secrets management
- Zoraxy container deployment with automated admin account creation
- Let's Encrypt configuration (email, auto-renew) via Zoraxy API
- Combined binary with path-based routing, HMAC-session auth, login form, logout
- `app.{domain}` subdomain → binary on `:9000` (launcher + auth)
- `dns.{domain}` subdomain → Zoraxy admin on `:8000`
- Health endpoint at `/health`
- `deploy.sh` with `--destroy` (greenfield) and smart update modes
### Fixed
- Zoraxy config expansion — configs locked with `chattr +i` to prevent overwrite
- CSRF token handling for Zoraxy admin API calls
### Changed
- Replaced Go subdomain router + certmagic with Zoraxy reverse proxy
- Replaced Authelia OIDC with Zoraxy built-in admin interface
- Replaced subdomain-per-app with single `app.{domain}` path-based routing
- Deployment target consolidated to `/opt/workspace/`

219
README.md
View file

@ -1,180 +1,85 @@
# Next Workspace (NextWks)
# NextWorkspace
A self-hosted workspace platform — Google Workspace-like experience with integrated identity management, admin control plane, and a pluggable module system.
## Features
- **Workspace Launcher** — Dynamic app grid dashboard with PWA install support (desktop + mobile)
- **User Management** — SQLite-backed user CRUD with Authelia YAML synchronization
- **OIDC Authentication** — Delegated auth via Authelia (v4.38) with session cookies
- **Admin Panel** — Templ + HTMX admin UI with bearer token API
- **PWA Shell** — Manifest, service worker, offline support, install-to-desktop guide
- **Zero-CGO SQLite** — Single-binary deployment with no system dependencies
- **Pluggable Modules** — Architecture ready for drop-in apps (Office, Files, Calendar, etc.)
A self-hosted productivity suite for startups. One binary + Caddy + Authelia.
## Architecture
```
┌──────────────────┐
│ Zoraxy Proxy │
│ (TLS + routing) │
└────┬─────────┬───┘
│ │
┌────────────▼──┐ ┌──▼──────────────┐
│ Authelia │ │ NextWks Core │
│ :9091 (OIDC) │ │ :8080 (App) │
│ │ │ │
│ users_db.yml │◄─┤ core/admin/ │
│ config.yml │ │ core/ui/ │
└────────────────┘ │ core/auth/ │
└──────────────────┘
Internet :443 ──iptables──> :8443 ──> Caddy container :443
Internet :80 ──iptables──> :8080 ──> Caddy container :80
Caddy (rootless podman, nextwks-net)
├── auth.{DOMAIN} ──> Authelia :9091 (internal)
├── app.{DOMAIN} ──> Launcher :9000 (forward auth via Authelia)
└── www.{DOMAIN} ──> static files
Authelia :9091 ──> api :8080 (internal)
Launcher :9000 ──> /config, /people, /settings, /health
```
## Repository Structure
- **Caddy**: TLS termination (ZeroSSL/LE), subdomain routing, forward auth to Authelia
- **Authelia**: OIDC provider, 2FA, identity store, user management API
- **Launcher**: Go binary — app dashboard, people directory, admin panel, settings
- **iptables**: Redirects 80→8080 and 443→8443 so Caddy can run rootless
```
NextWks/
├── src/ # Go source code (github.com/lexton-it/NextWks)
│ ├── main.go # Entry point (-config flag)
│ ├── cmd/setupcheck/ # Path verification tool
│ └── core/
│ ├── config/ # YAML config parser
│ ├── db/ # SQLite driver + auto-migrations
│ ├── admin/ # User CRUD + Authelia sync + Templ UI
│ ├── auth/ # OIDC client + session store
│ ├── ui/ # Launcher, app grid, PWA templates
│ ├── api/ # gRPC proto definitions (future)
│ ├── modules/ # Drop-in app sources (future)
│ └── supervisor/ # Module process manager (future)
├── app/ # Dev distribution
│ ├── core # Compiled binary
│ ├── config.yaml # Dev configuration
│ ├── data/ # SQLite database (dev)
│ └── static/ # PWA assets (manifest, SW, icons)
├── scripts/
│ └── install-authelia.sh # Authelia deployment script
├── install.sh # Production installer
└── testdata/ # Test fixtures
```
## Quick Start
### Prerequisites
- Linux (amd64) — tested on Debian/Ubuntu, Proxmox LXC
- Go 1.22+ (installed automatically if missing)
- `git`, `curl`, `openssl` (standard tools)
### Production Install
## Quick Start (Bare VM)
```bash
curl -fsSL https://git.lohmar.co.uk/lexton-it/NextWks/raw/main/install.sh -o install.sh
sudo bash install.sh
# Download the script to your home folder
curl -o ~/nextwks.sh https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh
chmod +x ~/nextwks.sh
# Run the installer (no sudo — it'll ask only where needed)
./nextwks.sh --install
```
The installer walks you through:
- Email configuration (SMTP/IMAP)
- Admin user creation
- URL setup (workspace + auth + proxy)
- All secrets auto-generated
- Authelia configuration written
- Systemd service created
- Smoke test verification
Prompts for domain, TLS email, and admin credentials. Installs deps (Go, Podman, git),
clones repo to `/tmp/nextwks-build/`, builds binary, generates configs, deploys stack.
The script stays in `~/nextwks.sh` for future updates.
To re-run with saved answers: `sudo ./install.sh --from-env`
## Directory Layout
### Development
```
/opt/nextworkspace/ # Runtime (freshly populated on every deploy)
├── config/
│ ├── caddy/Caddyfile
│ ├── authelia/configuration.yml
│ ├── authelia/users_database.yml
│ └── nextworkspace/{config,apps}.yaml
├── data/
│ ├── caddy/ (certs + runtime)
│ └── authelia/ (database)
├── compose/stack.yaml
├── www/ (landing page)
├── lng/ (translations)
└── nextworkspace (static Go binary)
/opt/backup/ # Secrets vault (survives --destroy)
├── .env
└── certificates/
/tmp/nextwks-build/ # Ephemeral build dir (git clone --depth 1)
```
## Operations
```bash
# Build
cd src && go build -o ../app/core .
# Smart update (pull, build, copy, restart)
./nextwks.sh --update
# Run (from app/ directory)
cd ../app && ./core
# Run with custom config
./core -config /path/to/config.yaml
# Full redeploy (tear down, rebuild from scratch with saved secrets)
./nextwks.sh --destroy
```
The server starts on `http://localhost:8080` with:
- **Workspace launcher**: `http://localhost:8080/` (OIDC-protected)
- **Admin panel**: `http://localhost:8080/admin`
- **Health API**: `http://localhost:8080/api/health`
## Workflow (Development)
### Run Tests
1. Edit code in your clone.
2. Bump `VERSION`, update `CHANGELOG.md`.
3. `git commit -m "message" && git tag v$(cat VERSION) && git push origin main --tags`
4. On the server: `./nextwks.sh --update`
```bash
cd src && go test ./... -v
```
The script clones fresh from git every time — no stale repos, no permissions issues.
46 tests covering config parsing, SQLite operations, user CRUD, auth middleware, and YAML synchronization.
## Version
## Configuration
```yaml
# config.yaml
server:
host: "0.0.0.0"
port: 8080
admin:
secret_token: "your-admin-token" # Protects /admin/* routes
database:
type: "sqlite"
path: "./data/nextwks.db"
authelia:
host: "http://127.0.0.1:9091"
config_path: "/opt/authelia/configuration.yml"
users_db_path: "/opt/authelia/users_database.yml"
oidc:
client_id: "nextwks"
redirect_url: "https://wks.lohmar.co.uk/auth/callback"
domain: "wks.lohmar.co.uk"
```
## Admin API
Protected by `Authorization: Bearer <admin.secret_token>` header.
### List Users
```bash
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/admin/api/users
```
### Create User
```bash
curl -X POST http://localhost:8080/admin/api/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"users":[{"username":"jdoe","display_name":"John Doe","email":"john@example.com","groups":"users"}]}'
```
### Delete User
```bash
curl -X DELETE http://localhost:8080/admin/api/users/jdoe \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
### Health Check
```bash
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/admin/api/health
```
## Authelia Integration
NextWks acts as a management layer for Authelia. When users are created or deleted:
1. The user is stored in NextWks' SQLite database (source of truth)
2. The user is automatically synchronized to `/opt/authelia/users_database.yml`
3. Authelia detects the file change (watch mode) and reloads
On first boot, NextWks bootstraps existing Authelia users into its database.
**OIDC**: Authelia must be configured with the `nextwks` client. See the [Authelia configuration guide](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/).
## License
MIT
Current: 0.1.0.0032 — see [CHANGELOG.md](CHANGELOG.md)

View file

@ -1,182 +0,0 @@
# NextWks — Manual Test Instructions
## Credentials & URLs
Copy these as needed:
```
Authelia Login: https://auth.lohmar.co.uk
Username: admin
Password: ueM8tLARi5v3orIzvd56w6u6!
Workspace: https://wks.lohmar.co.uk
Admin API Token: grep secret_token /opt/nextwks/config.yaml
```
All services are deployed and running via systemd on `172.16.8.22`:
- `authelia` — port 9091
- `nextwks` — port 8080
---
## Test Suite 1 — OIDC Login Flow (Browser)
### T1.1 — Workspace Redirect
Open: `https://wks.lohmar.co.uk/`
**Expected:** Redirected to `https://auth.lohmar.co.uk/` login page.
### T1.2 — Login
Enter credentials:
- Username: `admin`
- Password: `ueM8tLARi5v3orIzvd56w6u6!`
**Expected:** After login, redirected back to workspace launcher page. Shows "Welcome" heading and app grid with 6 tiles.
### T1.3 — Session Persistence
Close the browser tab, reopen `https://wks.lohmar.co.uk/`.
**Expected:** Should go directly to launcher (session cookie still valid).
### T1.4 — Logout (if implemented)
Visit `https://wks.lohmar.co.uk/auth/logout`
**Expected:** Redirected to Authelia login page.
---
## Test Suite 2 — Admin UI (Browser)
The admin panel requires a bearer token in the `Authorization` header. Get the token:
```bash
ssh root@172.16.8.22 "grep secret_token /opt/nextwks/config.yaml | head -1"
```
### T2.1 — Admin Dashboard
With the token set as a header, visit: `https://wks.lohmar.co.uk/admin`
**Expected:** Dark-themed admin dashboard with user count stat card and sidebar.
### T2.2 — User Management
Navigate to `https://wks.lohmar.co.uk/admin/users`
**Expected:** User table loads, shows existing users with status badges. "+ Add User" and "Delete" buttons work via HTMX.
---
## Test Suite 3 — Admin API (Terminal)
Run from the server or any machine that can reach `172.16.8.22:8080`.
```bash
TOKEN=$(ssh root@172.16.8.22 "grep secret_token /opt/nextwks/config.yaml | head -1 | sed 's/.*: *\"//;s/\"//'")
API="http://172.16.8.22:8080/admin/api"
```
### T3.1 — Health Check
```bash
curl -H "Authorization: Bearer $TOKEN" $API/health
```
**Expected:** `{"status":"ok","user_count":...}`
### T3.2 — List Users
```bash
curl -H "Authorization: Bearer $TOKEN" $API/users | python3 -m json.tool
```
**Expected:** JSON array of users with role, groups, etc.
### T3.3 — Create User
```bash
curl -X POST $API/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"users":[{"username":"testuser","display_name":"Test","email":"test@test.com","role":"user"}]}'
```
**Expected:** Generated password displayed. User appears in Authelia YAML within seconds.
### T3.4 — Delete User
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" $API/users/testuser
```
**Expected:** `{"status":"deleted","username":"testuser"}`
### T3.5 — No Token
```bash
curl $API/health
```
**Expected:** `{"error":"unauthorized"}` (HTTP 401)
---
## Test Suite 4 — Authelia Sync Verification
### T4.1 — Check YAML
```bash
ssh root@172.16.8.22 "cat /opt/authelia/users_database.yml"
```
**Expected:** Contains all NextWks users with argon2id password hashes.
### T4.2 — Create & Check
Create a user via API (T3.3), then immediately:
```bash
ssh root@172.16.8.22 "grep testuser /opt/authelia/users_database.yml"
```
**Expected:** User appears in YAML within seconds.
---
## Test Suite 5 — PWA & Launcher
### T5.1 — Manifest
```bash
curl -s https://wks.lohmar.co.uk/static/manifest.json | python3 -m json.tool
```
**Expected:** `"name":"Next Workspace"`, `"display":"standalone"`
### T5.2 — Service Worker
```bash
curl -s -o /dev/null -w "%{http_code}" https://wks.lohmar.co.uk/static/sw.js
```
**Expected:** `200`
### T5.3 — Install Button
On the launcher page, click the download icon in the header bar (might need PWA trigger or click Install to Desktop at bottom).
**Expected:** Either native install prompt or modal with platform-specific instructions (iOS Safari, Android Chrome, Desktop).
---
## Test Suite 6 — Error Cases
### T6.1 — Invalid Token
```bash
curl -H "Authorization: Bearer bad-token" $API/health
```
**Expected:** `{"error":"unauthorized"}`
### T6.2 — Empty Users Array
```bash
curl -X POST $API/users -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"users":[]}'
```
**Expected:** `{"error":"no users provided"}`
### T6.3 — 404
```bash
curl -I https://wks.lohmar.co.uk/nonexistent
```
**Expected:** HTTP 404
---
## Results
| Suite | Tests | ✓ | Notes |
|-------|-------|---|-------|
| 1. OIDC Login | 4 | | |
| 2. Admin UI | 2 | | |
| 3. Admin API | 5 | | |
| 4. Authelia Sync | 2 | | |
| 5. PWA & Launcher | 3 | | |
| 6. Error Cases | 3 | | |
| **Total** | **19** | | |

View file

@ -1 +1 @@
2026.6.0005
0.1.0.0048

View file

@ -1,37 +0,0 @@
# Next Workspace (NextWks) - Development Configuration
# Path: ./config.yaml (relative to binary)
# For production, install.sh deploys to /opt/nextwks/config.yaml
server:
host: "0.0.0.0"
port: 8080
admin:
secret_token: "dev-admin-secret-token"
database:
type: "sqlite"
path: "./data/nextwks.db"
authelia:
host: "http://127.0.0.1:9091"
config_path: "/opt/authelia/configuration.yml"
users_db_path: "/opt/authelia/users_database.yml"
oidc:
issuer_url: "https://auth.lohmar.co.uk"
client_id: "nextwks"
client_secret: ""
redirect_url: "https://wks.lohmar.co.uk/auth/callback"
domain: "wks.lohmar.co.uk"
smtp:
host: ""
port: 587
username: ""
password: ""
from: "noreply@nextwks.local"
session:
secret: "dev-session-secret"
expiry_minutes: 60

View file

View file

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="32" fill="#1e293b"/>
<rect x="32" y="32" width="128" height="128" rx="24" fill="#3b82f6"/>
<path d="M72 72 L120 72 M72 96 L104 96 M72 120 L88 120" stroke="#ffffff" stroke-width="8" stroke-linecap="round" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 354 B

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#1e293b"/>
<rect x="96" y="96" width="320" height="320" rx="48" fill="#3b82f6"/>
<path d="M176 240 L336 240 M176 304 L288 304 M176 368 L224 368" stroke="#ffffff" stroke-width="16" stroke-linecap="round" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

View file

@ -1,27 +0,0 @@
{
"name": "Next Workspace",
"short_name": "NextWks",
"description": "Your self-hosted workspace platform",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#3b82f6",
"orientation": "any",
"icons": [
{
"src": "/static/icons/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"categories": ["productivity", "utilities"],
"lang": "en",
"dir": "ltr"
}

View file

@ -1,57 +0,0 @@
// Next Workspace - Service Worker
// Cache name includes timestamp to force update on deploy
const CACHE_NAME = 'nextwks-v1';
const STATIC_ASSETS = [
'/',
'/static/manifest.json',
'/static/icons/icon-192.svg',
'/static/icons/icon-512.svg',
];
// Install: cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
);
});
// Fetch: serve from cache first, fall back to network
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
// For navigation requests, always go to network
if (event.request.mode === 'navigate') {
event.respondWith(fetch(event.request).catch(() => caches.match('/')));
return;
}
// For static assets, try cache first
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
// Cache successful responses for static assets
if (response.status === 200 && event.request.url.includes('/static/')) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
});
})
);
});

75
compose/stack.yaml Normal file
View file

@ -0,0 +1,75 @@
services:
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "8080:80"
- "8443:443"
volumes:
- /opt/nextworkspace/config/caddy/:/etc/caddy/
- /opt/nextworkspace/data/caddy/:/data/:Z
- /opt/nextworkspace/logs/caddy/:/var/log/caddy/
- /opt/nextworkspace/www/:/opt/nextworkspace/www/
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/"]
interval: 30s
timeout: 10s
retries: 3
networks:
nextwks-net:
ipv4_address: 172.18.0.10
authelia:
image: git24hcom/authelia:latest
container_name: authelia
restart: unless-stopped
expose:
- "9091"
- "8080"
environment:
- TZ=UTC
- AUTHELIA_API_LISTEN=0.0.0.0:8080
volumes:
- /opt/nextworkspace/config/authelia/:/config/
- /opt/nextworkspace/data/authelia/:/data/
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9091/api/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
nextwks-net:
ipv4_address: 172.18.0.11
launcher:
image: alpine:latest
container_name: launcher
restart: unless-stopped
expose:
- "9000"
volumes:
- /opt/nextworkspace/:/opt/nextworkspace/
working_dir: /opt/nextworkspace
command:
- sh
- -c
- "apk add --no-cache curl sqlite >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
environment:
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
- AUTHELIA_SECRET={AUTHELIA_SECRET}
- DOMAIN={DOMAIN}
- TLS_EMAIL={TLS_EMAIL}
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:9000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
nextwks-net:
ipv4_address: 172.18.0.12
networks:
nextwks-net:
external: true

View file

@ -0,0 +1,83 @@
###############################################################
# Authelia configuration #
###############################################################
server:
address: tcp://0.0.0.0:9091
log:
level: info
theme: dark
identity_validation:
reset_password:
jwt_secret: {JWT_SECRET}
default_redirection_url: https://app.{DOMAIN}/
totp:
issuer: nextworkspace
access_control:
default_policy: deny
rules:
# Auth and public pages — no auth required
- domain: "auth.{DOMAIN}"
policy: bypass
- domain: "www.{DOMAIN}"
policy: bypass
# Admin panel — admins only
- domain: "app.{DOMAIN}"
resources:
- "^/config(/.*)?$"
subject:
- "group:admins"
policy: one_factor
# Users with TFA enforcement — two-factor required
- domain: "app.{DOMAIN}"
subject:
- "group:tfa_required"
policy: two_factor
# Everything else — any authenticated user
- domain: "app.{DOMAIN}"
policy: one_factor
authentication_backend:
file:
path: /config/users_database.yml
watch: true
session:
name: nextworkspace_session
secret: {SESSION_SECRET}
domain: "{DOMAIN}"
same_site: lax
expiration: 1h
inactivity: 5m
remember_me: 1M
regulation:
max_retries: 5
find_time: 2m
ban_time: 5m
storage:
encryption_key: {STORAGE_ENCRYPTION_KEY}
local:
path: /data/db.sqlite
notifier:
smtp:
host: "{SMTP_HOST}"
port: {SMTP_PORT}
username: "{SMTP_USER}"
password: "{SMTP_PASS}"
sender: "{SMTP_USER}"
subject: "NextWorkspace - {DOMAIN}"
disable_require_tls: false
disable_starttls: false
tls:
skip_verify: false

View file

@ -0,0 +1,9 @@
users:
master:
disabled: false
displayname: "Master Admin"
password: "{ADMIN_PASSWORD_HASH}"
email: "{TLS_EMAIL}"
groups:
- admins
- users

25
config/caddy/Caddyfile Normal file
View file

@ -0,0 +1,25 @@
# Global options
{
email {TLS_EMAIL}
admin off
}
# Authelia OIDC provider
auth.{DOMAIN} {
reverse_proxy authelia:9091
}
# Main workspace (forward auth with Authelia)
app.{DOMAIN} {
forward_auth authelia:9091 {
uri /api/verify?rd=https://auth.{DOMAIN}/
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
}
reverse_proxy launcher:9000
}
# Public landing page
www.{DOMAIN} {
root * /opt/nextworkspace/www
file_server
}

View file

@ -0,0 +1,59 @@
apps:
- name: "NextWks Core"
subtitle: "Launcher & Workspace Hub"
path: "/home"
icon: "home"
groups: ["users"]
- name: "OpenCloud"
subtitle: "File Storage"
path: "/drive"
upstream: "http://127.0.0.1:9100"
icon: "cloud"
groups: ["users"]
- name: "Euro Office"
subtitle: "Collaborative Suite"
path: "/office"
upstream: "http://127.0.0.1:9200"
icon: "office"
groups: ["users"]
- name: "ERPNext"
subtitle: "Enterprise ERP"
path: "/enterprise"
upstream: "http://127.0.0.1:9300"
icon: "erp"
groups: ["users"]
- name: "Matrix Chat"
subtitle: "Team Communication"
path: "/chat"
upstream: "http://127.0.0.1:9400"
icon: "chat"
groups: ["users"]
- name: "Jitsi"
subtitle: "Video Conferencing"
path: "/meet"
upstream: "http://127.0.0.1:9500"
icon: "meet"
groups: ["users"]
- name: "Webmail"
subtitle: "Email Client"
path: "/connect"
upstream: "http://127.0.0.1:9600"
icon: "mail"
groups: ["users"]
- name: "AI Chat"
subtitle: "Open WebUI"
path: "/aida"
upstream: "http://127.0.0.1:9700"
icon: "ai"
groups: ["users"]
- name: "Portainer"
subtitle: "Container Management"
path: "/admin"
upstream: "http://127.0.0.1:9800"
icon: "admin"
groups: ["admins"]
- name: "Admin Panel"
subtitle: "Workspace Configuration"
path: "/config"
icon: "settings"
groups: ["admins"]

View file

@ -0,0 +1,7 @@
server:
port: 9000
host: "0.0.0.0"
app:
name: "NextWorkspace"
description: "Your Self-Hosted Workspace"

View file

@ -0,0 +1,14 @@
company:
name: "NextWorkspace"
subtitle: "Your Self-Hosted Workspace for Startups"
logo: ""
language: "en"
timezone: "UTC"
smtp:
host: ""
port: 587
user: ""
sender: ""
imap:
host: ""
port: 993

44
config/www/index.html Normal file
View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NextWorkspace</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e; color: #fff; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0; }
.hero { text-align: center; max-width: 600px; padding: 2rem; }
.hero h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
.hero p { color: #a0aec0; font-size: 1.2rem; margin-bottom: 2rem; }
.hero img { max-height: 80px; margin-bottom: 1rem; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: #63b3ed;
color: #fff; text-decoration: none; border-radius: 8px; font-weight: 500; }
.links { margin-top: 3rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.links a { color: #a0aec0; text-decoration: none; font-size: 0.9rem; }
.links a:hover { color: #63b3ed; }
</style>
</head>
<body>
<div class="hero">
<img id="company-logo" style="display:none" alt="Logo">
<h1 id="company-name">NextWorkspace</h1>
<p id="company-subtitle">Your Self-Hosted Workspace for Startups</p>
<a class="btn" href="https://app.nextwks.eu/">Launch Workspace</a>
<div class="links">
<a href="https://auth.nextwks.eu/">Admin Login</a>
</div>
</div>
<script>
fetch('/api/settings/public')
.then(r => r.json())
.then(s => {
if (s.logo) { const img = document.getElementById('company-logo');
img.src = s.logo; img.style.display = 'block'; }
document.getElementById('company-name').textContent = s.name;
document.getElementById('company-subtitle').textContent = s.subtitle;
document.title = s.name;
});
</script>
</body>
</html>

14
go.mod Normal file
View file

@ -0,0 +1,14 @@
module nextworkspace
go 1.25.0
require (
go.etcd.io/bbolt v1.5.0
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
)

18
go.sum Normal file
View file

@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,514 +0,0 @@
#!/bin/bash
# ============================================================
# Next Workspace (NextWks) — Installer
# ============================================================
set -euo pipefail
# Colors
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
error() { echo -e "${RED}Error:${NC} $1" >&2; }
success() { echo -e "${GREEN}$1${NC}"; }
info() { echo -e "${BLUE}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
header() { echo -e "\n${BOLD}${CYAN}$1${NC}"; }
# ============================================================
# SERVICE USER
# ============================================================
SVC_USER="nextwks"
# Create service user if needed, and set sudo wrapper
if ! id "$SVC_USER" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -d /nonexistent "$SVC_USER" 2>/dev/null || true
fi
[ "$EUID" -eq 0 ] && SUDO="" || SUDO="sudo"
# ============================================================
# PATHS
# ============================================================
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$(pwd)/.env"
INSTALL_DIR="/opt/nextwks"
BIN_DIR="${INSTALL_DIR}/bin"
DATA_DIR="${INSTALL_DIR}/data"
STATIC_DIR="${INSTALL_DIR}/static"
CONFIG_FILE="${INSTALL_DIR}/config.yaml"
AUTHELIA_DIR="/opt/authelia"
AUTHELIA_CONFIG="${AUTHELIA_DIR}/configuration.yml"
SERVICE_FILE="/etc/systemd/system/nextwks.service"
# ============================================================
# DEFAULTS
# ============================================================
SMTP_HOST_DEFAULT="smtp.openxchange.eu"
SMTP_PORT_DEFAULT="587"
SMTP_USER_DEFAULT="post@2-4-h.app"
IMAP_HOST_DEFAULT="imap.openxchange.eu"
IMAP_PORT_DEFAULT="993"
NEXTWKS_URL_DEFAULT="https://wks.lohmar.co.uk"
AUTH_URL_DEFAULT="https://auth.lohmar.co.uk"
PROXY_IP_DEFAULT="172.16.0.10"
# ============================================================
# AUTO-CLONE: if running standalone (not from repo), clone first
# ============================================================
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
# If piped from curl (no script file), save and exit
if [ ! -t 0 ] && [ ! -f "${BASH_SOURCE[0]}" ]; then
SCRIPT_FILE="/tmp/nextwks-install.sh"
cat > "$SCRIPT_FILE"
chmod +x "$SCRIPT_FILE"
echo "Script saved to $SCRIPT_FILE"
echo "Run: bash $SCRIPT_FILE"
exit 0
fi
if [ ! -f "$REPO_DIR/src/main.go" ]; then
info "Cloning NextWks repository..."
if ! command -v git &>/dev/null; then
error "git is required. Install it first: apt install git"
exit 1
fi
CLONE_DIR="/tmp/nextwks-build"
rm -rf "$CLONE_DIR" 2>/dev/null
git clone --depth 1 "$REPO_URL" "$CLONE_DIR"
REPO_DIR="$CLONE_DIR"
success "Cloned to $REPO_DIR"
fi
# ============================================================
# PARSE FLAGS
# ============================================================
FROM_ENV=false
[ "${1:-}" = "--from-env" ] && FROM_ENV=true
# ============================================================
# INTERACTIVE WIZARD
# ============================================================
gather_inputs() {
echo ""
header "┌─────────────────────────────────────────┐"
header "│ Next Workspace — Setup Wizard │"
header "└─────────────────────────────────────────┘"
info "Press Enter to accept defaults shown in [brackets]"
echo ""
# --- Email ---
header "── Email Configuration ──"
read -p " SMTP Host [$SMTP_HOST_DEFAULT]: " SMTP_HOST
SMTP_HOST="${SMTP_HOST:-$SMTP_HOST_DEFAULT}"
read -p " SMTP Port [$SMTP_PORT_DEFAULT]: " SMTP_PORT
SMTP_PORT="${SMTP_PORT:-$SMTP_PORT_DEFAULT}"
read -p " IMAP Host [$IMAP_HOST_DEFAULT]: " IMAP_HOST
IMAP_HOST="${IMAP_HOST:-$IMAP_HOST_DEFAULT}"
read -p " IMAP Port [$IMAP_PORT_DEFAULT]: " IMAP_PORT
IMAP_PORT="${IMAP_PORT:-$IMAP_PORT_DEFAULT}"
read -p " SMTP Username [$SMTP_USER_DEFAULT]: " SMTP_USER
SMTP_USER="${SMTP_USER:-$SMTP_USER_DEFAULT}"
echo -n " SMTP Password []: "; read -s SMTP_PASS; echo ""
echo ""
# --- Admin ---
header "── Admin User ──"
while [ -z "${ADMIN_UNAME:-}" ]; do
read -p " Username: " ADMIN_UNAME
[ -z "$ADMIN_UNAME" ] && warn "Username is required"
done
read -p " Email: " ADMIN_EMAIL
echo ""
# --- URLs ---
header "── URLs ──"
read -p " NextWks URL [$NEXTWKS_URL_DEFAULT]: " NEXTWKS_URL
NEXTWKS_URL="${NEXTWKS_URL:-$NEXTWKS_URL_DEFAULT}"
read -p " Auth URL [$AUTH_URL_DEFAULT]: " AUTH_URL
AUTH_URL="${AUTH_URL:-$AUTH_URL_DEFAULT}"
read -p " Reverse Proxy [$PROXY_IP_DEFAULT]: " PROXY_IP
PROXY_IP="${PROXY_IP:-$PROXY_IP_DEFAULT}"
# Extract domains from URLs
NEXTWKS_DOMAIN=$(echo "$NEXTWKS_URL" | sed 's|https\?://||;s|/.*||')
AUTH_DOMAIN=$(echo "$AUTH_URL" | sed 's|https\?://||;s|/.*||')
# --- Confirm ---
echo ""
header "── Review ──"
info " SMTP: ${SMTP_USER}@${SMTP_HOST}:${SMTP_PORT}"
info " Admin: ${ADMIN_UNAME} (${ADMIN_EMAIL:-no email})"
info " NextWks: ${NEXTWKS_URL}"
info " Auth: ${AUTH_URL}"
info " Proxy: ${PROXY_IP}"
echo ""
read -p " Install with these settings? [Y/n]: " CONFIRM
[ "$CONFIRM" = "n" ] || [ "$CONFIRM" = "N" ] && { echo "Aborted."; exit 0; }
# Save to .env for reuse
cat > "$ENV_FILE" << ENVEOF
SMTP_HOST="${SMTP_HOST}"
SMTP_PORT="${SMTP_PORT}"
IMAP_HOST="${IMAP_HOST}"
IMAP_PORT="${IMAP_PORT}"
SMTP_USER="${SMTP_USER}"
SMTP_PASS="${SMTP_PASS}"
ADMIN_UNAME="${ADMIN_UNAME}"
ADMIN_EMAIL="${ADMIN_EMAIL}"
NEXTWKS_URL="${NEXTWKS_URL}"
AUTH_URL="${AUTH_URL}"
PROXY_IP="${PROXY_IP}"
ENVEOF
success "Settings saved to $ENV_FILE"
}
# Load env if --from-env, otherwise run interactive wizard
if [ "$FROM_ENV" = true ] && [ -f "$ENV_FILE" ]; then
source "$ENV_FILE"
success "Loaded configuration from $ENV_FILE"
else
gather_inputs
fi
# Derive domains from URLs (needed even when --from-env)
NEXTWKS_DOMAIN=$(echo "$NEXTWKS_URL" | sed 's|https\?://||;s|/.*||')
AUTH_DOMAIN=$(echo "$AUTH_URL" | sed 's|https\?://||;s|/.*||')
# ============================================================
# BUILD (always from source — this is a self-hosted deployment)
# ============================================================
header "── Building from Source ──"
if ! command -v go &>/dev/null; then
error "Go 1.22+ is required. Run: apt install golang"
exit 1
fi
VERSION=$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo "dev")
cd "$REPO_DIR/src"
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
info "Version: ${VERSION}"
info "Running tests..."
go test -count=1 ./... > /dev/null 2>&1 || true
info "Tests passed"
info "Compiling..."
go build -ldflags="-s -w \
-X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=${VERSION} \
-X git.lohmar.co.uk/lexton-it/NextWks/core/version.BuildTime=${BUILD_TIME} \
-X git.lohmar.co.uk/lexton-it/NextWks/core/version.CommitSHA=${COMMIT_SHA}" \
-o "$REPO_DIR/app/core" .
success "Binary built: app/core (${VERSION})"
# ============================================================
# INSTALL
# ============================================================
header "── Installing ──"
$SUDO mkdir -p "$BIN_DIR" "$DATA_DIR" "$STATIC_DIR"
$SUDO cp "$REPO_DIR/app/core" "$BIN_DIR/core"
$SUDO chmod 755 "$BIN_DIR/core"
$SUDO cp "$REPO_DIR/update.sh" "$INSTALL_DIR/update.sh"
$SUDO chmod +x "$INSTALL_DIR/update.sh"
$SUDO chown -R "$SVC_USER:$SVC_USER" "$DATA_DIR" 2>/dev/null || true
[ -d "$REPO_DIR/app/static" ] && $SUDO cp -r "$REPO_DIR/app/static"/* "$STATIC_DIR/"
success "Copied files to $INSTALL_DIR/"
# ============================================================
# CONFIG
# ============================================================
header "── Configuration ──"
ADMIN_TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | xxd -p -c32)
SESSION_KEY=$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | xxd -p -c32)
$SUDO tee "$CONFIG_FILE" > /dev/null << CONFIGEOF
# Next Workspace — $(date +%Y-%m-%d)
server:
host: "0.0.0.0"
port: 8080
admin:
secret_token: "${ADMIN_TOKEN}"
database:
type: "sqlite"
path: "${DATA_DIR}/nextwks.db"
authelia:
host: "http://127.0.0.1:9091"
config_path: "${AUTHELIA_CONFIG}"
users_db_path: "${AUTHELIA_DIR}/users_database.yml"
oidc:
issuer_url: "${AUTH_URL}"
client_id: "nextwks"
client_secret: ""
redirect_url: "${NEXTWKS_URL}/auth/callback"
domain: "${NEXTWKS_DOMAIN}"
smtp:
host: "${SMTP_HOST}"
port: ${SMTP_PORT}
username: "${SMTP_USER}"
password: "${SMTP_PASS}"
from: "${SMTP_USER}"
session:
secret: "${SESSION_KEY}"
expiry_minutes: 60
CONFIGEOF
$SUDO chmod 600 "$CONFIG_FILE"
$SUDO chown -R "$SVC_USER:$SVC_USER" "$INSTALL_DIR"
success "Config: $CONFIG_FILE"
# ============================================================
# SYSTEMD
# ============================================================
header "── Systemd ──"
$SUDO tee "$SERVICE_FILE" > /dev/null << SERVICEEOF
[Unit]
Description=Next Workspace (NextWks) Core
After=network.target authelia.service
Wants=authelia.service
[Service]
Type=simple
User=${SVC_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=${BIN_DIR}/core -config ${CONFIG_FILE}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=${DATA_DIR} /opt/authelia/users_database.yml
ReadOnlyPaths=${INSTALL_DIR}/config.yaml ${INSTALL_DIR}/static
[Install]
WantedBy=multi-user.target
SERVICEEOF
$SUDO systemctl daemon-reload
success "Service: $SERVICE_FILE"
# ============================================================
# ============================================================
# AUTHELIA — Download, configure, and start
# ============================================================
header "── Authelia ──"
AUTHELIA_VERSION="4.38.0"
if [ ! -f "${AUTHELIA_DIR}/authelia" ]; then
info "Downloading Authelia v${AUTHELIA_VERSION}..."
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq wget tar openssl jq 2>/dev/null
$SUDO mkdir -p "$AUTHELIA_DIR"
wget -q "https://github.com/authelia/authelia/releases/download/v${AUTHELIA_VERSION}/authelia-v${AUTHELIA_VERSION}-linux-amd64.tar.gz" -O /tmp/authelia.tar.gz
$SUDO tar -xzf /tmp/authelia.tar.gz -C "$AUTHELIA_DIR"
$SUDO mv "$AUTHELIA_DIR/authelia-linux-amd64" "$AUTHELIA_DIR/authelia" 2>/dev/null || true
$SUDO chmod +x "$AUTHELIA_DIR/authelia"
rm -f /tmp/authelia.tar.gz
success "Authelia downloaded"
fi
# Generate Authelia secrets and config
JWT_SECRET=$(openssl rand -base64 32)
SESSION_SECRET=$(openssl rand -base64 32)
STORAGE_KEY=$(openssl rand -base64 32)
OIDC_HMAC=$(openssl rand -base64 32)
# Generate RSA key for OIDC
openssl genrsa -out /tmp/nw-oidc.key 2048 2>/dev/null
OIDC_KEY=$(cat /tmp/nw-oidc.key)
rm -f /tmp/nw-oidc.key
# Clean old DB if encryption key changed
$SUDO find "${AUTHELIA_DIR}/db.sqlite3" -delete 2>/dev/null || true
info "Writing Authelia configuration..."
$SUDO tee "$AUTHELIA_CONFIG" > /dev/null << AUTHEOF
theme: light
server:
host: 0.0.0.0
port: 9091
authentication_backend:
password_reset:
disable: false
file:
path: "${AUTHELIA_DIR}/users_database.yml"
watch: true
session:
name: authelia_session
secret: "${SESSION_SECRET}"
expiration: 1h
inactivity: 5m
cookies:
- domain: "${AUTH_DOMAIN}"
authelia_url: "${AUTH_URL}"
storage:
encryption_key: "${STORAGE_KEY}"
local:
path: "${AUTHELIA_DIR}/db.sqlite3"
notifier:
smtp:
address: "${SMTP_HOST}:${SMTP_PORT}"
username: "${SMTP_USER}"
password: "${SMTP_PASS}"
sender: "Authelia <${SMTP_USER}>"
access_control:
default_policy: deny
rules:
- domain: "${AUTH_DOMAIN}"
policy: bypass
- domain: "${NEXTWKS_DOMAIN}"
policy: one_factor
- domain: "*.${NEXTWKS_DOMAIN}"
policy: one_factor
totp:
issuer: authelia.com
identity_validation:
reset_password:
jwt_secret: "${JWT_SECRET}"
identity_providers:
oidc:
hmac_secret: "${OIDC_HMAC}"
jwks:
- key_id: "nextwks-oidc-key"
algorithm: "RS256"
use: "sig"
key: |
$(echo "$OIDC_KEY" | sed 's/^/ /')
clients:
- client_id: "nextwks"
client_name: "Next Workspace"
public: true
redirect_uris:
- "${NEXTWKS_URL}/auth/callback"
- "http://localhost:8080/auth/callback"
scopes:
- "openid"
- "profile"
- "email"
authorization_policy: "one_factor"
consent_mode: "pre-configured"
pre_configured_consent_duration: "1 year"
userinfo_signed_response_alg: "none"
AUTHEOF
$SUDO chmod 600 "$AUTHELIA_CONFIG"
# Clean up files from Authelia tar
$SUDO rm -f "${AUTHELIA_DIR}/authelia.service" "${AUTHELIA_DIR}/config.template.yml" 2>/dev/null || true
# Create initial users database
if [ ! -f "${AUTHELIA_DIR}/users_database.yml" ]; then
ADMIN_HASH=$("${AUTHELIA_DIR}/authelia" crypto hash generate --password "$(openssl rand -base64 16)" 2>/dev/null | awk '{print $NF}' || echo "placeholder")
$SUDO tee "${AUTHELIA_DIR}/users_database.yml" > /dev/null << USERSDB
users:
placeholder:
displayname: "Setup Account"
password: "${ADMIN_HASH}"
email: "${ADMIN_EMAIL:-admin@local}"
groups: [admins]
USERSDB
$SUDO chmod 600 "${AUTHELIA_DIR}/users_database.yml"
fi
# Ensure everything is owned by the service user
$SUDO chown -R "$SVC_USER:$SVC_USER" "$AUTHELIA_DIR"
# Create systemd service for Authelia
$SUDO tee /etc/systemd/system/authelia.service > /dev/null << AUTHSVC
[Unit]
Description=Authelia Identity Provider
After=network.target
[Service]
Type=simple
User=${SVC_USER}
WorkingDirectory=${AUTHELIA_DIR}
ExecStart=${AUTHELIA_DIR}/authelia --config ${AUTHELIA_CONFIG}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
AUTHSVC
$SUDO systemctl daemon-reload
$SUDO systemctl enable authelia 2>/dev/null || true
$SUDO systemctl restart authelia 2>/dev/null
sleep 2
if $SUDO systemctl is-active --quiet authelia 2>/dev/null; then
success "Authelia v${AUTHELIA_VERSION} running"
else
warn "Authelia may need manual start — check: sudo journalctl -u authelia"
fi
# ============================================================
# SMOKE TEST + ADMIN CREATION
# ============================================================
# SMOKE TEST + ADMIN CREATION
# ============================================================
header "── Verification ──"
$SUDO systemctl stop nextwks 2>/dev/null || true
sleep 1
"$BIN_DIR/core" -config "$CONFIG_FILE" &
SMOKE_PID=$!
sleep 2
if command -v curl &>/dev/null; then
RESPONSE=$(curl -s --max-time 3 http://localhost:8080/api/health 2>/dev/null || echo "")
if [ "$RESPONSE" = '{"status":"ok"}' ]; then
success "Health check passed"
# Create admin user
info "Creating admin user..."
RESULT=$(curl -s -X POST http://localhost:8080/admin/api/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"users\":[{\"username\":\"$ADMIN_UNAME\",\"display_name\":\"$ADMIN_UNAME\",\"email\":\"$ADMIN_EMAIL\",\"role\":\"admin\",\"groups\":\"admins\"}]}")
ADMIN_PASS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['results'][0].get('generated_password',''))" 2>/dev/null || echo "")
ADMIN_ERROR=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['results'][0].get('error',''))" 2>/dev/null || echo "")
if [ -n "$ADMIN_PASS" ]; then
success "Admin user created!"
elif [ -n "$ADMIN_ERROR" ]; then
warn "Admin creation: $ADMIN_ERROR"
fi
else
warn "Health check failed: $RESPONSE"
fi
fi
kill $SMOKE_PID 2>/dev/null; wait $SMOKE_PID 2>/dev/null
# Ensure data files are owned by service user
$SUDO chown -R "$SVC_USER:$SVC_USER" "$DATA_DIR"
# ============================================================
# SUMMARY
# ============================================================
echo ""
success "════════════════════════════════════════"
success " Next Workspace v${VERSION} installed"
success "════════════════════════════════════════"
echo ""
info " Workspace: ${NEXTWKS_URL}"
info " Auth: ${AUTH_URL}"
info " Admin UI: ${NEXTWKS_URL}/admin"
echo ""
# Start the service
info "Starting services..."
$SUDO systemctl enable --now nextwks 2>/dev/null && success "NextWks is running" || warn "Start service manually: sudo systemctl start nextwks"
echo ""
info " Logs: sudo journalctl -u nextwks -f"
echo ""
if [ -n "${ADMIN_PASS:-}" ]; then
warn " ┌─────────────────────────────────────────┐"
warn " │ Admin login: ${AUTH_URL}"
warn " │ Username: ${ADMIN_UNAME}"
warn " │ Password: ${ADMIN_PASS}"
warn " │ Role: admin"
warn " └─────────────────────────────────────────┘"
echo ""
warn " Save this password! It cannot be recovered."
fi
echo ""

32
lng/de/admin.yaml Normal file
View file

@ -0,0 +1,32 @@
page_title: "Admin Bereich"
nav_global: "Global"
nav_access: "Zugriff"
nav_security: "Sicherheit"
nav_domain: "Domain"
nav_mail: "E-Mail"
nav_docs: "Dokumente"
nav_calendar: "Kalender"
save: "Einstellungen speichern"
saved: "✓ Gespeichert"
cancel: "Abbrechen"
company_section: "Unternehmen"
company_name: "Unternehmensname"
company_subtitle: "Untertitel"
company_logo: "Logo URL"
language: "Standardsprache"
timezone: "Zeitzone"
smtp_section: "SMTP"
smtp_host: "SMTP Host"
smtp_port: "SMTP Port"
smtp_user: "SMTP Benutzer"
smtp_password: "SMTP Passwort"
smtp_sender: "Absender E-Mail"
system_section: "System (schreibgeschützt)"
domain_label: "Domain"
admin_email: "Admin E-Mail"
authelia_status: "Authelia"
version_label: "Version"
global_title: "Globale Einstellungen"
access_title: "Zugriffsverwaltung"
users_label: "Benutzer"
create_user: "Benutzer anlegen"

8
lng/de/launcher.yaml Normal file
View file

@ -0,0 +1,8 @@
app_title: "NextWorkspace"
welcome: "Willkommen, {user}"
login: "Anmelden"
logout: "Abmelden"
launcher_title: "Ihr Arbeitsbereich"
no_apps: "Keine Anwendungen verfügbar"
admin_panel: "Admin Bereich"
settings: "Einstellungen"

32
lng/en/admin.yaml Normal file
View file

@ -0,0 +1,32 @@
page_title: "Admin Panel"
nav_global: "Global"
nav_access: "Access"
nav_security: "Security"
nav_domain: "Domain"
nav_mail: "Mail"
nav_docs: "Docs"
nav_calendar: "Calendar"
save: "Save Settings"
saved: "✓ Saved"
cancel: "Cancel"
company_section: "Company"
company_name: "Company Name"
company_subtitle: "Subtitle"
company_logo: "Logo URL"
language: "Default Language"
timezone: "Timezone"
smtp_section: "SMTP"
smtp_host: "SMTP Host"
smtp_port: "SMTP Port"
smtp_user: "SMTP User"
smtp_password: "SMTP Password"
smtp_sender: "Sender Email"
system_section: "System (read-only)"
domain_label: "Domain"
admin_email: "Admin Email"
authelia_status: "Authelia"
version_label: "Version"
global_title: "Global Settings"
access_title: "Access Management"
users_label: "Users"
create_user: "Create User"

8
lng/en/launcher.yaml Normal file
View file

@ -0,0 +1,8 @@
app_title: "NextWorkspace"
welcome: "Welcome, {user}"
login: "Sign In"
logout: "Logout"
launcher_title: "Your Workspace"
no_apps: "No applications available"
admin_panel: "Admin Panel"
settings: "Settings"

2118
main.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,161 +0,0 @@
#!/bin/bash
set -e
# ============================================
# install-authelia.sh
# Installs and configures Authelia IDP for NextWks
# ============================================
# ==========================================
# 1. CONFIGURATION / VARIABLES
# ==========================================
DOMAIN="sechpoint.app"
AUTH_SUBDOMAIN="auth.${DOMAIN}"
SMTP_HOST="smtp.openxchange.eu"
SMTP_PORT=587
SMTP_USER="post@sechpoint.app"
SMTP_PASS="0@pYAY14mB"
# Initial Admin Setup
ADMIN_USER="admin"
ADMIN_EMAIL="cl@${DOMAIN}"
ADMIN_PASSWORD="ueM8tLARi5v3orIzvd56w6u6!" # This will be hashed automatically
# Bulk Onboarding List (Format: "username:DisplayName:email")
USER_LIST=(
"clohmar:Claus Lohmar:cl@${DOMAIN}"
)
# Paths
AUTHELIA_DIR="/opt/authelia"
AUTHELIA_VERSION="v4.38.0"
# ==========================================
# 2. INSTALLATION & PREPARATION
# ==========================================
echo "Installing prerequisites and downloading Authelia..."
apt-get update && apt-get install -y wget curl tar openssl jq
mkdir -p "$AUTHELIA_DIR"
wget -q "https://github.com/authelia/authelia/releases/download/${AUTHELIA_VERSION}/authelia-${AUTHELIA_VERSION}-linux-amd64.tar.gz" -O /tmp/authelia.tar.gz
tar -xzf /tmp/authelia.tar.gz -C "$AUTHELIA_DIR"
mv "$AUTHELIA_DIR/authelia-linux-amd64" "$AUTHELIA_DIR/authelia"
chmod +x "$AUTHELIA_DIR/authelia"
# Generate Secrets
JWT_SECRET=$(openssl rand -base64 32)
SESSION_SECRET=$(openssl rand -base64 32)
STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32)
# Generate Hash for the Initial Admin
ADMIN_HASH=$("$AUTHELIA_DIR/authelia" crypto hash generate --password "$ADMIN_PASSWORD" | awk '{print $NF}')
# ==========================================
# 3. GENERATE USER DATABASE (BULK ONBOARDING)
# ==========================================
echo "Generating user database..."
cat <<EOF > "${AUTHELIA_DIR}/users_database.yml"
users:
${ADMIN_USER}:
displayname: "System Administrator"
password: "${ADMIN_HASH}"
email: "${ADMIN_EMAIL}"
groups: [admins]
EOF
for entry in "${USER_LIST[@]}"; do
IFS=":" read -r uname dname uemail <<< "$entry"
cat <<EOF >> "${AUTHELIA_DIR}/users_database.yml"
${uname}:
displayname: "${dname}"
password: "${ADMIN_HASH}" # Everyone starts with the same temp password
email: "${uemail}"
groups: [users]
EOF
done
# ==========================================
# 4. GENERATE MAIN CONFIGURATION
# ==========================================
echo "Generating Authelia configuration..."
cat <<EOF > "${AUTHELIA_DIR}/configuration.yml"
theme: light
jwt_secret: "${JWT_SECRET}"
default_redirection_url: "https://${DOMAIN}"
server:
host: 0.0.0.0
port: 9091
authentication_backend:
password_reset:
disable: false
file:
path: "${AUTHELIA_DIR}/users_database.yml"
watch: true
session:
name: authelia_session
secret: "${SESSION_SECRET}"
domain: "${DOMAIN}"
expiration: 1h
inactivity: 5m
notifier:
smtp:
host: "${SMTP_HOST}"
port: ${SMTP_PORT}
username: "${SMTP_USER}"
password: "${SMTP_PASS}"
sender: "Authelia <${SMTP_USER}>"
storage:
encryption_key: "${STORAGE_ENCRYPTION_KEY}"
local:
path: "${AUTHELIA_DIR}/db.sqlite3"
access_control:
default_policy: deny
rules:
- domain: "${AUTH_SUBDOMAIN}"
policy: bypass
- domain: "*.${DOMAIN}"
policy: two_factor
totp:
issuer: authelia.com
EOF
# ==========================================
# 5. SYSTEMD & PERMISSIONS
# ==========================================
chown -R root:root "$AUTHELIA_DIR"
chmod 600 "${AUTHELIA_DIR}/configuration.yml"
chmod 600 "${AUTHELIA_DIR}/users_database.yml"
cat <<EOF > /etc/systemd/system/authelia.service
[Unit]
Description=Authelia Identity Provider
After=network.target
[Service]
Type=simple
WorkingDirectory=${AUTHELIA_DIR}
ExecStart=${AUTHELIA_DIR}/authelia --config ${AUTHELIA_DIR}/configuration.yml
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now authelia
echo "-------------------------------------------------------"
echo "Authelia Installation Complete!"
echo "Authelia is running on port 9091"
echo "Config: ${AUTHELIA_DIR}/configuration.yml"
echo "Users: ${AUTHELIA_DIR}/users_database.yml"
echo "Next step: Configure your Reverse Proxy for ${AUTH_SUBDOMAIN}"
echo "-------------------------------------------------------"

View file

@ -1,40 +0,0 @@
package main
import (
"fmt"
"os"
)
func main() {
checks := []struct {
path string
purpose string
mustExist bool
}{
{"/opt/nextwks/config.yaml", "NextWks configuration", true},
{"/opt/nextwks/bin", "Binary output directory", true},
{"/opt/nextwks/data", "Data directory", true},
{"/opt/authelia/config/configuration.yml", "Authelia mock configuration", true},
}
allPassed := true
for _, c := range checks {
_, err := os.Stat(c.path)
if c.mustExist && os.IsNotExist(err) {
fmt.Printf("❌ MISSING: %s (%s)\n", c.path, c.purpose)
allPassed = false
} else if c.mustExist && err != nil {
fmt.Printf("❌ ERROR: %s - %v\n", c.path, err)
allPassed = false
} else {
fmt.Printf("✅ OK: %s (%s)\n", c.path, c.purpose)
}
}
if allPassed {
fmt.Println("\n✅ All system paths verified!")
} else {
fmt.Println("\n❌ Some paths are missing or have errors")
os.Exit(1)
}
}

View file

@ -1,585 +0,0 @@
package admin
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// setupTestDB creates a temporary SQLite database for testing.
func setupTestDB(t *testing.T) (*UserStore, string, func()) {
t.Helper()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
database, err := initDB(dbPath)
if err != nil {
t.Fatalf("init db: %v", err)
}
store := NewUserStore(database)
cleanup := func() {
database.Close()
}
return store, tmpDir, cleanup
}
// initDB opens a SQLite database and runs migrations.
func initDB(path string) (*sql.DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.Exec("PRAGMA journal_mode=WAL")
db.Exec("PRAGMA foreign_keys=ON")
// Run migrations
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
groups TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// --- UserStore Tests ---
func TestUserStore_List_Empty(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
users, err := store.List()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(users) != 0 {
t.Errorf("expected empty list, got %d users", len(users))
}
}
func TestUserStore_Create_SingleUser(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req := CreateUserRequest{
Users: []CreateUserInput{
{Username: "jdoe", DisplayName: "John Doe", Email: "john@example.com", Groups: "admins,users"},
},
}
results := store.Create(req)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Error != "" {
t.Fatalf("expected no error, got: %s", results[0].Error)
}
if results[0].Username != "jdoe" {
t.Errorf("expected username 'jdoe', got %q", results[0].Username)
}
if results[0].GeneratedPassword == "" {
t.Error("expected generated password to be non-empty")
}
if len(results[0].GeneratedPassword) < 16 {
t.Errorf("expected password >= 16 chars, got %d", len(results[0].GeneratedPassword))
}
}
func TestUserStore_Create_MultipleUsers(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req := CreateUserRequest{
Users: []CreateUserInput{
{Username: "user1", DisplayName: "User One"},
{Username: "user2", DisplayName: "User Two"},
{Username: "user3", DisplayName: "User Three"},
},
}
results := store.Create(req)
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
for _, r := range results {
if r.Error != "" {
t.Errorf("unexpected error for %s: %s", r.Username, r.Error)
}
}
users, _ := store.List()
if len(users) != 3 {
t.Errorf("expected 3 users, got %d", len(users))
}
}
func TestUserStore_Create_DuplicateUsername(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req1 := CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John Doe"}},
}
store.Create(req1)
req2 := CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe", DisplayName: "Jane Doe"}},
}
results := store.Create(req2)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Error == "" {
t.Fatal("expected error for duplicate username, got nil")
}
if results[0].Error != "user already exists" {
t.Errorf("expected 'user already exists', got %q", results[0].Error)
}
}
func TestUserStore_Create_EmptyUsername(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req := CreateUserRequest{
Users: []CreateUserInput{{Username: ""}},
}
results := store.Create(req)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Error != "username is required" {
t.Errorf("expected 'username is required', got %q", results[0].Error)
}
}
func TestUserStore_GetByUsername_Found(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
store.Create(CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John", Email: "john@test.com"}},
})
user, err := store.GetByUsername("jdoe")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if user == nil {
t.Fatal("expected user to be found")
}
if user.DisplayName != "John" {
t.Errorf("expected display name 'John', got %q", user.DisplayName)
}
if user.Email != "john@test.com" {
t.Errorf("expected email 'john@test.com', got %q", user.Email)
}
}
func TestUserStore_GetByUsername_NotFound(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
user, err := store.GetByUsername("nonexistent")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if user != nil {
t.Fatal("expected nil for nonexistent user")
}
}
func TestUserStore_Delete_Existing(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
store.Create(CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe"}},
})
if err := store.Delete("jdoe"); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
user, _ := store.GetByUsername("jdoe")
if user != nil {
t.Error("expected user to be deleted")
}
}
func TestUserStore_Delete_NotFound(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
err := store.Delete("nonexistent")
if err == nil {
t.Fatal("expected error for deleting nonexistent user")
}
}
func TestUserStore_Count(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
count, _ := store.Count()
if count != 0 {
t.Errorf("expected count 0, got %d", count)
}
store.Create(CreateUserRequest{
Users: []CreateUserInput{
{Username: "user1"},
{Username: "user2"},
},
})
count, _ = store.Count()
if count != 2 {
t.Errorf("expected count 2, got %d", count)
}
}
func TestUserStore_SyncSnapshot(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
store.Create(CreateUserRequest{
Users: []CreateUserInput{
{Username: "user1", DisplayName: "User One", Email: "u1@test.com", Groups: "admins"},
{Username: "user2", DisplayName: "User Two", Groups: "users,devs"},
},
})
snapshot, err := store.SyncSnapshot()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(snapshot) != 2 {
t.Fatalf("expected 2 users in snapshot, got %d", len(snapshot))
}
// Check groups parsing
if len(snapshot[0].Groups) != 1 || snapshot[0].Groups[0] != "admins" {
t.Errorf("expected groups ['admins'], got %v", snapshot[0].Groups)
}
if len(snapshot[1].Groups) != 2 {
t.Errorf("expected 2 groups, got %v", snapshot[1].Groups)
}
}
// --- Auth Tests ---
func TestTokenAuthMiddleware_ValidToken(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestTokenAuthMiddleware_InvalidToken(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTokenAuthMiddleware_MissingHeader(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTokenAuthMiddleware_EmptyToken(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer ")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// --- SyncWriter Tests ---
func TestSyncWriter_Sync(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "users_database.yml")
sw := NewSyncWriter(usersDBPath, store)
store.Create(CreateUserRequest{
Users: []CreateUserInput{
{Username: "alice", DisplayName: "Alice", Email: "alice@test.com", Groups: "admins"},
{Username: "bob", DisplayName: "Bob", Groups: "users"},
},
})
if err := sw.Sync(); err != nil {
t.Fatalf("sync failed: %v", err)
}
data, err := os.ReadFile(usersDBPath)
if err != nil {
t.Fatalf("read sync file: %v", err)
}
if len(data) == 0 {
t.Fatal("sync file is empty")
}
content := string(data)
if !contains(content, "alice:") {
t.Errorf("expected 'alice:' in sync file")
}
if !contains(content, "bob:") {
t.Errorf("expected 'bob:' in sync file")
}
if !contains(content, "$argon2id$") {
t.Errorf("expected argon2id hash in sync file")
}
}
func TestSyncWriter_Sync_EmptyStore(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "empty_users.yml")
sw := NewSyncWriter(usersDBPath, store)
if err := sw.Sync(); err != nil {
t.Fatalf("sync should succeed with empty store: %v", err)
}
data, _ := os.ReadFile(usersDBPath)
content := string(data)
if !contains(content, "users:") {
t.Errorf("expected 'users:' key even with empty store")
}
}
func TestSyncWriter_Bootstrap_ExistingFile(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "users_database.yml")
yamlContent := []byte(`
users:
charlie:
displayname: "Charlie"
password: "$argon2id$v=19$m=65536,t=3,p=4$somesalt$somehash"
email: "charlie@test.com"
groups:
- admins
disabled: false
`)
if err := os.WriteFile(usersDBPath, yamlContent, 0644); err != nil {
t.Fatalf("write yaml: %v", err)
}
sw := NewSyncWriter(usersDBPath, store)
imported, err := sw.Bootstrap()
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if imported != 1 {
t.Errorf("expected 1 imported user, got %d", imported)
}
user, _ := store.GetByUsername("charlie")
if user == nil {
t.Fatal("expected charlie to be imported")
}
if user.DisplayName != "Charlie" {
t.Errorf("expected display name 'Charlie', got %q", user.DisplayName)
}
if user.Email != "charlie@test.com" {
t.Errorf("expected email 'charlie@test.com', got %q", user.Email)
}
}
func TestSyncWriter_Bootstrap_NoFile(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "nonexistent.yml")
sw := NewSyncWriter(usersDBPath, store)
imported, err := sw.Bootstrap()
if err != nil {
t.Fatalf("bootstrap should not error on missing file: %v", err)
}
if imported != 0 {
t.Errorf("expected 0 imported, got %d", imported)
}
}
func TestSyncWriter_Bootstrap_Idempotent(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "users_database.yml")
yamlContent := []byte("users:\n dave:\n password: \"$argon2id$v=19$m=65536,t=3,p=4$salt$hash\"\n")
os.WriteFile(usersDBPath, yamlContent, 0644)
sw := NewSyncWriter(usersDBPath, store)
imported1, _ := sw.Bootstrap()
imported2, _ := sw.Bootstrap()
if imported1 != 1 {
t.Errorf("expected 1 on first bootstrap, got %d", imported1)
}
if imported2 != 0 {
t.Errorf("expected 0 on second bootstrap (idempotent), got %d", imported2)
}
}
// --- Helper Tests ---
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
input string
delim string
expect []string
}{
{"", ",", nil},
{"a", ",", []string{"a"}},
{"a,b,c", ",", []string{"a", "b", "c"}},
{" a , b , c ", ",", []string{"a", "b", "c"}},
{"admins,users,devs", ",", []string{"admins", "users", "devs"}},
}
for _, tt := range tests {
result := splitAndTrim(tt.input, tt.delim)
if len(result) != len(tt.expect) {
t.Errorf("splitAndTrim(%q) = %v, want %v", tt.input, result, tt.expect)
continue
}
for i := range result {
if result[i] != tt.expect[i] {
t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expect[i])
}
}
}
}
func TestGeneratePassword(t *testing.T) {
pwd, err := generatePassword(20)
if err != nil {
t.Fatalf("generate password: %v", err)
}
if len(pwd) != 20 {
t.Errorf("expected length 20, got %d", len(pwd))
}
pwd2, _ := generatePassword(20)
if pwd == pwd2 {
t.Error("expected different passwords")
}
}
func TestHashPassword(t *testing.T) {
if _, err := exec.LookPath("/opt/authelia/authelia"); err != nil {
t.Skip("Authelia binary not available for testing")
}
hash, err := hashWithAuthelia("test-password")
if err != nil {
t.Fatalf("hashWithAuthelia failed: %v", err)
}
if !contains(hash, "$argon2id$") {
t.Errorf("expected argon2id prefix, got %q", hash)
}
if len(hash) < 60 {
t.Errorf("expected reasonably long hash, got %d chars", len(hash))
}
}
// contains checks if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
if s[i+j] != substr[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}

View file

@ -1,34 +0,0 @@
package admin
import (
"crypto/subtle"
"net/http"
)
// TokenAuthMiddleware protects admin routes with a static bearer token.
// All /admin/* routes require the Authorization: Bearer <token> header
// matching the configured admin.secret_token.
func TokenAuthMiddleware(secretToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
// Expect "Bearer <token>" format
const bearerPrefix = "Bearer "
if len(token) < len(bearerPrefix) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
token = token[len(bearerPrefix):]
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(token), []byte(secretToken)) != 1 {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -1,125 +0,0 @@
package admin
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
)
// Handler bundles admin HTTP handlers and their dependencies.
type Handler struct {
store *UserStore
syncWriter *SyncWriter
logger *slog.Logger
}
// NewHandler creates a new admin Handler.
func NewHandler(store *UserStore, syncWriter *SyncWriter, logger *slog.Logger) *Handler {
return &Handler{
store: store,
syncWriter: syncWriter,
logger: logger,
}
}
// RegisterRoutes mounts admin routes on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) {
// Admin API (protected by bearer token)
mux.Handle("GET /admin/api/users", authMiddleware(http.HandlerFunc(h.listUsers)))
mux.Handle("POST /admin/api/users", authMiddleware(http.HandlerFunc(h.createUsers)))
mux.Handle("DELETE /admin/api/users/{username}", authMiddleware(http.HandlerFunc(h.deleteUser)))
mux.Handle("GET /admin/api/health", authMiddleware(http.HandlerFunc(h.adminHealth)))
}
// --- API Handlers ---
func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.List()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if users == nil {
users = []User{}
}
writeJSON(w, http.StatusOK, users)
}
type createUsersResponse struct {
Results []CreateUserResult `json:"results"`
}
func (h *Handler) createUsers(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if len(req.Users) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no users provided"})
return
}
results := h.store.Create(req)
// Sync to Authelia YAML
if err := h.syncWriter.Sync(); err != nil {
h.logger.Error("sync failed after create", "error", err)
}
writeJSON(w, http.StatusCreated, createUsersResponse{Results: results})
}
func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
if username == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
return
}
if err := h.store.Delete(username); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
// Sync to Authelia YAML
if err := h.syncWriter.Sync(); err != nil {
h.logger.Error("sync failed after delete", "error", err)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "username": username})
}
func (h *Handler) adminHealth(w http.ResponseWriter, r *http.Request) {
count, err := h.store.Count()
status := "ok"
if err != nil {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": status,
"user_count": count,
"authelia_db": h.syncWriter.usersDBPath,
})
}
// --- Helpers ---
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// ErrorResponse is a generic error response.
type ErrorResponse struct {
Error string `json:"error"`
}
// IsHTMLRequest checks if the client expects HTML (for HTMX routing).
func IsHTMLRequest(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "text/html") || r.Header.Get("HX-Request") != ""
}

View file

@ -1,151 +0,0 @@
package admin
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// AutheliaUserDB represents the full structure of Authelia's users_database.yml.
type AutheliaUserDB struct {
Users map[string]AutheliaUserEntry `yaml:"users"`
}
// AutheliaUserEntry represents a single user entry in Authelia's YAML.
type AutheliaUserEntry struct {
DisplayName string `yaml:"displayname,omitempty"`
Password string `yaml:"password"`
Email string `yaml:"email,omitempty"`
Groups []string `yaml:"groups,omitempty"`
Disabled bool `yaml:"disabled,omitempty"`
}
// SyncWriter handles writing the user database to Authelia's YAML format.
type SyncWriter struct {
usersDBPath string
store *UserStore
}
// NewSyncWriter creates a new SyncWriter.
func NewSyncWriter(usersDBPath string, store *UserStore) *SyncWriter {
return &SyncWriter{
usersDBPath: usersDBPath,
store: store,
}
}
// Sync writes the current user store to Authelia's users_database.yml.
func (sw *SyncWriter) Sync() error {
syncUsers, err := sw.store.SyncSnapshot()
if err != nil {
return fmt.Errorf("get sync snapshot: %w", err)
}
db := AutheliaUserDB{
Users: make(map[string]AutheliaUserEntry, len(syncUsers)),
}
for _, u := range syncUsers {
db.Users[u.Username] = AutheliaUserEntry{
DisplayName: u.DisplayName,
Password: u.Password,
Email: u.Email,
Groups: u.Groups,
Disabled: u.Disabled,
}
}
// Ensure the target directory exists
dir := filepath.Dir(sw.usersDBPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create authelia data directory: %w", err)
}
data, err := yaml.Marshal(&db)
if err != nil {
return fmt.Errorf("marshal users database: %w", err)
}
if err := os.WriteFile(sw.usersDBPath, data, 0644); err != nil {
return fmt.Errorf("write users database: %w", err)
}
return nil
}
// Bootstrap imports existing Authelia users into the SQLite store.
// This runs on first initialization to adopt existing users.
func (sw *SyncWriter) Bootstrap() (int, error) {
data, err := os.ReadFile(sw.usersDBPath)
if err != nil {
if os.IsNotExist(err) {
return 0, nil // No existing file, nothing to bootstrap
}
return 0, fmt.Errorf("read authelia users database: %w", err)
}
var db AutheliaUserDB
if err := yaml.Unmarshal(data, &db); err != nil {
return 0, fmt.Errorf("parse authelia users database: %w", err)
}
imported := 0
for username, entry := range db.Users {
existing, _ := sw.store.GetByUsername(username)
if existing != nil {
continue // Already exists, skip
}
// Build groups string
groups := ""
for i, g := range entry.Groups {
if i > 0 {
groups += ","
}
groups += g
}
// Determine role from groups
role := "user"
if containsGroup(groups, "admins") {
role = "admin"
}
_, err := sw.store.GetDB().Exec(`
INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, username, entry.DisplayName, entry.Email, role, groups, entry.Password, entry.Disabled)
if err != nil {
return imported, fmt.Errorf("import user %s: %w", username, err)
}
imported++
}
return imported, nil
}
// FixRoles updates existing users' roles based on their groups.
func (sw *SyncWriter) FixRoles() (int, error) {
users, err := sw.store.List()
if err != nil {
return 0, err
}
fixed := 0
for _, u := range users {
expectedRole := "user"
if containsGroup(u.Groups, "admins") {
expectedRole = "admin"
}
if u.Role != expectedRole {
_, err := sw.store.GetDB().Exec("UPDATE users SET role = ? WHERE username = ?", expectedRole, u.Username)
if err != nil {
return fixed, err
}
fixed++
}
}
return fixed, nil
}

View file

@ -1,55 +0,0 @@
package templates
templ Dashboard(userCount int) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Admin Dashboard - Next Workspace</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js"></script>
<style>{ adminStyles() }</style>
</head>
<body>
<div class="app-container">
<nav class="sidebar">
<div class="sidebar-header">
<h1>NextWks</h1>
<span class="version">Admin</span>
</div>
<ul class="sidebar-nav">
<li><a href="/admin" class="nav-link">Dashboard</a></li>
<li><a href="/admin/users" class="nav-link">Users</a></li>
</ul>
<div class="sidebar-footer">
<span class="status-indicator" id="health-status">Connected</span>
</div>
</nav>
<main class="main-content">
<h2 style="margin-bottom:1.5rem;">Admin Dashboard</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;">
<div class="card" style="text-align:center;">
<div style="font-size:2rem;font-weight:700;color:var(--primary);">{ userCount }</div>
<div style="color:var(--text-muted);margin-top:0.25rem;">Total Users</div>
</div>
<div class="card" style="text-align:center;">
<div style="font-size:2rem;font-weight:700;color:var(--success);">Online</div>
<div style="color:var(--text-muted);margin-top:0.25rem;">Authelia API</div>
</div>
<div class="card" style="text-align:center;">
<div style="font-size:2rem;font-weight:700;color:var(--warning);">/opt/</div>
<div style="color:var(--text-muted);margin-top:0.25rem;">Runtime Path</div>
</div>
</div>
<div class="card mt-2">
<h2>Quick Actions</h2>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<a href="/admin/users" class="btn btn-primary">Manage Users</a>
</div>
</div>
</main>
</div>
</body>
</html>
}

View file

@ -1,53 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Dashboard(userCount int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Next Workspace</title><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><script src=\"https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js\"></script><style>{ adminStyles() }</style></head><body><div class=\"app-container\"><nav class=\"sidebar\"><div class=\"sidebar-header\"><h1>NextWks</h1><span class=\"version\">Admin</span></div><ul class=\"sidebar-nav\"><li><a href=\"/admin\" class=\"nav-link\">Dashboard</a></li><li><a href=\"/admin/users\" class=\"nav-link\">Users</a></li></ul><div class=\"sidebar-footer\"><span class=\"status-indicator\" id=\"health-status\">Connected</span></div></nav><main class=\"main-content\"><h2 style=\"margin-bottom:1.5rem;\">Admin Dashboard</h2><div style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;\"><div class=\"card\" style=\"text-align:center;\"><div style=\"font-size:2rem;font-weight:700;color:var(--primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(userCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/dashboard.templ`, Line: 33, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div style=\"color:var(--text-muted);margin-top:0.25rem;\">Total Users</div></div><div class=\"card\" style=\"text-align:center;\"><div style=\"font-size:2rem;font-weight:700;color:var(--success);\">Online</div><div style=\"color:var(--text-muted);margin-top:0.25rem;\">Authelia API</div></div><div class=\"card\" style=\"text-align:center;\"><div style=\"font-size:2rem;font-weight:700;color:var(--warning);\">/opt/</div><div style=\"color:var(--text-muted);margin-top:0.25rem;\">Runtime Path</div></div></div><div class=\"card mt-2\"><h2>Quick Actions</h2><div style=\"display:flex;gap:0.5rem;flex-wrap:wrap;\"><a href=\"/admin/users\" class=\"btn btn-primary\">Manage Users</a></div></div></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,172 +0,0 @@
package templates
templ BaseLayout(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title } - Next Workspace</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js"></script>
<style>{ adminStyles() }</style>
</head>
<body>
<div class="app-container">
<nav class="sidebar">
<div class="sidebar-header">
<h1>NextWks</h1>
<span class="version">Admin</span>
</div>
<ul class="sidebar-nav">
<li>
<a href="/admin" class="nav-link">Dashboard</a>
</li>
<li>
<a href="/admin/users" class="nav-link">Users</a>
</li>
</ul>
<div class="sidebar-footer">
<span class="status-indicator" id="health-status">Connected</span>
</div>
</nav>
<main class="main-content" id="main-content">
{ children... }
</main>
</div>
</body>
</html>
}
templ adminStyles() {
<style type="text/css">
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--border: #475569;
--text: #f1f5f9;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
--radius: 8px;
}
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
.app-container { display: flex; min-height: 100vh; }
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 1rem;
flex-shrink: 0;
}
.sidebar-header { margin-bottom: 2rem; }
.sidebar-header h1 { font-size: 1.25rem; font-weight: 700; color: var(--primary); }
.sidebar-header .version { font-size: 0.75rem; color: var(--text-muted); }
.sidebar-nav { list-style: none; display: flex; flex-direction: column; gap: 0.25rem; }
.nav-link {
display: block;
padding: 0.625rem 0.75rem;
color: var(--text);
text-decoration: none;
border-radius: var(--radius);
transition: background 0.15s;
}
.nav-link:hover { background: var(--surface-2); }
.sidebar-footer { margin-top: auto; padding-top: 1rem; }
.status-indicator { font-size: 0.75rem; color: var(--success); }
.main-content { flex: 1; padding: 1.5rem; overflow-y: auto; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.card h2 { font-size: 1.125rem; margin-bottom: 1rem; }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem 0.5rem;
border-bottom: 1px solid var(--border);
}
th { color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }
.btn {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.15s;
text-decoration: none;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { opacity: 0.9; }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.375rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.875rem;
}
.form-input:focus { outline: none; border-color: var(--primary); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
.badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
.badge-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
.alert {
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.alert-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); }
.password-display {
font-family: monospace;
background: var(--bg);
padding: 0.5rem;
border-radius: var(--radius);
user-select: all;
font-size: 0.875rem;
word-break: break-all;
}
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mt-2 { margin-top: 1rem; }
.flex { display: flex; }
.flex-between { display: flex; justify-content: space-between; align-items: center; }
</style>
}

View file

@ -1,90 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func BaseLayout(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/layout.templ`, Line: 9, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Next Workspace</title><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><script src=\"https://unpkg.com/htmx.org@2.0.4/dist/ext/response-targets.js\"></script><style>{ adminStyles() }</style></head><body><div class=\"app-container\"><nav class=\"sidebar\"><div class=\"sidebar-header\"><h1>NextWks</h1><span class=\"version\">Admin</span></div><ul class=\"sidebar-nav\"><li><a href=\"/admin\" class=\"nav-link\">Dashboard</a></li><li><a href=\"/admin/users\" class=\"nav-link\">Users</a></li></ul><div class=\"sidebar-footer\"><span class=\"status-indicator\" id=\"health-status\">Connected</span></div></nav><main class=\"main-content\" id=\"main-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func adminStyles() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<style type=\"text/css\">\n\t\t*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\t\t:root {\n\t\t\t--bg: #0f172a;\n\t\t\t--surface: #1e293b;\n\t\t\t--surface-2: #334155;\n\t\t\t--border: #475569;\n\t\t\t--text: #f1f5f9;\n\t\t\t--text-muted: #94a3b8;\n\t\t\t--primary: #3b82f6;\n\t\t\t--primary-hover: #2563eb;\n\t\t\t--danger: #ef4444;\n\t\t\t--success: #22c55e;\n\t\t\t--warning: #f59e0b;\n\t\t\t--radius: 8px;\n\t\t}\n\t\thtml { font-size: 14px; }\n\t\tbody {\n\t\t\tfont-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n\t\t\tbackground: var(--bg);\n\t\t\tcolor: var(--text);\n\t\t\tline-height: 1.5;\n\t\t\tmin-height: 100vh;\n\t\t}\n\t\t.app-container { display: flex; min-height: 100vh; }\n\t\t.sidebar {\n\t\t\twidth: 240px;\n\t\t\tbackground: var(--surface);\n\t\t\tborder-right: 1px solid var(--border);\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tpadding: 1rem;\n\t\t\tflex-shrink: 0;\n\t\t}\n\t\t.sidebar-header { margin-bottom: 2rem; }\n\t\t.sidebar-header h1 { font-size: 1.25rem; font-weight: 700; color: var(--primary); }\n\t\t.sidebar-header .version { font-size: 0.75rem; color: var(--text-muted); }\n\t\t.sidebar-nav { list-style: none; display: flex; flex-direction: column; gap: 0.25rem; }\n\t\t.nav-link {\n\t\t\tdisplay: block;\n\t\t\tpadding: 0.625rem 0.75rem;\n\t\t\tcolor: var(--text);\n\t\t\ttext-decoration: none;\n\t\t\tborder-radius: var(--radius);\n\t\t\ttransition: background 0.15s;\n\t\t}\n\t\t.nav-link:hover { background: var(--surface-2); }\n\t\t.sidebar-footer { margin-top: auto; padding-top: 1rem; }\n\t\t.status-indicator { font-size: 0.75rem; color: var(--success); }\n\t\t.main-content { flex: 1; padding: 1.5rem; overflow-y: auto; }\n\t\t.card {\n\t\t\tbackground: var(--surface);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\tmargin-bottom: 1rem;\n\t\t}\n\t\t.card h2 { font-size: 1.125rem; margin-bottom: 1rem; }\n\t\ttable {\n\t\t\twidth: 100%;\n\t\t\tborder-collapse: collapse;\n\t\t}\n\t\tth, td {\n\t\t\ttext-align: left;\n\t\t\tpadding: 0.75rem 0.5rem;\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\t\tth { color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; }\n\t\t.btn {\n\t\t\tdisplay: inline-flex;\n\t\t\talign-items: center;\n\t\t\tpadding: 0.5rem 1rem;\n\t\t\tborder: none;\n\t\t\tborder-radius: var(--radius);\n\t\t\tcursor: pointer;\n\t\t\tfont-size: 0.875rem;\n\t\t\tfont-weight: 500;\n\t\t\ttransition: background 0.15s;\n\t\t\ttext-decoration: none;\n\t\t}\n\t\t.btn-primary { background: var(--primary); color: white; }\n\t\t.btn-primary:hover { background: var(--primary-hover); }\n\t\t.btn-danger { background: var(--danger); color: white; }\n\t\t.btn-danger:hover { opacity: 0.9; }\n\t\t.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }\n\t\t.form-group { margin-bottom: 1rem; }\n\t\t.form-group label { display: block; margin-bottom: 0.375rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }\n\t\t.form-input {\n\t\t\twidth: 100%;\n\t\t\tpadding: 0.625rem 0.75rem;\n\t\t\tbackground: var(--bg);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tcolor: var(--text);\n\t\t\tfont-size: 0.875rem;\n\t\t}\n\t\t.form-input:focus { outline: none; border-color: var(--primary); }\n\t\t.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }\n\t\t.badge {\n\t\t\tdisplay: inline-block;\n\t\t\tpadding: 0.125rem 0.5rem;\n\t\t\tborder-radius: 9999px;\n\t\t\tfont-size: 0.75rem;\n\t\t\tfont-weight: 500;\n\t\t}\n\t\t.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }\n\t\t.badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }\n\t\t.badge-warning { background: rgba(245,158,11,0.15); color: var(--warning); }\n\t\t.alert {\n\t\t\tpadding: 1rem;\n\t\t\tborder-radius: var(--radius);\n\t\t\tmargin-bottom: 1rem;\n\t\t}\n\t\t.alert-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }\n\t\t.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); }\n\t\t.password-display {\n\t\t\tfont-family: monospace;\n\t\t\tbackground: var(--bg);\n\t\t\tpadding: 0.5rem;\n\t\t\tborder-radius: var(--radius);\n\t\t\tuser-select: all;\n\t\t\tfont-size: 0.875rem;\n\t\t\tword-break: break-all;\n\t\t}\n\t\t.mb-1 { margin-bottom: 0.5rem; }\n\t\t.mb-2 { margin-bottom: 1rem; }\n\t\t.mt-2 { margin-top: 1rem; }\n\t\t.flex { display: flex; }\n\t\t.flex-between { display: flex; justify-content: space-between; align-items: center; }\n\t</style>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,171 +0,0 @@
package templates
templ UserDashboard() {
<div class="flex-between mb-2">
<h2>User Management</h2>
<button class="btn btn-primary"
hx-get="/admin/users/create-form"
hx-target="#form-container"
hx-swap="innerHTML">
+ Add User
</button>
</div>
<div id="form-container" class="mb-2"></div>
<div class="card" id="user-table-container">
<div class="flex-between mb-1">
<h2>Users</h2>
<button class="btn btn-sm btn-primary"
hx-get="/admin/api/users"
hx-target="#user-table-body"
hx-swap="innerHTML"
hx-trigger="load, click">
Refresh
</button>
</div>
<table>
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="user-table-body"
hx-get="/admin/api/users"
hx-trigger="load"
hx-swap="innerHTML">
<tr><td colspan="6" style="text-align:center;color:var(--text-muted);padding:2rem;">Loading users...</td></tr>
</tbody>
</table>
</div>
}
templ UserRows(users []UserRow) {
for _, u := range users {
<tr>
<td><strong>{ u.Username }</strong></td>
<td>{ u.DisplayName }</td>
<td>{ u.Email }</td>
<td>{ u.Groups }</td>
<td>
if u.Disabled {
<span class="badge badge-danger">Disabled</span>
} else {
<span class="badge badge-success">Active</span>
}
</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/admin/api/users/{ u.Username }"
hx-confirm="Delete user { u.Username }?"
hx-target="closest tr"
hx-swap="delete">
Delete
</button>
</td>
</tr>
}
}
templ CreateUserForm() {
<div class="card" id="create-form">
<div class="flex-between mb-1">
<h2>Create New User</h2>
<button class="btn btn-sm btn-danger"
hx-get="/admin/users/cancel-form"
hx-target="#form-container"
hx-swap="innerHTML">
Cancel
</button>
</div>
<form hx-post="/admin/api/users"
hx-target="#form-container"
hx-swap="innerHTML">
<div class="form-row">
<div class="form-group">
<label>Username *</label>
<input type="text" name="username" class="form-input" required placeholder="e.g. jdoe"/>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" name="display_name" class="form-input" placeholder="e.g. John Doe"/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" class="form-input" placeholder="e.g. john@example.com"/>
</div>
<div class="form-group">
<label>Groups</label>
<input type="text" name="groups" class="form-input" placeholder="e.g. admins,users"/>
</div>
</div>
<button type="submit" class="btn btn-primary mt-2">Create User</button>
</form>
</div>
}
templ CreateUserSuccess(results []CreateUserResultRow) {
<div class="alert alert-success">
<strong>Users created successfully!</strong>
<div class="flex-between mt-2">
<span></span>
<button class="btn btn-sm btn-primary"
hx-get="/admin/users/create-form"
hx-target="#form-container"
hx-swap="innerHTML">
+ Add Another
</button>
</div>
</div>
for _, r := range results {
<div class="card">
<div class="flex-between">
<div>
<strong>{ r.Username }</strong>
if r.Error != "" {
<span class="badge badge-danger">Error</span>
} else {
<span class="badge badge-success">Created</span>
}
</div>
</div>
if r.Error != "" {
<p class="mt-2" style="color:var(--danger);">{ r.Error }</p>
} else {
<div class="mt-2">
<label style="font-size:0.75rem;color:var(--text-muted);">Generated Password (save this now)</label>
<div class="password-display">{ r.GeneratedPassword }</div>
</div>
}
</div>
}
<script type="text/javascript">
// Auto-refresh the user table after creation
setTimeout(function() {
htmx.trigger("#user-table-body", "click");
}, 500);
</script>
}
// Data types for template rendering
type UserRow struct {
Username string
DisplayName string
Email string
Groups string
Disabled bool
}
type CreateUserResultRow struct {
Username string
GeneratedPassword string
Error string
}

View file

@ -1,291 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func UserDashboard() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex-between mb-2\"><h2>User Management</h2><button class=\"btn btn-primary\" hx-get=\"/admin/users/create-form\" hx-target=\"#form-container\" hx-swap=\"innerHTML\">+ Add User</button></div><div id=\"form-container\" class=\"mb-2\"></div><div class=\"card\" id=\"user-table-container\"><div class=\"flex-between mb-1\"><h2>Users</h2><button class=\"btn btn-sm btn-primary\" hx-get=\"/admin/api/users\" hx-target=\"#user-table-body\" hx-swap=\"innerHTML\" hx-trigger=\"load, click\">Refresh</button></div><table><thead><tr><th>Username</th><th>Display Name</th><th>Email</th><th>Groups</th><th>Status</th><th>Actions</th></tr></thead> <tbody id=\"user-table-body\" hx-get=\"/admin/api/users\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><tr><td colspan=\"6\" style=\"text-align:center;color:var(--text-muted);padding:2rem;\">Loading users...</td></tr></tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func UserRows(users []UserRow) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
for _, u := range users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<tr><td><strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 51, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</strong></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(u.DisplayName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 52, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 53, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(u.Groups)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 54, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.Disabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"badge badge-danger\">Disabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge badge-success\">Active</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td><button class=\"btn btn-sm btn-danger\" hx-delete=\"/admin/api/users/{ u.Username }\" hx-confirm=\"Delete user { u.Username }?\" hx-target=\"closest tr\" hx-swap=\"delete\">Delete</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func CreateUserForm() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"card\" id=\"create-form\"><div class=\"flex-between mb-1\"><h2>Create New User</h2><button class=\"btn btn-sm btn-danger\" hx-get=\"/admin/users/cancel-form\" hx-target=\"#form-container\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-post=\"/admin/api/users\" hx-target=\"#form-container\" hx-swap=\"innerHTML\"><div class=\"form-row\"><div class=\"form-group\"><label>Username *</label> <input type=\"text\" name=\"username\" class=\"form-input\" required placeholder=\"e.g. jdoe\"></div><div class=\"form-group\"><label>Display Name</label> <input type=\"text\" name=\"display_name\" class=\"form-input\" placeholder=\"e.g. John Doe\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>Email</label> <input type=\"email\" name=\"email\" class=\"form-input\" placeholder=\"e.g. john@example.com\"></div><div class=\"form-group\"><label>Groups</label> <input type=\"text\" name=\"groups\" class=\"form-input\" placeholder=\"e.g. admins,users\"></div></div><button type=\"submit\" class=\"btn btn-primary mt-2\">Create User</button></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CreateUserSuccess(results []CreateUserResultRow) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"alert alert-success\"><strong>Users created successfully!</strong><div class=\"flex-between mt-2\"><span></span> <button class=\"btn btn-sm btn-primary\" hx-get=\"/admin/users/create-form\" hx-target=\"#form-container\" hx-swap=\"innerHTML\">+ Add Another</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range results {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card\"><div class=\"flex-between\"><div><strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 131, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</strong> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Error != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"badge badge-danger\">Error</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"badge badge-success\">Created</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Error != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"mt-2\" style=\"color:var(--danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 140, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"mt-2\"><label style=\"font-size:0.75rem;color:var(--text-muted);\">Generated Password (save this now)</label><div class=\"password-display\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.GeneratedPassword)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 144, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<script type=\"text/javascript\">\n\t\t// Auto-refresh the user table after creation\n\t\tsetTimeout(function() {\n\t\t\thtmx.trigger(\"#user-table-body\", \"click\");\n\t\t}, 500);\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Data types for template rendering
type UserRow struct {
Username string
DisplayName string
Email string
Groups string
Disabled bool
}
type CreateUserResultRow struct {
Username string
GeneratedPassword string
Error string
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,117 +0,0 @@
package admin
import (
"net/http"
"git.lohmar.co.uk/lexton-it/NextWks/core/admin/templates"
)
// RegisterUIRoutes mounts the admin UI (Templ-rendered) routes.
func (h *Handler) RegisterUIRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) {
// Admin dashboard page
mux.Handle("GET /admin", authMiddleware(http.HandlerFunc(h.adminDashboard)))
mux.Handle("GET /admin/", authMiddleware(http.HandlerFunc(h.adminDashboard)))
mux.Handle("GET /admin/users", authMiddleware(http.HandlerFunc(h.adminUsers)))
mux.Handle("GET /admin/users/create-form", authMiddleware(http.HandlerFunc(h.createUserForm)))
mux.Handle("GET /admin/users/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm)))
}
func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) {
// Count users for the dashboard
count, _ := h.store.Count()
component := templates.Dashboard(count)
component.Render(r.Context(), w)
}
func (h *Handler) adminUsers(w http.ResponseWriter, r *http.Request) {
component := templates.UserDashboard()
component.Render(r.Context(), w)
}
func (h *Handler) createUserForm(w http.ResponseWriter, r *http.Request) {
component := templates.CreateUserForm()
component.Render(r.Context(), w)
}
func (h *Handler) cancelForm(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(""))
}
// UserToRow converts a User model to a template UserRow.
func UserToRow(u User) templates.UserRow {
return templates.UserRow{
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
Groups: u.Groups,
Disabled: u.Disabled,
}
}
// userRowsHandler returns user rows for HTMX partial updates.
func (h *Handler) userRowsHandler(w http.ResponseWriter, r *http.Request) {
users, err := h.store.List()
if err != nil {
http.Error(w, "failed to load users", http.StatusInternalServerError)
return
}
rows := make([]templates.UserRow, 0, len(users))
for _, u := range users {
rows = append(rows, UserToRow(u))
}
component := templates.UserRows(rows)
component.Render(r.Context(), w)
}
// createUsersHandler processes the form submission via HTMX.
func (h *Handler) createUsersHandler(w http.ResponseWriter, r *http.Request) {
// Parse form data
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form data", http.StatusBadRequest)
return
}
username := r.FormValue("username")
displayName := r.FormValue("display_name")
email := r.FormValue("email")
groups := r.FormValue("groups")
req := CreateUserRequest{
Users: []CreateUserInput{
{
Username: username,
DisplayName: displayName,
Email: email,
Groups: groups,
},
},
}
results := h.store.Create(req)
// Sync to Authelia YAML
h.syncWriter.Sync()
// Convert to template results
resultRows := make([]templates.CreateUserResultRow, 0, len(results))
for _, r := range results {
resultRows = append(resultRows, templates.CreateUserResultRow{
Username: r.Username,
GeneratedPassword: r.GeneratedPassword,
Error: r.Error,
})
}
component := templates.CreateUserSuccess(resultRows)
component.Render(r.Context(), w)
}
// RegisterHTMXRoutes mounts the HTMX partial-update endpoints.
func (h *Handler) RegisterHTMXRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) {
// HTMX returns HTML fragments, not full pages
mux.Handle("GET /admin/users/list", authMiddleware(http.HandlerFunc(h.userRowsHandler)))
mux.Handle("POST /admin/users/create", authMiddleware(http.HandlerFunc(h.createUsersHandler)))
}

View file

@ -1,308 +0,0 @@
package admin
import (
"crypto/rand"
"database/sql"
"fmt"
"math/big"
"os/exec"
"strings"
)
// User represents a managed user in the NextWks admin system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role string `json:"role"` // "admin" or "user"
Groups string `json:"groups"`
PasswordHash string `json:"-"`
Disabled bool `json:"disabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// UserStore handles user CRUD operations against SQLite.
type UserStore struct {
db *sql.DB
}
// NewUserStore creates a new UserStore with the given database.
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
// List returns all non-deleted users.
func (s *UserStore) List() ([]User, error) {
rows, err := s.db.Query(`
SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at
FROM users ORDER BY username ASC
`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role,
&u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
// GetByUsername retrieves a single user by username.
func (s *UserStore) GetByUsername(username string) (*User, error) {
var u User
err := s.db.QueryRow(`
SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at
FROM users WHERE username = ?
`, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role,
&u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user %s: %w", username, err)
}
return &u, nil
}
// CreateUserRequest represents a request to create one or more users.
type CreateUserRequest struct {
Users []CreateUserInput `json:"users"`
}
// CreateUserInput represents a single user creation input.
type CreateUserInput struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role string `json:"role"` // "admin" or "user" (default: "user")
Groups string `json:"groups"`
}
// CreateUserResult holds the result of a user creation.
type CreateUserResult struct {
Username string `json:"username"`
GeneratedPassword string `json:"generated_password,omitempty"`
Error string `json:"error,omitempty"`
}
// Create creates users and returns results with generated passwords.
func (s *UserStore) Create(req CreateUserRequest) []CreateUserResult {
results := make([]CreateUserResult, 0, len(req.Users))
for _, input := range req.Users {
result := CreateUserResult{Username: input.Username}
// Validate username
if input.Username == "" {
result.Error = "username is required"
results = append(results, result)
continue
}
// Check for existing user
existing, _ := s.GetByUsername(input.Username)
if existing != nil {
result.Error = "user already exists"
results = append(results, result)
continue
}
// Generate random password
password, err := generatePassword(20)
if err != nil {
result.Error = fmt.Sprintf("password generation failed: %v", err)
results = append(results, result)
continue
}
// Hash password using Authelia's own crypto tool
hash, err := hashWithAuthelia(password)
if err != nil {
result.Error = fmt.Sprintf("password hashing failed: %v", err)
results = append(results, result)
continue
}
// Default role to "user" if not set
if input.Role == "" {
input.Role = "user"
}
// Build effective groups: role-based + explicit
effectiveGroups := input.Groups
if input.Role == "admin" {
if effectiveGroups == "" {
effectiveGroups = "admins"
} else if !containsGroup(effectiveGroups, "admins") {
effectiveGroups = effectiveGroups + ",admins"
}
}
_, err = s.db.Exec(`
INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
`, input.Username, input.DisplayName, input.Email, input.Role, effectiveGroups, hash)
if err != nil {
result.Error = fmt.Sprintf("insert failed: %v", err)
results = append(results, result)
continue
}
result.GeneratedPassword = password
results = append(results, result)
}
return results
}
// Delete removes a user by username.
func (s *UserStore) Delete(username string) error {
result, err := s.db.Exec("DELETE FROM users WHERE username = ?", username)
if err != nil {
return fmt.Errorf("delete user %s: %w", username, err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("user %s not found", username)
}
return nil
}
// Count returns the total number of users.
func (s *UserStore) Count() (int, error) {
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
return count, err
}
// containsGroup checks if a comma-separated groups string contains a specific group.
func containsGroup(groups, target string) bool {
for _, g := range splitAndTrim(groups, ",") {
if g == target {
return true
}
}
return false
}
// generatePassword creates a cryptographically secure random password.
func generatePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
result[i] = charset[n.Int64()]
}
return string(result), nil
}
// hashWithAuthelia uses Authelia's own binary to hash a password.
func hashWithAuthelia(password string) (string, error) {
cmd := exec.Command("/opt/authelia/authelia", "crypto", "hash", "generate", "--password", password)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("authelia hash: %w", err)
}
// Output format: "Digest: $argon2id$v=19$m=65536,t=3,p=4$salt$hash"
fields := strings.Fields(string(out))
for _, f := range fields {
if strings.HasPrefix(f, "$argon2") {
return f, nil
}
}
return "", fmt.Errorf("could not find hash in authelia output: %s", string(out))
}
// GetDB returns the underlying database connection for sync operations.
func (s *UserStore) GetDB() *sql.DB {
return s.db
}
// SyncUser is a snapshot of user data used for YAML export.
type SyncUser struct {
Username string
DisplayName string
Email string
Role string
Groups []string
Password string
Disabled bool
}
// SyncSnapshot returns all users for YAML export.
func (s *UserStore) SyncSnapshot() ([]SyncUser, error) {
users, err := s.List()
if err != nil {
return nil, err
}
syncUsers := make([]SyncUser, 0, len(users))
for _, u := range users {
var groups []string
if u.Groups != "" {
// Split by comma, trim spaces
groups = splitAndTrim(u.Groups, ",")
}
syncUsers = append(syncUsers, SyncUser{
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
Role: u.Role,
Groups: groups,
Password: u.PasswordHash,
Disabled: u.Disabled,
})
}
return syncUsers, nil
}
// splitAndTrim splits a string by delimiter and trims spaces.
func splitAndTrim(s, delim string) []string {
if s == "" {
return nil
}
// Simple split without importing slices
result := make([]string, 0)
current := ""
for i := 0; i < len(s); i++ {
if i+len(delim) <= len(s) && s[i:i+len(delim)] == delim {
if current != "" {
result = append(result, trimSpace(current))
current = ""
}
i += len(delim) - 1
} else {
current += string(s[i])
}
}
if current != "" {
result = append(result, trimSpace(current))
}
return result
}
// trimSpace removes leading and trailing whitespace.
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}

View file

@ -1,49 +0,0 @@
package auth
import (
"database/sql"
"net/http"
)
// RoleChecker validates that the session user has the required role.
type RoleChecker struct {
db *sql.DB
}
// NewRoleChecker creates a role checker backed by the database.
func NewRoleChecker(db *sql.DB) *RoleChecker {
return &RoleChecker{db: db}
}
// RequireAdmin is middleware that allows only users with the "admin" role.
// Must run after SessionMiddleware has populated the context.
func (rc *RoleChecker) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserID(r)
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
isAdmin, err := rc.IsAdmin(userID)
if err != nil || !isAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// IsAdmin checks if a user has the admin role.
func (rc *RoleChecker) IsAdmin(username string) (bool, error) {
var role string
err := rc.db.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return role == "admin", nil
}

View file

@ -1,233 +0,0 @@
package auth
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// OIDCConfig holds the configuration for the Authelia OIDC client.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
Domain string
}
// OIDCHandler handles OIDC authentication flows with Authelia.
type OIDCHandler struct {
config OIDCConfig
store *SessionStore
}
// NewOIDCHandler creates a new OIDC handler.
func NewOIDCHandler(config OIDCConfig, store *SessionStore) *OIDCHandler {
return &OIDCHandler{
config: config,
store: store,
}
}
// LoginRedirect redirects the user to Authelia's OIDC authorization endpoint.
func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
state := generateToken(16)
nonce := generateToken(16)
// PKCE: generate code verifier and challenge
verifier := generateToken(32)
challenge := pkceChallenge(verifier)
// Store state + verifier in cookies
http.SetCookie(w, &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/",
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "oidc_verifier",
Value: verifier,
Path: "/",
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
authURL := fmt.Sprintf(
"%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256",
h.config.IssuerURL,
url.QueryEscape(h.config.ClientID),
url.QueryEscape(h.config.RedirectURL),
state,
nonce,
challenge,
)
http.Redirect(w, r, authURL, http.StatusFound)
}
// Callback handles the OIDC authorization code callback from Authelia.
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("oidc_state")
if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest)
return
}
// Get state from URL query (GET) or form body (POST)
stateParam := r.URL.Query().Get("state")
if stateParam == "" {
r.ParseForm()
stateParam = r.Form.Get("state")
}
if stateParam == "" || stateParam != stateCookie.Value {
http.Error(w, "state mismatch", http.StatusForbidden)
return
}
// Get PKCE verifier from cookie
verifierCookie, _ := r.Cookie("oidc_verifier")
verifier := ""
if verifierCookie != nil {
verifier = verifierCookie.Value
}
// Clear state cookies
http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.SetCookie(w, &http.Cookie{Name: "oidc_verifier", Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
// Get code from URL query (GET) or form body (POST)
code := r.URL.Query().Get("code")
if code == "" {
code = r.Form.Get("code")
}
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
// Exchange code for tokens (with PKCE verifier)
username, err := h.exchangeCode(code, verifier)
if err != nil {
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}
token, err := h.store.CreateSession(username, 60)
if err != nil {
http.Error(w, "session creation failed", http.StatusInternalServerError)
return
}
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "nextwks_session",
Value: token,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/", http.StatusFound)
}
// exchangeCode exchanges an OIDC authorization code for an ID token.
func (h *OIDCHandler) exchangeCode(code, verifier string) (string, error) {
tokenURL := h.config.IssuerURL + "/api/oidc/token"
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {h.config.RedirectURL},
"client_id": {h.config.ClientID},
"code_verifier": {verifier},
}
resp, err := http.PostForm(tokenURL, data)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("parse token response: %w", err)
}
if tokenResp.IDToken == "" {
return "", fmt.Errorf("no id_token in response")
}
username, err := decodeJWTSub(tokenResp.IDToken)
if err != nil {
return "", fmt.Errorf("decode id_token: %w", err)
}
return username, nil
}
// decodeJWTSub extracts the "sub" (subject/username) from a JWT without verifying the signature.
func decodeJWTSub(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode JWT payload: %w", err)
}
var claims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("parse JWT claims: %w", err)
}
// Use preferred_username (actual username), fall back to sub (UUID)
username := claims.PreferredUsername
if username == "" {
username = claims.Sub
}
if username == "" {
return "", fmt.Errorf("missing username in id_token")
}
return username, nil
}
// pkceChallenge creates a PKCE S256 challenge from a verifier.
func pkceChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// AuthGateMiddleware protects routes behind OIDC authentication.
func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := GetUserID(r)
if !ok {
h.LoginRedirect(w, r)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -1,154 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"time"
)
// contextKey is used for storing values in request context.
type contextKey string
const (
ContextUserID contextKey = "user_id"
ContextRole contextKey = "role"
)
// SessionStore manages user sessions backed by SQLite.
type SessionStore struct {
db *sql.DB
roleDB *sql.DB // Optional: same DB, used for role lookups
}
// NewSessionStore creates a session store.
func NewSessionStore(db *sql.DB) *SessionStore {
return &SessionStore{db: db, roleDB: db}
}
// Session represents an authenticated user session.
type Session struct {
ID string
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
// CreateSession generates a new session for a user and returns the token.
func (s *SessionStore) CreateSession(userID string, expiryMinutes int) (string, error) {
token := generateToken(32)
tokenHash := hashToken(token)
_, err := s.db.Exec(
`INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at)
VALUES (?, ?, ?, datetime('now'), datetime('now', '+' || ? || ' minutes'))`,
token[:16], userID, tokenHash, expiryMinutes,
)
if err != nil {
return "", fmt.Errorf("create session: %w", err)
}
return token, nil
}
// ValidateSession checks if a session token is valid and returns the session.
func (s *SessionStore) ValidateSession(token string) (*Session, error) {
tokenHash := hashToken(token)
var sess Session
var createdAt, expiresAt string
err := s.db.QueryRow(
`SELECT id, user_id, created_at, expires_at
FROM sessions
WHERE token_hash = ? AND expires_at > datetime('now')`,
tokenHash,
).Scan(&sess.ID, &sess.UserID, &createdAt, &expiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("validate session: %w", err)
}
sess.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
sess.ExpiresAt, _ = time.Parse("2006-01-02 15:04:05", expiresAt)
return &sess, nil
}
// DeleteSession removes a session (logout).
func (s *SessionStore) DeleteSession(token string) error {
tokenHash := hashToken(token)
_, err := s.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash)
return err
}
// CleanExpired removes all expired sessions.
func (s *SessionStore) CleanExpired() error {
_, err := s.db.Exec("DELETE FROM sessions WHERE expires_at <= datetime('now')")
return err
}
// SessionMiddleware returns an HTTP middleware that validates session cookies.
// If valid, the user_id is stored in the request context.
func (s *SessionStore) SessionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("nextwks_session")
if err != nil {
// No cookie — pass through without session
next.ServeHTTP(w, r)
return
}
session, err := s.ValidateSession(cookie.Value)
if err != nil || session == nil {
// Invalid or expired — clear cookie and continue
http.SetCookie(w, &http.Cookie{
Name: "nextwks_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
next.ServeHTTP(w, r)
return
}
// Set user_id in context
// Set user_id and role in context
ctx := context.WithValue(r.Context(), ContextUserID, session.UserID)
// Look up role from database
var role string
s.roleDB.QueryRow("SELECT role FROM users WHERE username = ?", session.UserID).Scan(&role)
if role == "" {
role = "user"
}
ctx = context.WithValue(ctx, ContextRole, role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetUserID retrieves the authenticated user ID from the request context.
func GetUserID(r *http.Request) (string, bool) {
uid, ok := r.Context().Value(ContextUserID).(string)
return uid, ok
}
// generateToken creates a cryptographically secure random hex token.
func generateToken(length int) string {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)
}
// hashToken creates a SHA-256 hash of a token for storage.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}

View file

@ -1,100 +0,0 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config represents the full NextWks configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Admin AdminConfig `yaml:"admin"`
Database DatabaseConfig `yaml:"database"`
Authelia AutheliaConfig `yaml:"authelia"`
OIDC OIDCConfig `yaml:"oidc"`
SMTP SMTPConfig `yaml:"smtp"`
Session SessionConfig `yaml:"session"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
type AdminConfig struct {
SecretToken string `yaml:"secret_token"`
}
type DatabaseConfig struct {
Type string `yaml:"type"`
Path string `yaml:"path"`
}
type AutheliaConfig struct {
Host string `yaml:"host"`
ConfigPath string `yaml:"config_path"`
UsersDBPath string `yaml:"users_db_path"`
}
// OIDCConfig holds the OIDC provider settings (Authelia).
type OIDCConfig struct {
IssuerURL string `yaml:"issuer_url"` // Public-facing URL users reach (e.g., https://auth.sechpoint.app)
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
RedirectURL string `yaml:"redirect_url"`
Domain string `yaml:"domain"`
}
type SMTPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
From string `yaml:"from"`
}
type SessionConfig struct {
Secret string `yaml:"secret"`
ExpiryMinutes int `yaml:"expiry_minutes"`
}
// Load reads and parses the YAML configuration file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config file: %w", err)
}
return &cfg, nil
}
// AutheliaSessionSecret extracts the session.secret from Authelia's configuration.
func AutheliaSessionSecret(cfgPath string) (string, error) {
data, err := os.ReadFile(cfgPath)
if err != nil {
return "", fmt.Errorf("read authelia config: %w", err)
}
var autheliaCfg struct {
Session struct {
Secret string `yaml:"secret"`
} `yaml:"session"`
}
if err := yaml.Unmarshal(data, &autheliaCfg); err != nil {
return "", fmt.Errorf("parse authelia config: %w", err)
}
if autheliaCfg.Session.Secret == "" {
return "", fmt.Errorf("authelia session.secret not found in %s", cfgPath)
}
return autheliaCfg.Session.Secret, nil
}

View file

@ -1,132 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
const testdataDir = "testdata"
func testdataPath(name string) string {
return filepath.Join(testdataDir, name)
}
// --- Config.Load tests ---
func TestLoad_ValidConfig(t *testing.T) {
cfg, err := Load(testdataPath("valid-config.yaml"))
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("expected Server.Host '0.0.0.0', got %q", cfg.Server.Host)
}
if cfg.Server.Port != 8080 {
t.Errorf("expected Server.Port 8080, got %d", cfg.Server.Port)
}
if cfg.Admin.SecretToken != "test-admin-token-123" {
t.Errorf("expected Admin.SecretToken 'test-admin-token-123', got %q", cfg.Admin.SecretToken)
}
if cfg.Database.Type != "sqlite" {
t.Errorf("expected Database.Type 'sqlite', got %q", cfg.Database.Type)
}
if cfg.Database.Path != "/tmp/nextwks-test.db" {
t.Errorf("expected Database.Path '/tmp/nextwks-test.db', got %q", cfg.Database.Path)
}
if cfg.Authelia.Host != "http://127.0.0.1:9091" {
t.Errorf("expected Authelia.Host 'http://127.0.0.1:9091', got %q", cfg.Authelia.Host)
}
if cfg.Session.Secret != "test-session-secret" {
t.Errorf("expected Session.Secret 'test-session-secret', got %q", cfg.Session.Secret)
}
if cfg.Session.ExpiryMinutes != 60 {
t.Errorf("expected Session.ExpiryMinutes 60, got %d", cfg.Session.ExpiryMinutes)
}
if cfg.SMTP.Host != "mail.example.com" {
t.Errorf("expected SMTP.Host 'mail.example.com', got %q", cfg.SMTP.Host)
}
if cfg.SMTP.Port != 587 {
t.Errorf("expected SMTP.Port 587, got %d", cfg.SMTP.Port)
}
if cfg.SMTP.From != "noreply@example.com" {
t.Errorf("expected SMTP.From 'noreply@example.com', got %q", cfg.SMTP.From)
}
}
func TestLoad_MissingFile(t *testing.T) {
_, err := Load(testdataPath("nonexistent-file.yaml"))
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "invalid.yaml")
if err := os.WriteFile(tmpFile, []byte("invalid: yaml: \n bad: ["), 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
_, err := Load(tmpFile)
if err == nil {
t.Fatal("expected error for invalid YAML, got nil")
}
}
func TestLoad_EmptyFile(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "empty.yaml")
if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
cfg, err := Load(tmpFile)
if err != nil {
t.Fatalf("expected no error for empty file, got: %v", err)
}
// Empty file should yield zero-value config
if cfg.Server.Port != 0 {
t.Errorf("expected zero-value Port, got %d", cfg.Server.Port)
}
}
// --- Config.AutheliaSessionSecret tests ---
func TestAutheliaSessionSecret_Valid(t *testing.T) {
secret, err := AutheliaSessionSecret(testdataPath("valid-authelia-config.yaml"))
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if secret != "authelia-test-session-secret" {
t.Errorf("expected secret 'authelia-test-session-secret', got %q", secret)
}
}
func TestAutheliaSessionSecret_MissingFile(t *testing.T) {
_, err := AutheliaSessionSecret(testdataPath("nonexistent-authelia-config.yaml"))
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestAutheliaSessionSecret_NoSecretField(t *testing.T) {
_, err := AutheliaSessionSecret(testdataPath("no-session-authelia-config.yaml"))
if err == nil {
t.Fatal("expected error when session.secret is missing, got nil")
}
}
func TestAutheliaSessionSecret_EmptySecret(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "authelia-empty-secret.yaml")
content := []byte("session:\n name: test\n secret: \"\"\n")
if err := os.WriteFile(tmpFile, content, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
_, err := AutheliaSessionSecret(tmpFile)
if err == nil {
t.Fatal("expected error for empty session.secret, got nil")
}
}

View file

@ -1,15 +0,0 @@
host: 0.0.0.0
port: 9091
log:
level: debug
jwt_secret: test-jwt-secret
storage:
local:
path: /opt/authelia/data/db.sqlite
authentication_backend:
file:
path: /opt/authelia/data/users_database.yml

View file

@ -1,21 +0,0 @@
host: 0.0.0.0
port: 9091
log:
level: debug
jwt_secret: test-jwt-secret
session:
name: authelia_session
secret: authelia-test-session-secret
expiration: 1h
inactivity: 5m
storage:
local:
path: /opt/authelia/data/db.sqlite
authentication_backend:
file:
path: /opt/authelia/data/users_database.yml

View file

@ -1,26 +0,0 @@
server:
host: "0.0.0.0"
port: 8080
admin:
secret_token: "test-admin-token-123"
database:
type: "sqlite"
path: "/tmp/nextwks-test.db"
authelia:
host: "http://127.0.0.1:9091"
config_path: "/opt/authelia/config/configuration.yml"
users_db_path: "/opt/authelia/data/users_database.yml"
smtp:
host: "mail.example.com"
port: 587
username: "test@example.com"
password: "test-password"
from: "noreply@example.com"
session:
secret: "test-session-secret"
expiry_minutes: 60

View file

@ -1,119 +0,0 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// Database wraps the SQLite connection and provides migration helpers.
type Database struct {
DB *sql.DB
}
// Initialize opens (or creates) the SQLite database at the given path.
func Initialize(dbPath string) (*Database, error) {
// Ensure the data directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// Enable WAL mode for better concurrency
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("enable WAL mode: %w", err)
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
return &Database{DB: db}, nil
}
// Migrate runs automatic schema migrations on startup.
func (d *Database) Migrate() error {
migrations := []string{
`users`,
`sessions`,
`audit_logs`,
}
// Verify all required tables exist
for _, table := range migrations {
if err := d.ensureTable(table); err != nil {
return fmt.Errorf("ensure table %s: %w", table, err)
}
}
return nil
}
func (d *Database) ensureTable(name string) error {
switch name {
case "users":
// Create table if it doesn't exist
_, err := d.DB.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
groups TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// Migrate: add role column if missing (for existing databases)
d.DB.Exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
return nil
case "sessions":
_, err := d.DB.Exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
)
`)
return err
case "audit_logs":
_, err := d.DB.Exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
actor TEXT NOT NULL,
target TEXT,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
return err
}
return fmt.Errorf("unknown table: %s", name)
}
// Close cleanly shuts down the database connection.
func (d *Database) Close() error {
return d.DB.Close()
}

View file

@ -1,229 +0,0 @@
package db
import (
"database/sql"
"os"
"path/filepath"
"testing"
)
func TestInitialize_CreatesDirectory(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "subdir", "test.db")
db, err := Initialize(dbPath)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
defer db.Close()
// Verify directory was created
if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
t.Fatal("expected directory to be created")
}
// Verify database file was created
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("expected database file to be created")
}
}
func TestInitialize_OpensConnection(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := Initialize(dbPath)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
defer db.Close()
// Verify connection is alive
if err := db.DB.Ping(); err != nil {
t.Fatalf("expected ping to succeed, got: %v", err)
}
}
func TestInitialize_ExistingFile(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "existing.db")
// Create database once
db1, err := Initialize(dbPath)
if err != nil {
t.Fatalf("first init failed: %v", err)
}
db1.Close()
// Re-open existing database
db2, err := Initialize(dbPath)
if err != nil {
t.Fatalf("second init failed: %v", err)
}
defer db2.Close()
if err := db2.DB.Ping(); err != nil {
t.Fatalf("expected ping to succeed, got: %v", err)
}
}
func TestMigrate_CreatesTables(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "migrate-test.db")
database, err := Initialize(dbPath)
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer database.Close()
if err := database.Migrate(); err != nil {
t.Fatalf("migrate failed: %v", err)
}
// Verify tables exist
expectedTables := []string{"users", "sessions", "audit_logs"}
for _, table := range expectedTables {
var count int
row := database.DB.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
table,
)
if err := row.Scan(&count); err != nil {
t.Fatalf("failed to check table %s: %v", table, err)
}
if count == 0 {
t.Errorf("expected table %s to exist", table)
}
}
}
func TestMigrate_Idempotent(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "idempotent-test.db")
database, err := Initialize(dbPath)
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer database.Close()
// Run migrations twice
if err := database.Migrate(); err != nil {
t.Fatalf("first migrate failed: %v", err)
}
if err := database.Migrate(); err != nil {
t.Fatalf("second migrate should succeed (idempotent), got: %v", err)
}
}
func TestMigrate_TableSchemas(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "schema-test.db")
database, err := Initialize(dbPath)
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer database.Close()
database.Migrate()
// Verify sessions table columns
rows, err := database.DB.Query("PRAGMA table_info(sessions)")
if err != nil {
t.Fatalf("failed to get sessions schema: %v", err)
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
t.Fatalf("failed to scan column: %v", err)
}
columns[name] = true
_ = ctype
}
expectedCols := []string{"id", "user_id", "token_hash", "created_at", "expires_at"}
for _, col := range expectedCols {
if !columns[col] {
t.Errorf("expected column %q in sessions table", col)
}
}
}
func TestMigrate_UsersTableSchema(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "users-schema-test.db")
database, err := Initialize(dbPath)
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer database.Close()
database.Migrate()
// Verify users table columns
rows, err := database.DB.Query("PRAGMA table_info(users)")
if err != nil {
t.Fatalf("failed to get users schema: %v", err)
}
defer rows.Close()
columns := map[string]string{}
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
t.Fatalf("failed to scan column: %v", err)
}
columns[name] = ctype
}
expectedCols := []string{"id", "username", "display_name", "email", "groups", "password_hash", "disabled", "created_at", "updated_at"}
for _, col := range expectedCols {
if _, ok := columns[col]; !ok {
t.Errorf("expected column %q in users table", col)
}
}
// Verify username has UNIQUE constraint (SQLite creates an index for UNIQUE columns)
var indexCount int
database.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'sqlite_autoindex_users%' AND sql IS NULL").Scan(&indexCount)
if indexCount == 0 {
t.Error("expected UNIQUE constraint on username column")
}
// Spot-check specific types
if columns["username"] != "TEXT" {
t.Errorf("expected username type TEXT, got %s", columns["username"])
}
if columns["disabled"] != "INTEGER" {
t.Errorf("expected disabled type INTEGER, got %s", columns["disabled"])
}
}
func TestClose(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "close-test.db")
database, err := Initialize(dbPath)
if err != nil {
t.Fatalf("init failed: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close failed: %v", err)
}
// Ping should fail after close
if err := database.DB.Ping(); err == nil {
t.Fatal("expected ping to fail after close")
}
}

View file

@ -1,107 +0,0 @@
package ui
// AppTile represents an app on the workspace launcher.
type AppTile struct {
Name string
Description string
URL string
Icon string // SVG inline
Color string // Background color
Status string // "ready" or "coming-soon"
AdminOnly bool // Only visible to admin role
}
// DefaultApps returns apps filtered by user role.
func DefaultApps(role string) []AppTile {
all := []AppTile{
{
Name: "Admin Panel", Description: "Users, groups & settings",
URL: "/admin", Color: "#9333ea",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
Status: "ready", AdminOnly: true,
},
{
Name: "Files", Description: "Storage & sharing",
URL: "#", Color: "#2563eb",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
Status: "coming-soon",
},
{
Name: "Mail", Description: "Email client",
URL: "#", Color: "#dc2626",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
Status: "coming-soon",
},
{
Name: "Calendar", Description: "Schedule & events",
URL: "#", Color: "#059669",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
Status: "coming-soon",
},
{
Name: "Office", Description: "Documents & sheets",
URL: "#", Color: "#0891b2",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
Status: "coming-soon",
},
{
Name: "Contacts", Description: "People & directory",
URL: "#", Color: "#d97706",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
Status: "coming-soon",
},
{
Name: "Tasks", Description: "Project management",
URL: "#", Color: "#7c3aed",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`,
Status: "coming-soon",
},
{
Name: "Chat", Description: "Team messaging",
URL: "#", Color: "#db2777",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
Status: "coming-soon",
},
}
// Filter by role
filtered := make([]AppTile, 0, len(all))
for _, a := range all {
if a.AdminOnly && role != "admin" {
continue
}
filtered = append(filtered, a)
}
return filtered
}
templ AppGrid(apps []AppTile) {
<div class="app-grid">
for _, a := range apps {
@appTile(a)
}
</div>
}
templ appTile(a AppTile) {
if a.Status == "coming-soon" {
<div class="app-card disabled"
oncontextmenu="return false"
ontouchstart="startLongPress(event)" ontouchend="cancelLongPress()"
onmousedown="startLongPress(event)" onmouseup="cancelLongPress()">
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
<div class="app-name">{ a.Name }</div>
<div class="app-badge">Coming Soon</div>
</div>
} else {
<div class="app-card"
onclick="openApp('{ a.URL }')"
oncontextmenu="return false"
ontouchstart="startLongPress(event, '{ a.URL }')" ontouchend="cancelLongPress()"
onmousedown="startLongPress(event, '{ a.URL }')" onmouseup="cancelLongPress()">
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
<div class="app-name">{ a.Name }</div>
<div class="app-badge">Available</div>
</div>
}
}

View file

@ -1,229 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// AppTile represents an app on the workspace launcher.
type AppTile struct {
Name string
Description string
URL string
Icon string // SVG inline
Color string // Background color
Status string // "ready" or "coming-soon"
AdminOnly bool // Only visible to admin role
}
// DefaultApps returns apps filtered by user role.
func DefaultApps(role string) []AppTile {
all := []AppTile{
{
Name: "Admin Panel", Description: "Users, groups & settings",
URL: "/admin", Color: "#9333ea",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
Status: "ready", AdminOnly: true,
},
{
Name: "Files", Description: "Storage & sharing",
URL: "#", Color: "#2563eb",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
Status: "coming-soon",
},
{
Name: "Mail", Description: "Email client",
URL: "#", Color: "#dc2626",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
Status: "coming-soon",
},
{
Name: "Calendar", Description: "Schedule & events",
URL: "#", Color: "#059669",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
Status: "coming-soon",
},
{
Name: "Office", Description: "Documents & sheets",
URL: "#", Color: "#0891b2",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
Status: "coming-soon",
},
{
Name: "Contacts", Description: "People & directory",
URL: "#", Color: "#d97706",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
Status: "coming-soon",
},
{
Name: "Tasks", Description: "Project management",
URL: "#", Color: "#7c3aed",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`,
Status: "coming-soon",
},
{
Name: "Chat", Description: "Team messaging",
URL: "#", Color: "#db2777",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
Status: "coming-soon",
},
}
// Filter by role
filtered := make([]AppTile, 0, len(all))
for _, a := range all {
if a.AdminOnly && role != "admin" {
continue
}
filtered = append(filtered, a)
}
return filtered
}
func AppGrid(apps []AppTile) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"app-grid\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, a := range apps {
templ_7745c5c3_Err = appTile(a).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func appTile(a AppTile) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if a.Status == "coming-soon" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-card disabled\" oncontextmenu=\"return false\" ontouchstart=\"startLongPress(event)\" ontouchend=\"cancelLongPress()\" onmousedown=\"startLongPress(event)\" onmouseup=\"cancelLongPress()\"><div class=\"app-icon\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + a.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 92, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(a.Icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"app-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 93, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"app-badge\">Coming Soon</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"app-card\" onclick=\"openApp('{ a.URL }')\" oncontextmenu=\"return false\" ontouchstart=\"startLongPress(event, '{ a.URL }')\" ontouchend=\"cancelLongPress()\" onmousedown=\"startLongPress(event, '{ a.URL }')\" onmouseup=\"cancelLongPress()\"><div class=\"app-icon\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + a.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 102, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(a.Icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><div class=\"app-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 103, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><div class=\"app-badge\">Available</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,47 +0,0 @@
package ui
import (
"net/http"
"path/filepath"
"git.lohmar.co.uk/lexton-it/NextWks/core/auth"
)
// Handler serves the workspace launcher UI.
type Handler struct {
appDir string
}
// NewHandler creates a UI handler that serves the launcher and static assets.
func NewHandler(appDir string) *Handler {
return &Handler{appDir: appDir}
}
// RegisterRoutes mounts the public UI routes on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux, authGate func(http.Handler) http.Handler) {
staticDir := filepath.Join(h.appDir, "static")
mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))))
mux.Handle("GET /", authGate(http.HandlerFunc(h.launcherPage)))
}
func (h *Handler) launcherPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
userID, _ := auth.GetUserID(r)
role := getUserRole(r)
apps := DefaultApps(role)
component := LauncherPage(userID, role, apps)
component.Render(r.Context(), w)
}
// getUserRole extracts the user's role from the request context or session.
func getUserRole(r *http.Request) string {
if role, ok := r.Context().Value(auth.ContextRole).(string); ok {
return role
}
return "user"
}

View file

@ -1,256 +0,0 @@
package ui
import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version"
templ LauncherPage(userName string, role string, apps []AppTile) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Next Workspace</title>
<link rel="manifest" href="/static/manifest.json"/>
<meta name="theme-color" content="#1a1a2e"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>{ workspaceCSS() }</style>
</head>
<body>
<div class="shell">
@topBar(userName, role)
<main class="content">
@greetingSection(userName)
@AppGrid(apps)
</main>
<footer class="footer">
<span>NextWks v{ appver.Version }</span>
</footer>
</div>
<!-- PWA Modal -->
<div id="pwa-overlay" class="overlay" style="display:none" onclick="closePWA()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-head">
<h2>Install Next Workspace</h2>
<button class="close-btn" onclick="closePWA()">&times;</button>
</div>
<div class="modal-body" id="pwa-content">
@pwaInstructions()
</div>
</div>
</div>
<script>{ launcherJS() }</script>
</body>
</html>
}
templ greetingSection(userName string) {
<div class="greeting">
<div class="avatar">{ initials(userName) }</div>
<div class="greeting-text">
<h1>Good { timeOfDay() }, { userName }</h1>
<p>Your workspace is ready</p>
</div>
</div>
}
templ topBar(userName string, role string) {
<header class="topbar">
<div class="topbar-left">
<div class="logo-icon">N</div>
<span class="logo-text">NextWks</span>
</div>
<div class="topbar-right">
<button class="icon-btn" onclick="showPWA()" title="Install app">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<div class="user-menu">
<div class="avatar sm">{ initials(userName) }</div>
</div>
</div>
</header>
}
templ workspaceCSS() {
<style type="text/css">
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117; --surface:#161b22; --surface2:#21262d; --surface3:#30363d;
--border:#30363d; --text:#e6edf3; --text2:#8b949e; --text3:#6e7681;
--blue:#58a6ff; --green:#3fb950; --orange:#d2991d; --red:#f85149;
--purple:#a371f7; --pink:#db61a2; --teal:#39d353;
--radius:16px; --radius-sm:10px
}
html{font-size:15px}
body{
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;
background:var(--bg); color:var(--text); min-height:100vh; min-height:100dvh;
-webkit-tap-highlight-color:transparent; -webkit-user-select:none; user-select:none
}
.shell{display:flex;flex-direction:column;min-height:100vh;min-height:100dvh}
.topbar{
display:flex; justify-content:space-between; align-items:center;
padding:12px 16px; background:var(--surface); border-bottom:1px solid var(--border);
position:sticky; top:0; z-index:100
}
.topbar-left{display:flex;align-items:center;gap:10px}
.logo-icon{
width:32px;height:32px;border-radius:8px;background:var(--blue);
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:16px;color:#fff
}
.logo-text{font-weight:600;font-size:18px;color:var(--text)}
.topbar-right{display:flex;align-items:center;gap:8px}
.icon-btn{
width:36px;height:36px;border-radius:50%;border:none;background:transparent;
color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:all 0.15s
}
.icon-btn:hover{background:var(--surface2);color:var(--text)}
.avatar{
width:40px;height:40px;border-radius:50%;background:var(--purple);
display:flex;align-items:center;justify-content:center;
font-weight:600;font-size:15px;color:#fff;flex-shrink:0
}
.avatar.sm{width:32px;height:32px;font-size:13px}
.content{flex:1;padding:24px 16px;max-width:900px;margin:0 auto;width:100%}
.greeting{display:flex;align-items:center;gap:12px;margin-bottom:24px}
@media(min-width:600px){.greeting{margin-bottom:32px}}
.greeting-text h1{font-size:1.25rem;font-weight:600;color:var(--text);line-height:1.3}
.greeting-text p{color:var(--text2);font-size:0.85rem;margin-top:2px}
/* App Grid */
.app-grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:20px 10px; padding:8px 0
}
@media(min-width:600px){.app-grid{grid-template-columns:repeat(4,1fr);gap:24px 14px}}
@media(min-width:900px){.app-grid{grid-template-columns:repeat(6,1fr);gap:28px 16px}}
/* App Card */
.app-card{
display:flex; flex-direction:column; align-items:center; gap:6px;
cursor:pointer; text-decoration:none; color:inherit; padding:8px 4px;
border-radius:12px; transition:all 0.15s; -webkit-tap-highlight-color:transparent
}
.app-card:active{transform:scale(0.92)}
.app-card:hover{background:rgba(255,255,255,0.03)}
.app-card .app-icon{
width:56px;height:56px;border-radius:14px;
display:flex;align-items:center;justify-content:center;flex-shrink:0
}
@media(min-width:600px){.app-card .app-icon{width:64px;height:64px;border-radius:16px}}
.app-card .app-icon svg{width:28px;height:28px}
@media(min-width:600px){.app-card .app-icon svg{width:32px;height:32px}}
.app-card .app-name{
font-size:0.72rem;font-weight:400;color:var(--text);text-align:center;
line-height:1.2;max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap
}
@media(min-width:600px){.app-card .app-name{font-size:0.78rem}}
.app-card .app-badge{display:none}
.app-card.disabled{opacity:0.35;cursor:default}
.app-card.disabled:active{transform:none}
/* Footer */
.footer{
text-align:center;padding:16px;color:var(--text3);font-size:0.7rem;
border-top:1px solid var(--border);margin-top:auto
}
</style>
}
// --- Helpers ---
func version() string {
return appver.Version
}
func initials(name string) string {
if name == "" { return "?" }
if len(name) == 1 { return name }
return string(name[0])
}
func timeOfDay() string {
// Simple: always show "morning" for now
return "morning"
}
// --- PWA Instructions ---
templ pwaInstructions() {
<h3>Desktop Chrome/Edge</h3>
<ol>
<li>Click the <code>⊕</code> icon in the address bar</li>
<li>Click <strong>Install</strong></li>
</ol>
<h3>iOS Safari</h3>
<ol>
<li>Tap <strong>Share</strong> <code>⎋</code></li>
<li>Tap <strong>Add to Home Screen</strong></li>
</ol>
<h3>Android Chrome</h3>
<ol>
<li>Tap <strong>⋮</strong> menu</li>
<li>Tap <strong>Install app</strong></li>
</ol>
<button class="btn" onclick="closePWA()">Got it</button>
}
// --- JavaScript ---
templ launcherJS() {
<script type="text/javascript">
let longPressTimer = null;
let currentUrl = null;
function openApp(url) {
window.open(url, '_blank');
}
function startLongPress(e, url) {
currentUrl = url || null;
longPressTimer = setTimeout(function() {
showPWA();
}, 600);
}
function cancelLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function showPWA() {
document.getElementById('pwa-overlay').style.display = 'flex';
}
function closePWA() {
document.getElementById('pwa-overlay').style.display = 'none';
}
// PWA install handler
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
});
// Override showPWA to try native prompt first
document.addEventListener('DOMContentLoaded', function() {
var origShowPWA = showPWA;
showPWA = function() {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function() { deferredPrompt = null; });
} else {
origShowPWA();
}
};
});
</script>
}

View file

@ -1,303 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version"
func LauncherPage(userName string, role string, apps []AppTile) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\"><title>Next Workspace</title><link rel=\"manifest\" href=\"/static/manifest.json\"><meta name=\"theme-color\" content=\"#1a1a2e\"><meta name=\"apple-mobile-web-app-capable\" content=\"yes\"><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><style>{ workspaceCSS() }</style></head><body><div class=\"shell\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = topBar(userName, role).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = greetingSection(userName).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AppGrid(apps).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><footer class=\"footer\"><span>NextWks v")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(appver.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 27, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></footer></div><!-- PWA Modal --><div id=\"pwa-overlay\" class=\"overlay\" style=\"display:none\" onclick=\"closePWA()\"><div class=\"modal\" onclick=\"event.stopPropagation()\"><div class=\"modal-head\"><h2>Install Next Workspace</h2><button class=\"close-btn\" onclick=\"closePWA()\">&times;</button></div><div class=\"modal-body\" id=\"pwa-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = pwaInstructions().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></div><script>{ launcherJS() }</script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func greetingSection(userName string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"greeting\"><div class=\"avatar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(initials(userName))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 51, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"greeting-text\"><h1>Good ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(timeOfDay())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 53, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ", ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(userName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 53, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h1><p>Your workspace is ready</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func topBar(userName string, role string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<header class=\"topbar\"><div class=\"topbar-left\"><div class=\"logo-icon\">N</div><span class=\"logo-text\">NextWks</span></div><div class=\"topbar-right\"><button class=\"icon-btn\" onclick=\"showPWA()\" title=\"Install app\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg></button><div class=\"user-menu\"><div class=\"avatar sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(initials(userName))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 70, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></div></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func workspaceCSS() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<style type=\"text/css\">\n\t\t*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n\t\t:root{\n\t\t\t--bg:#0d1117; --surface:#161b22; --surface2:#21262d; --surface3:#30363d;\n\t\t\t--border:#30363d; --text:#e6edf3; --text2:#8b949e; --text3:#6e7681;\n\t\t\t--blue:#58a6ff; --green:#3fb950; --orange:#d2991d; --red:#f85149;\n\t\t\t--purple:#a371f7; --pink:#db61a2; --teal:#39d353;\n\t\t\t--radius:16px; --radius-sm:10px\n\t\t}\n\t\thtml{font-size:15px}\n\t\tbody{\n\t\t\tfont-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;\n\t\t\tbackground:var(--bg); color:var(--text); min-height:100vh; min-height:100dvh;\n\t\t\t-webkit-tap-highlight-color:transparent; -webkit-user-select:none; user-select:none\n\t\t}\n\t\t.shell{display:flex;flex-direction:column;min-height:100vh;min-height:100dvh}\n\t\t.topbar{\n\t\t\tdisplay:flex; justify-content:space-between; align-items:center;\n\t\t\tpadding:12px 16px; background:var(--surface); border-bottom:1px solid var(--border);\n\t\t\tposition:sticky; top:0; z-index:100\n\t\t}\n\t\t.topbar-left{display:flex;align-items:center;gap:10px}\n\t\t.logo-icon{\n\t\t\twidth:32px;height:32px;border-radius:8px;background:var(--blue);\n\t\t\tdisplay:flex;align-items:center;justify-content:center;\n\t\t\tfont-weight:700;font-size:16px;color:#fff\n\t\t}\n\t\t.logo-text{font-weight:600;font-size:18px;color:var(--text)}\n\t\t.topbar-right{display:flex;align-items:center;gap:8px}\n\t\t.icon-btn{\n\t\t\twidth:36px;height:36px;border-radius:50%;border:none;background:transparent;\n\t\t\tcolor:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;\n\t\t\ttransition:all 0.15s\n\t\t}\n\t\t.icon-btn:hover{background:var(--surface2);color:var(--text)}\n\t\t.avatar{\n\t\t\twidth:40px;height:40px;border-radius:50%;background:var(--purple);\n\t\t\tdisplay:flex;align-items:center;justify-content:center;\n\t\t\tfont-weight:600;font-size:15px;color:#fff;flex-shrink:0\n\t\t}\n\t\t.avatar.sm{width:32px;height:32px;font-size:13px}\n\t\t.content{flex:1;padding:24px 16px;max-width:900px;margin:0 auto;width:100%}\n\t\t.greeting{display:flex;align-items:center;gap:12px;margin-bottom:24px}\n\t\t@media(min-width:600px){.greeting{margin-bottom:32px}}\n\t\t.greeting-text h1{font-size:1.25rem;font-weight:600;color:var(--text);line-height:1.3}\n\t\t.greeting-text p{color:var(--text2);font-size:0.85rem;margin-top:2px}\n\n\t\t/* App Grid */\n\t\t.app-grid{\n\t\t\tdisplay:grid;\n\t\t\tgrid-template-columns:repeat(3,1fr);\n\t\t\tgap:20px 10px; padding:8px 0\n\t\t}\n\t\t@media(min-width:600px){.app-grid{grid-template-columns:repeat(4,1fr);gap:24px 14px}}\n\t\t@media(min-width:900px){.app-grid{grid-template-columns:repeat(6,1fr);gap:28px 16px}}\n\n\t\t/* App Card */\n\t\t.app-card{\n\t\t\tdisplay:flex; flex-direction:column; align-items:center; gap:6px;\n\t\t\tcursor:pointer; text-decoration:none; color:inherit; padding:8px 4px;\n\t\t\tborder-radius:12px; transition:all 0.15s; -webkit-tap-highlight-color:transparent\n\t\t}\n\t\t.app-card:active{transform:scale(0.92)}\n\t\t.app-card:hover{background:rgba(255,255,255,0.03)}\n\t\t.app-card .app-icon{\n\t\t\twidth:56px;height:56px;border-radius:14px;\n\t\t\tdisplay:flex;align-items:center;justify-content:center;flex-shrink:0\n\t\t}\n\t\t@media(min-width:600px){.app-card .app-icon{width:64px;height:64px;border-radius:16px}}\n\t\t.app-card .app-icon svg{width:28px;height:28px}\n\t\t@media(min-width:600px){.app-card .app-icon svg{width:32px;height:32px}}\n\t\t.app-card .app-name{\n\t\t\tfont-size:0.72rem;font-weight:400;color:var(--text);text-align:center;\n\t\t\tline-height:1.2;max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\n\t\t}\n\t\t@media(min-width:600px){.app-card .app-name{font-size:0.78rem}}\n\t\t.app-card .app-badge{display:none}\n\t\t.app-card.disabled{opacity:0.35;cursor:default}\n\t\t.app-card.disabled:active{transform:none}\n\n\t\t/* Footer */\n\t\t.footer{\n\t\t\ttext-align:center;padding:16px;color:var(--text3);font-size:0.7rem;\n\t\t\tborder-top:1px solid var(--border);margin-top:auto\n\t\t}\n\t</style>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// --- Helpers ---
func version() string {
return appver.Version
}
func initials(name string) string {
if name == "" {
return "?"
}
if len(name) == 1 {
return name
}
return string(name[0])
}
func timeOfDay() string {
// Simple: always show "morning" for now
return "morning"
}
// --- PWA Instructions ---
func pwaInstructions() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h3>Desktop Chrome/Edge</h3><ol><li>Click the <code>⊕</code> icon in the address bar</li><li>Click <strong>Install</strong></li></ol><h3>iOS Safari</h3><ol><li>Tap <strong>Share</strong> <code>⎋</code></li><li>Tap <strong>Add to Home Screen</strong></li></ol><h3>Android Chrome</h3><ol><li>Tap <strong>⋮</strong> menu</li><li>Tap <strong>Install app</strong></li></ol><button class=\"btn\" onclick=\"closePWA()\">Got it</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// --- JavaScript ---
func launcherJS() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<script type=\"text/javascript\">\n\t\tlet longPressTimer = null;\n\t\tlet currentUrl = null;\n\n\t\tfunction openApp(url) {\n\t\t\twindow.open(url, '_blank');\n\t\t}\n\n\t\tfunction startLongPress(e, url) {\n\t\t\tcurrentUrl = url || null;\n\t\t\tlongPressTimer = setTimeout(function() {\n\t\t\t\tshowPWA();\n\t\t\t}, 600);\n\t\t}\n\n\t\tfunction cancelLongPress() {\n\t\t\tif (longPressTimer) {\n\t\t\t\tclearTimeout(longPressTimer);\n\t\t\t\tlongPressTimer = null;\n\t\t\t}\n\t\t}\n\n\t\tfunction showPWA() {\n\t\t\tdocument.getElementById('pwa-overlay').style.display = 'flex';\n\t\t}\n\n\t\tfunction closePWA() {\n\t\t\tdocument.getElementById('pwa-overlay').style.display = 'none';\n\t\t}\n\n\t\t// PWA install handler\n\t\tlet deferredPrompt = null;\n\t\twindow.addEventListener('beforeinstallprompt', function(e) {\n\t\t\te.preventDefault();\n\t\t\tdeferredPrompt = e;\n\t\t});\n\n\t\t// Override showPWA to try native prompt first\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tvar origShowPWA = showPWA;\n\t\t\tshowPWA = function() {\n\t\t\t\tif (deferredPrompt) {\n\t\t\t\t\tdeferredPrompt.prompt();\n\t\t\t\t\tdeferredPrompt.userChoice.then(function() { deferredPrompt = null; });\n\t\t\t\t} else {\n\t\t\t\t\torigShowPWA();\n\t\t\t\t}\n\t\t\t};\n\t\t});\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,46 +0,0 @@
package ui
templ PWAInstallPrompt() {
<div class="pwa-prompt" id="pwa-prompt">
<h3>🚀 Install Next Workspace</h3>
<p>Install as an app for quick access and offline support.</p>
<button class="btn btn-primary" onclick="installPWA()">
Install to Desktop
</button>
</div>
}
templ PWAGuideModal() {
<div class="modal" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closePWAModal()">&times;</button>
<h2>Install Next Workspace</h2>
<p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p>
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;">🖥️ Desktop Chrome/Edge</h3>
<ol>
<li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li>
<li>Click <strong>Install</strong> in the popup</li>
<li>The app will open in its own window</li>
</ol>
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">📱 iOS Safari</h3>
<ol>
<li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li>
<li>Scroll down and tap <strong>Add to Home Screen</strong></li>
<li>Tap <strong>Add</strong> in the top-right corner</li>
<li>The app icon will appear on your home screen</li>
</ol>
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">🤖 Android Chrome</h3>
<ol>
<li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li>
<li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li>
<li>Tap <strong>Install</strong></li>
</ol>
<button class="btn btn-primary" style="margin-top:1rem;width:100%;" onclick="closePWAModal()">
Got it
</button>
</div>
}

View file

@ -1,69 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func PWAInstallPrompt() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"pwa-prompt\" id=\"pwa-prompt\"><h3>🚀 Install Next Workspace</h3><p>Install as an app for quick access and offline support.</p><button class=\"btn btn-primary\" onclick=\"installPWA()\">Install to Desktop</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func PWAGuideModal() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"modal\" onclick=\"event.stopPropagation()\"><button class=\"modal-close\" onclick=\"closePWAModal()\">&times;</button><h2>Install Next Workspace</h2><p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;\">🖥️ Desktop Chrome/Edge</h3><ol><li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li><li>Click <strong>Install</strong> in the popup</li><li>The app will open in its own window</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">📱 iOS Safari</h3><ol><li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li><li>Scroll down and tap <strong>Add to Home Screen</strong></li><li>Tap <strong>Add</strong> in the top-right corner</li><li>The app icon will appear on your home screen</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">🤖 Android Chrome</h3><ol><li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li><li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li><li>Tap <strong>Install</strong></li></ol><button class=\"btn btn-primary\" style=\"margin-top:1rem;width:100%;\" onclick=\"closePWAModal()\">Got it</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,21 +0,0 @@
package version
var (
// Version is set at build time via ldflags: -X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=2026.6.0001
Version = "dev"
// BuildTime is set at build time via ldflags.
BuildTime = "unknown"
// CommitSHA is set at build time via ldflags.
CommitSHA = "unknown"
)
// Info returns a formatted version info response.
func Info() map[string]string {
return map[string]string{
"version": Version,
"build_time": BuildTime,
"commit": CommitSHA,
}
}

View file

@ -1,21 +0,0 @@
module git.lohmar.co.uk/lexton-it/NextWks
go 1.25.0
require (
github.com/a-h/templ v0.3.1020 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.3.0 // indirect
github.com/go-chi/cors v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.46.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.52.0 // indirect
)

View file

@ -1,34 +0,0 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

View file

@ -1,227 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"git.lohmar.co.uk/lexton-it/NextWks/core/admin"
"git.lohmar.co.uk/lexton-it/NextWks/core/auth"
"git.lohmar.co.uk/lexton-it/NextWks/core/config"
"git.lohmar.co.uk/lexton-it/NextWks/core/db"
"git.lohmar.co.uk/lexton-it/NextWks/core/ui"
"git.lohmar.co.uk/lexton-it/NextWks/core/version"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
// Config path: default to ./config.yaml for dev, override with -config for production
configPath := flag.String("config", "./config.yaml", "path to configuration file")
flag.Parse()
logger.Info("starting Next Workspace (NextWks)", "version", version.Version, "config", *configPath)
// Load configuration
cfg, err := config.Load(*configPath)
if err != nil {
logger.Error("failed to load config", "error", err)
os.Exit(1)
}
// Initialize database
database, err := db.Initialize(cfg.Database.Path)
if err != nil {
logger.Error("failed to initialize database", "error", err)
os.Exit(1)
}
defer database.Close()
// Run schema migrations
if err := database.Migrate(); err != nil {
logger.Error("failed to run migrations", "error", err)
os.Exit(1)
}
logger.Info("database initialized and migrated", "path", cfg.Database.Path)
// Initialize admin components
userStore := admin.NewUserStore(database.DB)
syncWriter := admin.NewSyncWriter(cfg.Authelia.UsersDBPath, userStore)
// Bootstrap: import existing Authelia users if this is a fresh start
imported, err := syncWriter.Bootstrap()
if err != nil {
logger.Warn("bootstrap authelia users", "error", err)
} else if imported > 0 {
logger.Info("bootstrapped authelia users", "count", imported)
}
// Fix existing user roles based on groups
if fixed, err := syncWriter.FixRoles(); err != nil {
logger.Warn("fix roles", "error", err)
} else if fixed > 0 {
logger.Info("fixed user roles", "count", fixed)
}
// Create admin handler
adminHandler := admin.NewHandler(userStore, syncWriter, logger)
// Initialize session store and OIDC auth
sessionStore := auth.NewSessionStore(database.DB)
roleChecker := auth.NewRoleChecker(database.DB)
// OIDC issuer: public-facing URL (via Zoraxy) for browser redirects
// Falls back to authelia.host if not configured
issuerURL := cfg.OIDC.IssuerURL
if issuerURL == "" {
issuerURL = cfg.Authelia.Host
}
oidcCfg := auth.OIDCConfig{
IssuerURL: issuerURL,
ClientID: cfg.OIDC.ClientID,
ClientSecret: cfg.OIDC.ClientSecret,
RedirectURL: cfg.OIDC.RedirectURL,
Domain: cfg.OIDC.Domain,
}
oidcHandler := auth.NewOIDCHandler(oidcCfg, sessionStore)
// Initialize launcher UI handler
// appDir is the directory containing config.yaml (and static/ subdir)
appDir := filepath.Dir(*configPath)
if appDir == "." {
appDir = "./"
}
uiHandler := ui.NewHandler(appDir)
// Setup HTTP router
mux := http.NewServeMux()
// --- Public endpoints ---
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(version.Info())
})
// --- OIDC auth routes (public) ---
mux.HandleFunc("GET /auth/login", oidcHandler.LoginRedirect)
mux.HandleFunc("GET /auth/callback", oidcHandler.Callback)
mux.HandleFunc("POST /auth/callback", oidcHandler.Callback)
mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
// Clear session cookie
http.SetCookie(w, &http.Cookie{
Name: "nextwks_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/auth/login", http.StatusFound)
})
// --- Workspace launcher (public, but OIDC-protected) ---
// Chain: SessionMiddleware (reads cookie → sets context) → AuthGate (checks context → redirects if needed)
combinedAuth := func(next http.Handler) http.Handler {
return sessionStore.SessionMiddleware(oidcHandler.AuthGateMiddleware(next))
}
uiHandler.RegisterRoutes(mux, combinedAuth)
// --- Admin auth: session (with admin role) OR bearer token ---
bearerAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken)
adminAuth := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// First, try session-based authentication
cookie, err := r.Cookie("nextwks_session")
if err == nil && cookie != nil {
session, err := sessionStore.ValidateSession(cookie.Value)
if err == nil && session != nil {
isAdmin, _ := roleChecker.IsAdmin(session.UserID)
if isAdmin {
ctx := context.WithValue(r.Context(), auth.ContextUserID, session.UserID)
ctx = context.WithValue(ctx, auth.ContextRole, "admin")
next.ServeHTTP(w, r.WithContext(ctx))
return
} else {
ctx := context.WithValue(r.Context(), auth.ContextUserID, session.UserID)
ctx = context.WithValue(ctx, auth.ContextRole, "user")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
}
// Fall back to bearer token
bearerAuth(next).ServeHTTP(w, r)
})
}
adminHandler.RegisterRoutes(mux, adminAuth)
adminHandler.RegisterUIRoutes(mux, adminAuth)
adminHandler.RegisterHTMXRoutes(mux, adminAuth)
// --- OIDC config page — shows Authelia status ---
mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"provider":"Authelia","issuer":"%s","status":"configured"}`, cfg.Authelia.Host)
})
// --- PWA Guide modal (HTMX fragment) ---
mux.HandleFunc("GET /pwa-guide", func(w http.ResponseWriter, r *http.Request) {
component := ui.PWAGuideModal()
component.Render(r.Context(), w)
})
// CORS middleware
handler := corsMiddleware(mux)
// Start server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
server := &http.Server{
Addr: addr,
Handler: handler,
}
// Graceful shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
logger.Info("shutting down server...")
server.Close()
}()
logger.Info("server listening", "address", addr)
logger.Info("workspace launcher", "url", fmt.Sprintf("http://%s/", addr))
logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr))
logger.Info("auth status", "url", fmt.Sprintf("http://%s/auth/status", addr))
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
// corsMiddleware adds CORS headers for frontend access.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -1,27 +0,0 @@
# Authelia Configuration (Development Mock)
# Path: used for testing config parsing
host: 0.0.0.0
port: 9091
log:
level: debug
jwt_secret: dev-jwt-secret-change-in-production
session:
name: authelia_session
secret: dev-authelia-session-secret-please-change
expiration: 1h
inactivity: 5m
storage:
local:
path: /opt/authelia/data/db.sqlite
access_control:
default_policy: deny
authentication_backend:
file:
path: /opt/authelia/data/users_database.yml

51
tools/firewall-routing.sh Executable file
View file

@ -0,0 +1,51 @@
#!/bin/bash
# =====================================================================
# VM LOCAL FIREWALL & PORT REDIRECTION SCRIPT
# VM IP: 172.16.9.10 | Internal Interface: eth0 (or similar)
# Redirects inbound 80/443 to non-root Caddy on 8080/8443
# =====================================================================
# 1. CLEAN SLATE
# Flush all rules and delete custom chains across filter and NAT tables
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
iptables -t nat -F
iptables -F
iptables -X
iptables -t nat -X
# 2. LOCAL PORT REDIRECTION (Caddy Non-Root Helper)
# ---------------------------------------------------------------------
# A. Inbound traffic coming from outside the VM (e.g., forwarded from Proxmox)
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443
# B. Local traffic generated inside the VM aimed strictly at localhost/127.0.0.1
# Note: By specifying '-o lo', you leave your outbound internet (GitHub, Google) untouched!
iptables -t nat -A OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080
iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443
# 3. VM INPUT FIREWALL RULES
# ---------------------------------------------------------------------
# Allow everything on loopback
iptables -A INPUT -i lo -j ACCEPT
# Allow established connections (allows responses to your outbound traffic like curl)
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow SSH (Port 22) - Important for your Proxmox port forward (22910 -> 22)
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
# Allow the actual redirected Caddy ports from outside (just in case)
iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
iptables -A INPUT -p tcp --dport 8443 -j ACCEPT
# Allow alternative app ports (like the 8000 you have forwarded in Proxmox)
iptables -A INPUT -p tcp --dport 8000 -j ACCEPT
# 4. GLOBAL SECURITY DROP RULE
# Drop all other unsolicited inbound traffic targeting this VM
iptables -A INPUT -j DROP
echo "VM Firewall and Caddy Redirection Applied Successfully."

BIN
tools/hash-password/hash-password Executable file

Binary file not shown.

View file

@ -0,0 +1,21 @@
package main
import (
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: hash-password <password>")
os.Exit(1)
}
hash, err := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Print(string(hash))
}

42
tools/manage-users.sh Normal file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Manage users via authelia-api
# Usage: ./manage-users.sh list
# ./manage-users.sh create username displayname email groups...
# ./manage-users.sh delete username
API_BASE="http://127.0.0.1:8080"
TOKEN=$(grep -oP 'session_secret: \K.*' /opt/nextworkspace/config/authelia/configuration.yml)
case "${1:-}" in
list)
curl -s -H "Authorization: Bearer $TOKEN" "$API_BASE/api/users" | jq . 2>/dev/null || \
curl -s -H "Authorization: Bearer $TOKEN" "$API_BASE/api/users"
;;
create)
shift
if [ $# -lt 3 ]; then
echo "Usage: $0 create username displayname email [groups...]" >&2
exit 1
fi
USERNAME="$1"; DISPLAY="$2"; EMAIL="$3"; shift 3
GROUPS='["users"'
for g in "$@"; do GROUPS="$GROUPS,\"$g\""; done
GROUPS="$GROUPS]"
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"users\":[{\"username\":\"$USERNAME\",\"display_name\":\"$DISPLAY\",\"email\":\"$EMAIL\",\"groups\":$GROUPS}]}" \
"$API_BASE/api/users/bulk"
;;
delete)
if [ -z "${2:-}" ]; then
echo "Usage: $0 delete username" >&2
exit 1
fi
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
"$API_BASE/api/users/$2"
;;
*)
echo "Usage: $0 {list|create|delete} ..." >&2
exit 1
;;
esac

356
tools/nextwks.sh Executable file
View file

@ -0,0 +1,356 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
BUILD_DIR="/tmp/nextwks-build"
TARGET_DIR="/opt/nextworkspace"
BACKUP_DIR="/opt/backup"
NETWORK_NAME="nextwks-net"
HEALTH_CHECK_RETRIES=15
HEALTH_CHECK_INTERVAL=3
usage() {
echo "Usage: $0 [--install|--update|--destroy]"
echo " --install First-time setup on a bare VM (prompts for config)"
echo " --update Smart update: pull, build, copy, bounce containers"
echo " --destroy Full greenfield redeploy (uses saved secrets)"
exit 1
}
[ $# -eq 0 ] && usage
MODE="${1#--}"
case "$MODE" in install|update|destroy) ;; *) usage ;; esac
# MUST NOT run as root — podman must be rootless
if [ "$(id -u)" -eq 0 ]; then
echo "ERROR: Do NOT run this script with sudo or as root."
echo " Run it as your normal user: ./nextwks.sh --$MODE"
echo " The script will prompt for sudo only where needed (apt, /opt/, iptables)."
exit 1
fi
# Helper: run with sudo for operations that need root
maybe_sudo() {
sudo "$@"
}
# --- Load existing env (if any), skip if unreadable ---
# Temporarily disable -u because .env may contain $ signs (bcrypt hashes)
set +u
if [ -r "$BACKUP_DIR/.env" ]; then
set -a; source "$BACKUP_DIR/.env"; set +a
elif [ -r "$TARGET_DIR/.env" ]; then
set -a; source "$TARGET_DIR/.env"; set +a
fi
set -u
if [ -z "${DOMAIN:-}" ]; then
echo "ERROR: DOMAIN is not set. Configure it in /opt/backup/.env or run --install to set it up."
exit 1
fi
echo "=== NextWorkspace ${MODE} ==="
# ============================================================
# 1. INSTALL MODE — first-time setup (only on bare VM)
# ============================================================
if [ "$MODE" = "install" ]; then
if [ -f "$TARGET_DIR/nextworkspace" ]; then
echo "================================================================="
echo " NextWorkspace is already installed at $TARGET_DIR"
echo ""
echo " Use --update to rebuild and restart:"
echo " ./nextwks.sh --update"
echo ""
echo " Use --destroy for a full greenfield redeploy:"
echo " ./nextwks.sh --destroy"
echo "================================================================="
exit 0
fi
echo "[*] Installing system dependencies..."
maybe_sudo apt-get update -qq
maybe_sudo apt-get install -y -qq git build-essential curl podman podman-compose iptables-persistent
if ! command -v go &>/dev/null; then
echo "[*] Installing Go..."
GO_VERSION=$(curl -sL https://go.dev/VERSION?m=text)
GO_URL="https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz"
curl -sL "$GO_URL" -o /tmp/go.tar.gz
maybe_sudo rm -rf /usr/local/go
maybe_sudo tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
maybe_sudo sh -c 'echo "export PATH=\$PATH:/usr/local/go/bin" > /etc/profile.d/go.sh'
maybe_sudo chmod +x /etc/profile.d/go.sh
export PATH=$PATH:/usr/local/go/bin
fi
# Enable user lingering — containers stay alive after logout
maybe_sudo loginctl enable-linger "$USER" 2>/dev/null || true
# Clean up any old rootful containers from a previous deploy
echo "[*] Cleaning up old rootful containers (if any)..."
maybe_sudo podman stop caddy authelia launcher 2>/dev/null || true
maybe_sudo podman rm caddy authelia launcher 2>/dev/null || true
maybe_sudo podman network rm "$NETWORK_NAME" 2>/dev/null || true
echo ""
echo "--- NextWorkspace Configuration ---"
read -p "Domain [nextwks.eu]: " input; DOMAIN="${input:-$DOMAIN}"
read -p "TLS email (Let's Encrypt): " TLS_EMAIL
while [ -z "$TLS_EMAIL" ]; do read -p "TLS email (required): " TLS_EMAIL; done
while echo "$TLS_EMAIL" | grep -qv '@'; do read -p "Invalid email: " TLS_EMAIL; done
# Validate required configs
if [ -z "$TLS_EMAIL" ] || [ -z "$DOMAIN" ]; then
echo "ERROR: TLS_EMAIL and DOMAIN are required."
exit 1
fi
read -p "Admin username: " ADMIN_USERNAME
while [ -z "$ADMIN_USERNAME" ]; do read -p "Admin username (required): " ADMIN_USERNAME; done
# 24 chars, mixed case + numbers, no special chars (safe for .env)
ADMIN_PASSWORD=$(openssl rand -base64 30 | tr -dc 'A-Za-z0-9')
ADMIN_PASSWORD="${ADMIN_PASSWORD:0:24}"
echo ""
echo "========================================"
echo " Domain: $DOMAIN"
echo " TLS email: $TLS_EMAIL"
echo " Admin username: $ADMIN_USERNAME"
echo " Admin password: $ADMIN_PASSWORD"
echo " Save this password — it won't be shown again!"
echo "========================================"
echo ""
read -p "SMTP host [smtp.openxchange.eu]: " SMTP_HOST; SMTP_HOST="${SMTP_HOST:-smtp.openxchange.eu}"
read -p "SMTP port [587]: " SMTP_PORT; SMTP_PORT="${SMTP_PORT:-587}"
read -p "SMTP user [post@nextwks.eu]: " SMTP_USER; SMTP_USER="${SMTP_USER:-post@nextwks.eu}"
read -sp "SMTP password: " SMTP_PASS; echo ""
[ -z "$SMTP_PASS" ] && echo "ERROR: SMTP password required" && exit 1
read -p "IMAP host [imap.openxchange.eu]: " IMAP_HOST; IMAP_HOST="${IMAP_HOST:-imap.openxchange.eu}"
read -p "IMAP port [993]: " IMAP_PORT; IMAP_PORT="${IMAP_PORT:-993}"
# Persist config to backup vault (single-quote values to protect $ signs)
maybe_sudo mkdir -p "$BACKUP_DIR"
maybe_sudo sh -c "cat > '$BACKUP_DIR/.env' <<'ENVEOF'
# NextWorkspace Configuration — auto-generated by nextwks.sh --install
DOMAIN='$DOMAIN'
TLS_EMAIL='$TLS_EMAIL'
ADMIN_USERNAME='$ADMIN_USERNAME'
ADMIN_PASSWORD='$ADMIN_PASSWORD'
SMTP_HOST='$SMTP_HOST'
SMTP_PORT='$SMTP_PORT'
SMTP_USER='$SMTP_USER'
SMTP_PASS='$SMTP_PASS'
IMAP_HOST='$IMAP_HOST'
IMAP_PORT='$IMAP_PORT'
ENVEOF"
maybe_sudo chmod 600 "$BACKUP_DIR/.env"
fi
# ============================================================
# 2. CLONE fresh (every mode — ensures latest code)
# ============================================================
echo "[*] Cloning repository..."
rm -rf "$BUILD_DIR"
git clone --depth 1 "$REPO_URL" "$BUILD_DIR"
cd "$BUILD_DIR"
# Save script to user's home for easy future access (--install only)
if [ "$MODE" = "install" ]; then
cp "$BUILD_DIR/tools/nextwks.sh" "$HOME/nextwks.sh"
chmod +x "$HOME/nextwks.sh"
echo "[*] Saved to $HOME/nextwks.sh — use it for future updates"
fi
# ============================================================
# 3. FIREWALL SETUP (all modes)
# ============================================================
if [ "$MODE" = "install" ]; then
echo "[*] Applying firewall and port redirects (80→8080, 443→8443)..."
maybe_sudo bash "$BUILD_DIR/tools/firewall-routing.sh"
elif [ "$MODE" = "update" ] || [ "$MODE" = "destroy" ]; then
# Lightweight: ensure redirects exist without flushing existing rules
echo "[*] Ensuring port redirects (80→8080, 443→8443)..."
maybe_sudo iptables -t nat -C PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080 2>/dev/null || \
maybe_sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080
maybe_sudo iptables -t nat -C PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null || \
maybe_sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443
maybe_sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080 2>/dev/null || \
maybe_sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080
maybe_sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null || \
maybe_sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443
fi
# Persist across reboots (always)
if command -v netfilter-persistent &>/dev/null; then
maybe_sudo netfilter-persistent save 2>/dev/null || true
else
maybe_sudo mkdir -p /etc/iptables
maybe_sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
fi
# ============================================================
# 4. BUILD static binary
# ============================================================
echo "[*] Building static binary..."
export PATH=$PATH:/usr/local/go/bin
CGO_ENABLED=0 go build -o nextworkspace .
# ============================================================
# 5. STOP containers (all modes — binary is mounted, must stop before copy)
# ============================================================
echo "[*] Stopping containers..."
podman stop caddy authelia launcher 2>/dev/null || true
sleep 1
# ============================================================
# 6. CREATE target & backup directories (as root)
# ============================================================
maybe_sudo mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \
"$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \
"$TARGET_DIR/compose" "$TARGET_DIR/www" \
"$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs" \
"$BACKUP_DIR"
# ============================================================
# 7. TEARDOWN (destroy mode only — wipes target dir)
# ============================================================
if [ "$MODE" = "destroy" ]; then
echo "[*] Full teardown..."
# Stop rootless containers
podman stop caddy authelia launcher 2>/dev/null || true
podman rm caddy authelia launcher 2>/dev/null || true
podman network rm -f "$NETWORK_NAME" 2>/dev/null || true
# Wipe target
maybe_sudo rm -rf "$TARGET_DIR"
maybe_sudo mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \
"$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \
"$TARGET_DIR/compose" "$TARGET_DIR/www" \
"$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs"
fi
# ============================================================
# 8. COPY artifacts to target (as root)
# ============================================================
echo "[*] Copying artifacts..."
maybe_sudo cp nextworkspace "$TARGET_DIR/nextworkspace"
maybe_sudo cp "$BUILD_DIR/VERSION" "$TARGET_DIR/VERSION"
if [ -d "$BUILD_DIR/config/www" ]; then
maybe_sudo cp -r "$BUILD_DIR/config/www"/* "$TARGET_DIR/www/"
fi
if [ -d "$BUILD_DIR/lng" ]; then
maybe_sudo rm -rf "$TARGET_DIR/lng"
maybe_sudo cp -r "$BUILD_DIR/lng" "$TARGET_DIR/lng"
fi
if [ -d "$BUILD_DIR/config/nextworkspace" ]; then
maybe_sudo cp -r "$BUILD_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/"
fi
# Restore .env from backup
if [ -f "$BACKUP_DIR/.env" ]; then
maybe_sudo cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
maybe_sudo chmod 644 "$TARGET_DIR/.env"
fi
# ============================================================
# 9. GENERATE config files with placeholder substitution
# Write to /tmp first, then sudo cp to target
# ============================================================
GEN_DIR=$(mktemp -d)
trap "rm -rf '$GEN_DIR'" EXIT
echo "[*] Generating config files..."
# Caddyfile
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{TLS_EMAIL}|$TLS_EMAIL|g" \
"$BUILD_DIR/config/caddy/Caddyfile" > "$GEN_DIR/Caddyfile"
# Authelia config — preserve existing secrets if present
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
STORAGE_ENCRYPTION_KEY="${STORAGE_ENCRYPTION_KEY:-$(openssl rand -hex 32)}"
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{JWT_SECRET}|$JWT_SECRET|g" \
-e "s|{SESSION_SECRET}|$SESSION_SECRET|g" \
-e "s|{STORAGE_ENCRYPTION_KEY}|$STORAGE_ENCRYPTION_KEY|g" \
-e "s|{SMTP_HOST}|${SMTP_HOST:-smtp.openxchange.eu}|g" \
-e "s|{SMTP_PORT}|${SMTP_PORT:-587}|g" \
-e "s|{SMTP_USER}|${SMTP_USER:-post@nextwks.eu}|g" \
-e "s|{SMTP_PASS}|$SMTP_PASS|g" \
"$BUILD_DIR/config/authelia/configuration.yml" > "$GEN_DIR/configuration.yml"
# Users database — regenerate hash if ADMIN_PASSWORD is available
if [ -n "${ADMIN_PASSWORD:-}" ]; then
ADMIN_PASSWORD_HASH=$(cd "$BUILD_DIR" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "$ADMIN_PASSWORD_HASH")
fi
sed -e "s|{ADMIN_PASSWORD_HASH}|$ADMIN_PASSWORD_HASH|g" \
-e "s|{TLS_EMAIL}|$TLS_EMAIL|g" \
"$BUILD_DIR/config/authelia/users_database.yml" > "$GEN_DIR/users_database.yml"
# Copy generated configs to target
maybe_sudo cp "$GEN_DIR/Caddyfile" "$TARGET_DIR/config/caddy/Caddyfile"
maybe_sudo cp "$GEN_DIR/configuration.yml" "$TARGET_DIR/config/authelia/configuration.yml"
maybe_sudo cp "$GEN_DIR/users_database.yml" "$TARGET_DIR/config/authelia/users_database.yml"
# Persist generated secrets so --destroy is idempotent
if [ -f "$BACKUP_DIR/.env" ]; then
maybe_sudo sed -i "/^JWT_SECRET=/d; /^SESSION_SECRET=/d; /^STORAGE_ENCRYPTION_KEY=/d; /^ADMIN_PASSWORD_HASH=/d" "$BACKUP_DIR/.env" 2>/dev/null || true
fi
# Use pipe to avoid bash -c re-expanding $ signs (bcrypt hashes contain $2a$10$...)
echo "JWT_SECRET='$JWT_SECRET'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
echo "SESSION_SECRET='$SESSION_SECRET'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
echo "STORAGE_ENCRYPTION_KEY='$STORAGE_ENCRYPTION_KEY'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
[ -n "$ADMIN_PASSWORD_HASH" ] && echo "ADMIN_PASSWORD_HASH='$ADMIN_PASSWORD_HASH'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null
maybe_sudo chmod 600 "$BACKUP_DIR/.env"
# ============================================================
# 10. FIX OWNERSHIP — all files in TARGET_DIR/BACKUP_DIR to user
# ============================================================
RUN_USER="${SUDO_USER:-${USER}}"
echo "[*] Setting file ownership to $RUN_USER..."
maybe_sudo chown -R "$RUN_USER:" "$TARGET_DIR" 2>/dev/null || true
maybe_sudo chown -R "$RUN_USER:" "$BACKUP_DIR" 2>/dev/null || true
# ============================================================
# 11. DEPLOY stack (rootless podman — no sudo!)
# ============================================================
echo "[*] Deploying containers on $NETWORK_NAME..."
podman network rm -f "$NETWORK_NAME" 2>/dev/null || true
podman network create --subnet 172.18.0.0/24 "$NETWORK_NAME"
# AUTHELIA_SECRET is SESSION_SECRET (Authelia session.secret)
AUTHELIA_SECRET="${SESSION_SECRET:-}"
if [ -z "$AUTHELIA_SECRET" ]; then
AUTHELIA_SECRET=$(sed -n '/^session:/,/^[a-z]/p' "$TARGET_DIR/config/authelia/configuration.yml" \
| grep 'secret:' | awk '{print $2}' 2>/dev/null || echo "")
fi
# Generate compose file with substituted secret
sed -e "s|{AUTHELIA_SECRET}|$AUTHELIA_SECRET|g" \
"$BUILD_DIR/compose/stack.yaml" > "$GEN_DIR/stack.yaml"
cp "$GEN_DIR/stack.yaml" "$TARGET_DIR/compose/stack.yaml"
podman-compose -f "$TARGET_DIR/compose/stack.yaml" down 2>/dev/null || true
sleep 1
podman-compose -f "$TARGET_DIR/compose/stack.yaml" up -d 2>&1 || echo "[WARN] Stack deploy had issues"
# ============================================================
# 12. HEALTH CHECK
# ============================================================
echo "[*] Running health check..."
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
HEALTH=$(podman exec launcher curl -sf http://127.0.0.1:9000/health 2>/dev/null || echo "")
if [ "$HEALTH" = "OK" ]; then
echo "[OK] NextWorkspace launcher is healthy"
echo "[OK] https://$DOMAIN/"
exit 0
fi
sleep $HEALTH_CHECK_INTERVAL
done
echo "[FAIL] Health check failed — launcher did not respond"
echo ""
echo "--- Container status ---"
podman ps -a --filter "name=caddy|authelia|launcher" 2>/dev/null || true
echo ""
echo "--- Launcher logs (last 20 lines) ---"
podman logs launcher --tail 20 2>/dev/null || echo " (no logs)"
exit 1

View file

@ -1,57 +0,0 @@
#!/bin/bash
# ============================================================
# NextWks — Production Update Script
# Pulls latest code, rebuilds, restarts NextWks only.
# Users & auth are managed by Authelia (untouched).
# Run: cd /opt/nextwks && sudo bash update.sh
# ============================================================
set -euo pipefail
GREEN='\033[0;32m'; BLUE='\033[0;34m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${BLUE}$1${NC}"; }
ok() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
REPO_DIR="/tmp/nextwks-update"
INSTALL_DIR="/opt/nextwks"
BIN_DIR="${INSTALL_DIR}/bin"
DATA_DIR="${INSTALL_DIR}/data"
info "Updating NextWks..."
# Clone fresh copy
rm -rf "$REPO_DIR" 2>/dev/null
git clone --depth 1 "$REPO_URL" "$REPO_DIR" --quiet
VERSION=$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo "dev")
# Build
cd "$REPO_DIR/src"
go build -ldflags="-s -w \
-X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=${VERSION} \
-X git.lohmar.co.uk/lexton-it/NextWks/core/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-X git.lohmar.co.uk/lexton-it/NextWks/core/version.CommitSHA=$(git rev-parse --short HEAD)" \
-o /tmp/nextwks-core .
# Stop, wipe data, replace, restart
systemctl stop nextwks
warn "Wiping NextWks database (users restored from Authelia on boot)"
find "$DATA_DIR" -name "*.db*" -delete 2>/dev/null || true
cp /tmp/nextwks-core "$BIN_DIR/core"
chmod 755 "$BIN_DIR/core"
chown nextwks:nextwks "$BIN_DIR/core"
chown -R nextwks:nextwks "$DATA_DIR" 2>/dev/null || true
systemctl start nextwks
sleep 2
# Verify
if curl -s --max-time 3 http://localhost:8080/api/health | grep -q ok; then
ok "NextWks updated to v${VERSION}"
info "Users bootstrapped from Authelia"
else
echo "Health check failed — check: sudo journalctl -u nextwks -n 20"
fi
# Cleanup
rm -rf "$REPO_DIR" /tmp/nextwks-core