Compare commits
324 commits
v2026.6.00
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9364b4af81 | |||
| 6dedc6d432 | |||
| 4a9d5f7ede | |||
| 517c8849ec | |||
| e1a4566cc3 | |||
| f4fe31c561 | |||
| 5cff118d0c | |||
| 44326c542d | |||
| 48cda41d69 | |||
| 6cf084b931 | |||
| 359a0dccb6 | |||
| ae9b0b7a81 | |||
| aa1be0d4cd | |||
| b32180c268 | |||
| d82b1a9a6b | |||
| 03ae910b3f | |||
| 1849c65c68 | |||
| a21e63c4a2 | |||
| 0d7defff4e | |||
| 14e60151fd | |||
| 62b42635b2 | |||
| 9f6c85ce60 | |||
| 7303c94a3c | |||
| f0c6aa1a2e | |||
| 080341f8a8 | |||
| 034e6688de | |||
| 9ba62e4c8b | |||
| 0dbb576d1b | |||
| 336574571f | |||
| fc93201313 | |||
| fc1a98cbfe | |||
| 3df375e047 | |||
| b060e23a1d | |||
| e92382266f | |||
| 3ca19403fa | |||
| d41d1443a8 | |||
| 89ea56e255 | |||
| 1b24799026 | |||
| 2999f5da47 | |||
| d23b6c2909 | |||
| 6e95c088e4 | |||
| 6113f62ad2 | |||
| 81e5505f2a | |||
| 07288f4e19 | |||
| 7b797f0785 | |||
| 2a255b55ec | |||
| 20cc74ae50 | |||
| b7c65d37cd | |||
| a4b92feed9 | |||
| 54b2baf36c | |||
| 960ee5bc7c | |||
| cd4c48b2f5 | |||
| 10d8d8bfc6 | |||
| 87320e43e1 | |||
| 9083119aca | |||
| 634cab3464 | |||
| 30b0b88660 | |||
| 5e3c75d564 | |||
| b24bc4246b | |||
| 12b2a787ab | |||
| b555f153be | |||
| 414ead35d0 | |||
| 8770476d61 | |||
| 8d2ffb503a | |||
| 11bcd78a13 | |||
| 8561f59beb | |||
| 7e12e8d831 | |||
| 956b9f2968 | |||
| 079ca4bedb | |||
| 2ce600bb6d | |||
| 55e1cbece2 | |||
| 66eb350564 | |||
| e39d1b9ebd | |||
| 31bb7771cd | |||
| bb00b5cfb7 | |||
| 73bc5fa528 | |||
| b30ddacef0 | |||
| 5374473a59 | |||
| e34e71bf6a | |||
| 19817691b2 | |||
| d9f480be95 | |||
| 2e21aa8bea | |||
| 1e37c33603 | |||
| e7566e66c7 | |||
| ee29b28156 | |||
| a1d4368a0a | |||
| 2026510572 | |||
| a38e9fa7f3 | |||
| 5bff56dcda | |||
| 1ca2b028c8 | |||
| 6b00f081c0 | |||
| 7868322af3 | |||
| fc066da921 | |||
| af85ea31e7 | |||
| 60669b47a3 | |||
| bde90a8186 | |||
| 45f2509fe4 | |||
| 3f3e929374 | |||
| 207f7532be | |||
| f6a18dd8e2 | |||
| af8dc42dd6 | |||
| bad04bf390 | |||
| 9790d9338a | |||
| 857ce833cf | |||
| e1e8ad0f08 | |||
| 3e04d8f1c0 | |||
| 707eb9ec81 | |||
| ee9e533485 | |||
| d699dd34c4 | |||
| f5744a65d1 | |||
| fcacc28f55 | |||
| 3332bc3d59 | |||
| 22958d09e7 | |||
| f45132962c | |||
| 993514d92e | |||
| 594683dbf8 | |||
| 7927addcf7 | |||
| 80a5579537 | |||
| a56a485f42 | |||
| f3d0259bd4 | |||
| 9b71a13197 | |||
| f8468270af | |||
| 8f25bc5d63 | |||
| 11e893eb02 | |||
| ab8e46b3a4 | |||
| 07aff0ba28 | |||
| c252fb17ed | |||
| a287a67fd1 | |||
| e52b7e6915 | |||
| 94ae185d47 | |||
| 61b1587cd6 | |||
| 09a74425b3 | |||
| b948399d1d | |||
| d420f179a2 | |||
| aa29d363ce | |||
| 6ea3164d72 | |||
| abeae6cbbf | |||
| 5d30facbcc | |||
| dea385a71c | |||
| 7a44f112a5 | |||
| 48241364aa | |||
| f44041f0d5 | |||
| b6938d3800 | |||
| d6e0387b1c | |||
| b0325267db | |||
| b684242eb9 | |||
| ea82a51307 | |||
| efaa917013 | |||
| c426b711df | |||
| a3b31ed2ab | |||
| 1705d1e32d | |||
| 1298589afa | |||
| ec739975ec | |||
| 8b2301f3a4 | |||
| 93f853bd40 | |||
| 47d66f476d | |||
| 585d4e6ec8 | |||
| e03cb76017 | |||
| fa9537bcb5 | |||
| c42807d076 | |||
| 90c8c57fbe | |||
| b1004e4514 | |||
| fd1fa7dbeb | |||
| 710781bc47 | |||
| b2e3086e10 | |||
| 13c1bd2d13 | |||
| 5f46d3c2b8 | |||
| 6d87718b43 | |||
| c7d25c0107 | |||
| 881b94099c | |||
| 1eb470fa12 | |||
| d144fcdc28 | |||
| fbbf1732b3 | |||
| 5aca6025c5 | |||
| 5f168d21f1 | |||
| 02bab22a5d | |||
| 533b6f4137 | |||
| 434b23aee2 | |||
| 63099703a1 | |||
| 360112c7b7 | |||
| 2e7a0e3645 | |||
| b0a3cd3206 | |||
| 25c0e7b9b7 | |||
| 2398bdce83 | |||
| d73b67614e | |||
| 038d923a11 | |||
| 4e50bf15dd | |||
| ddef538795 | |||
| 7b3942658d | |||
| 79bf23618d | |||
| d0f9854084 | |||
| ccd362a964 | |||
| acd9671359 | |||
| eabbcdaa2d | |||
| 22471e479b | |||
| 8ee09fb229 | |||
| d2fa6c7365 | |||
| 47efc2e62d | |||
| 4416f15797 | |||
| f9b1b3aa7b | |||
| 8366164155 | |||
| 49ac348694 | |||
| c54c01d609 | |||
| 9bc8310db1 | |||
| 57defd7a63 | |||
| 91232a7beb | |||
| 7a4328040b | |||
| 2798485a2c | |||
| 7fdf6692df | |||
| 811c0c75f7 | |||
| 897275c659 | |||
| 672653f731 | |||
| 29b9f3c159 | |||
| cd15d294a1 | |||
| 2d3832df0b | |||
| 937250fa28 | |||
| 032c07beef | |||
| e575b4eeb0 | |||
| b069cab1e3 | |||
| 1a7c99988b | |||
| 1efeb05f4f | |||
| 3560e9b5b6 | |||
| b8db1f87f8 | |||
| 94258749dc | |||
| bcfe28b16b | |||
| 43b32cc881 | |||
| 7d432d0af5 | |||
| 151ddc6f63 | |||
| 5eb5399088 | |||
| 445c49e77c | |||
| 29782180d9 | |||
| c24bdc4305 | |||
| 7c13808527 | |||
| dd1e963357 | |||
| a8172324b7 | |||
| 95308c880b | |||
| 9f7e4c8a68 | |||
| b3d015a26a | |||
| 917dbb0844 | |||
| 312aa388d4 | |||
| 594cf3efe2 | |||
| d0dbe9b472 | |||
| 00a252b2e9 | |||
| 6bec565b7a | |||
| 49c4254594 | |||
| 2489ac589e | |||
| bc7d546e64 | |||
| b5ed7e2364 | |||
| 44fec75b42 | |||
| 8b2ab0c776 | |||
| 2200ff657a | |||
| 3b3cc7baa4 | |||
| 8bbdaf54ac | |||
| ecb3cbe442 | |||
| 8eca6d0ed4 | |||
| 19f1cf2c2b | |||
| f193765dad | |||
| 0e707cdae6 | |||
| 39b37c704e | |||
| ac72c77a85 | |||
| 46474981f0 | |||
| 9df43d8423 | |||
| b6e4345316 | |||
| 56acb3b763 | |||
| 10d6fd8a5b | |||
| 9034cdd107 | |||
| be566b2a88 | |||
| 8c15dbed35 | |||
| ef9510704f | |||
| 6944223d35 | |||
| 5663364b12 | |||
| 9edd6f1038 | |||
| 64ddd71358 | |||
| 9813124ba7 | |||
| da575701e4 | |||
| 863b87ffb7 | |||
| a12d860523 | |||
| 73d3536ad6 | |||
| b7e5467369 | |||
| 16b933bb07 | |||
| 936c3e6146 | |||
| 3e32968df8 | |||
| 889329c52e | |||
| 9527eb0ee4 | |||
| ed1b196a75 | |||
| 9e056ddd4a | |||
| 28881750db | |||
| c42525bc05 | |||
| a4163b6595 | |||
| 2579715cbc | |||
| cb4eb38289 | |||
| 583e1a5eea | |||
| 461a9a7249 | |||
| 19ae62da45 | |||
| e8906df62e | |||
| ed9710d704 | |||
| c68698bd2e | |||
| 5953736ddb | |||
| 101009c872 | |||
| 4ac067e91d | |||
| 1422207482 | |||
| 4f4a00757e | |||
| aefffb70db | |||
| b2e1732161 | |||
| 3589cfd280 | |||
| 7b3ba23c82 | |||
| e978e08424 | |||
| 9a4055df1b | |||
| 16f4870efe | |||
| ce418b6f0c | |||
| 73eaa27c6b | |||
| 3b4fd6a84c | |||
| d29ecf41ef | |||
| 17170c762a | |||
| 7cdff22565 | |||
| 0211244076 | |||
| 631780cdf6 | |||
| 50648f9c43 | |||
| 76c48ec399 | |||
| 1255fc39ea | |||
| 9c03571555 | |||
| afb3984573 | |||
| 827c8ef116 | |||
| f6fba3ab62 |
67 changed files with 3274 additions and 5632 deletions
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -1,9 +1,12 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
|
/nextworkspace
|
||||||
app/core
|
app/core
|
||||||
app/core.exe
|
app/core.exe
|
||||||
app/data/*.db
|
app/data/*.db
|
||||||
app/data/*.db-wal
|
app/data/*.db-wal
|
||||||
app/data/*.db-shm
|
app/data/*.db-shm
|
||||||
|
src/setupcheck
|
||||||
|
src/setupcheck.exe
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -15,10 +18,15 @@ Thumbs.db
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# AI / Agent config (stored at project root ~/development/)
|
||||||
|
AGENT.md
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# Generated binaries (development build)
|
# Temp
|
||||||
src/core/setupcheck
|
tmp/
|
||||||
src/core/setupcheck.exe
|
*.tmp
|
||||||
|
tools/nextwks-tool/nextwks-tool
|
||||||
|
tools/register-certs/register-certs
|
||||||
|
|
|
||||||
184
CHANGELOG.md
Normal file
184
CHANGELOG.md
Normal 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/`
|
||||||
222
README.md
222
README.md
|
|
@ -1,183 +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.
|
A self-hosted productivity suite for startups. One binary + Caddy + Authelia.
|
||||||
|
|
||||||
## 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.)
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────┐
|
Internet :443 ──iptables──> :8443 ──> Caddy container :443
|
||||||
│ Zoraxy Proxy │
|
Internet :80 ──iptables──> :8080 ──> Caddy container :80
|
||||||
│ (TLS + routing) │
|
|
||||||
└────┬─────────┬───┘
|
Caddy (rootless podman, nextwks-net)
|
||||||
│ │
|
├── auth.{DOMAIN} ──> Authelia :9091 (internal)
|
||||||
┌────────────▼──┐ ┌──▼──────────────┐
|
├── app.{DOMAIN} ──> Launcher :9000 (forward auth via Authelia)
|
||||||
│ Authelia │ │ NextWks Core │
|
└── www.{DOMAIN} ──> static files
|
||||||
│ :9091 (OIDC) │ │ :8080 (App) │
|
|
||||||
│ │ │ │
|
Authelia :9091 ──> api :8080 (internal)
|
||||||
│ users_db.yml │◄─┤ core/admin/ │
|
Launcher :9000 ──> /config, /people, /settings, /health
|
||||||
│ config.yml │ │ core/ui/ │
|
|
||||||
└────────────────┘ │ core/auth/ │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
```
|
## Quick Start (Bare VM)
|
||||||
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
|
|
||||||
|
|
||||||
- Go 1.22+
|
|
||||||
- Authelia v4.38 (install with `bash scripts/install-authelia.sh`)
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
# Download the script to your home folder
|
||||||
cd src && go build -o ../app/core .
|
curl -o ~/nextwks.sh https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh
|
||||||
|
chmod +x ~/nextwks.sh
|
||||||
|
|
||||||
# Run (from app/ directory)
|
# Run the installer (no sudo — it'll ask only where needed)
|
||||||
cd ../app && ./core
|
./nextwks.sh --install
|
||||||
|
|
||||||
# Run with custom config
|
|
||||||
./core -config /path/to/config.yaml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The server starts on `http://localhost:8080` with:
|
Prompts for domain, TLS email, and admin credentials. Installs deps (Go, Podman, git),
|
||||||
- **Workspace launcher**: `http://localhost:8080/` (OIDC-protected)
|
clones repo to `/tmp/nextwks-build/`, builds binary, generates configs, deploys stack.
|
||||||
- **Admin panel**: `http://localhost:8080/admin` (bearer token)
|
The script stays in `~/nextwks.sh` for future updates.
|
||||||
- **Health API**: `http://localhost:8080/api/health`
|
|
||||||
- **Auth status**: `http://localhost:8080/auth/status`
|
|
||||||
|
|
||||||
### Production Install
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/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
|
```bash
|
||||||
# Full install (build → test → deploy → systemd → smoke test)
|
# Smart update (pull, build, copy, restart)
|
||||||
./install.sh
|
./nextwks.sh --update
|
||||||
|
|
||||||
# Check status
|
# Full redeploy (tear down, rebuild from scratch with saved secrets)
|
||||||
./install.sh --status
|
./nextwks.sh --destroy
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
./install.sh --uninstall
|
|
||||||
```
|
```
|
||||||
|
|
||||||
More options:
|
## Workflow (Development)
|
||||||
```bash
|
|
||||||
./install.sh --build-only # Compile only
|
|
||||||
./install.sh --skip-build # Install existing binary
|
|
||||||
./install.sh --config-only # Generate config only
|
|
||||||
./install.sh --help # Show all options
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
The script clones fresh from git every time — no stale repos, no permissions issues.
|
||||||
cd src && go test ./... -v
|
|
||||||
```
|
|
||||||
|
|
||||||
46 tests covering config parsing, SQLite operations, user CRUD, auth middleware, and YAML synchronization.
|
## Version
|
||||||
|
|
||||||
## Configuration
|
Current: 0.1.0.0032 — see [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
|
||||||
|
|
@ -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** | | |
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
2026.6.0001
|
0.1.0.0048
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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
75
compose/stack.yaml
Normal 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
|
||||||
83
config/authelia/configuration.yml
Normal file
83
config/authelia/configuration.yml
Normal 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
|
||||||
9
config/authelia/users_database.yml
Normal file
9
config/authelia/users_database.yml
Normal 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
25
config/caddy/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
59
config/nextworkspace/apps.yaml
Normal file
59
config/nextworkspace/apps.yaml
Normal 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"]
|
||||||
7
config/nextworkspace/config.yaml
Normal file
7
config/nextworkspace/config.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
server:
|
||||||
|
port: 9000
|
||||||
|
host: "0.0.0.0"
|
||||||
|
|
||||||
|
app:
|
||||||
|
name: "NextWorkspace"
|
||||||
|
description: "Your Self-Hosted Workspace"
|
||||||
14
config/nextworkspace/settings.yaml
Normal file
14
config/nextworkspace/settings.yaml
Normal 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
44
config/www/index.html
Normal 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
14
go.mod
Normal 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
18
go.sum
Normal 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=
|
||||||
443
install.sh
443
install.sh
|
|
@ -1,443 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Next Workspace (NextWks) - Bare-Metal Installer
|
|
||||||
# Deploys compiled binaries to /opt/nextwks/ for production use
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./install.sh Build and install
|
|
||||||
# ./install.sh --skip-build Install existing binaries only
|
|
||||||
# ./install.sh --build-only Compile binary only (no install)
|
|
||||||
# ./install.sh --config-only Generate config only
|
|
||||||
# ./install.sh --status Check installation health
|
|
||||||
# ./install.sh --uninstall Remove installation
|
|
||||||
# ./install.sh --help Show this help
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
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}Warning:${NC} $1"; }
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
INSTALL_DIR="/opt/nextwks"
|
|
||||||
BIN_DIR="${INSTALL_DIR}/bin"
|
|
||||||
DATA_DIR="${INSTALL_DIR}/data"
|
|
||||||
MODULES_DIR="${INSTALL_DIR}/modules"
|
|
||||||
STATIC_DIR="${INSTALL_DIR}/static"
|
|
||||||
CONFIG_FILE="${INSTALL_DIR}/config.yaml"
|
|
||||||
SERVICE_FILE="/etc/systemd/system/nextwks.service"
|
|
||||||
HEALTH_URL="http://localhost:8080/api/health"
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
SKIP_BUILD=false
|
|
||||||
BUILD_ONLY=false
|
|
||||||
CONFIG_ONLY=false
|
|
||||||
UNINSTALL=false
|
|
||||||
CHECK_STATUS=false
|
|
||||||
ADMIN_USER=""
|
|
||||||
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--skip-build) SKIP_BUILD=true ;;
|
|
||||||
--build-only) BUILD_ONLY=true ;;
|
|
||||||
--config-only) CONFIG_ONLY=true ;;
|
|
||||||
--uninstall) UNINSTALL=true ;;
|
|
||||||
--status) CHECK_STATUS=true ;;
|
|
||||||
--admin=*) ADMIN_USER="${arg#*=}" ;;
|
|
||||||
--admin)
|
|
||||||
error "Use --admin=username,email (e.g., --admin=cclohmar,claus@lohmar.co.uk)"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
--help)
|
|
||||||
cat << 'HELPEOF'
|
|
||||||
NextWks Installer — Bare-metal deployment tool
|
|
||||||
|
|
||||||
./install.sh Build and install
|
|
||||||
./install.sh --skip-build Install existing binary only
|
|
||||||
./install.sh --build-only Compile only (no install)
|
|
||||||
./install.sh --config-only Generate config only
|
|
||||||
./install.sh --admin=user,email Create initial admin user
|
|
||||||
./install.sh --status Check installation health
|
|
||||||
./install.sh --uninstall Remove installation
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
./install.sh Full build + install
|
|
||||||
./install.sh --admin=cclohmar,cl@sechpoint.app Create admin during install
|
|
||||||
./install.sh --status Check what's running
|
|
||||||
HELPEOF
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*) error "Unknown argument: $arg (use --help for options)"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Status Check ---
|
|
||||||
if [ "$CHECK_STATUS" = true ]; then
|
|
||||||
echo ""
|
|
||||||
info "Next Workspace Installation Status"
|
|
||||||
echo "-----------------------------------"
|
|
||||||
|
|
||||||
if [ -f "${INSTALL_DIR}/bin/core" ]; then
|
|
||||||
VERSION=$("${INSTALL_DIR}/bin/core" 2>&1 | head -1 || echo "unknown")
|
|
||||||
success "✓ Binary installed: ${INSTALL_DIR}/bin/core"
|
|
||||||
else
|
|
||||||
warn "✗ Binary not found: ${INSTALL_DIR}/bin/core (not installed)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
|
||||||
success "✓ Configuration: $CONFIG_FILE"
|
|
||||||
else
|
|
||||||
warn "✗ Configuration: not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if systemctl is-active --quiet nextwks 2>/dev/null; then
|
|
||||||
success "✓ Service running: nextwks"
|
|
||||||
elif systemctl is-enabled --quiet nextwks 2>/dev/null; then
|
|
||||||
warn "⚠ Service nextwks: enabled but not running"
|
|
||||||
else
|
|
||||||
info " Service nextwks: not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "/opt/authelia/authelia" ]; then
|
|
||||||
success "✓ Authelia found: /opt/authelia/"
|
|
||||||
if systemctl is-active --quiet authelia 2>/dev/null; then
|
|
||||||
success " Authelia service: running (port 9091)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "✗ Authelia: not installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try health check if server appears to be running
|
|
||||||
if command -v curl &>/dev/null; then
|
|
||||||
HEALTH=$(curl -s --max-time 2 "$HEALTH_URL" 2>/dev/null || echo "")
|
|
||||||
if [ "$HEALTH" = '{"status":"ok"}' ]; then
|
|
||||||
success "✓ Health endpoint: OK (http://localhost:8080)"
|
|
||||||
elif [ -n "$HEALTH" ]; then
|
|
||||||
warn "⚠ Health endpoint: unexpected response: $HEALTH"
|
|
||||||
else
|
|
||||||
info " Health endpoint: not responding"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Uninstall ---
|
|
||||||
if [ "$UNINSTALL" = true ]; then
|
|
||||||
info "Uninstalling Next Workspace..."
|
|
||||||
|
|
||||||
if [ -f "$SERVICE_FILE" ]; then
|
|
||||||
systemctl stop nextwks 2>/dev/null || true
|
|
||||||
systemctl disable nextwks 2>/dev/null || true
|
|
||||||
rm -f "$SERVICE_FILE"
|
|
||||||
systemctl daemon-reload
|
|
||||||
info "Removed systemd service"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$INSTALL_DIR" ]; then
|
|
||||||
rm -rf "$INSTALL_DIR"
|
|
||||||
success "Removed $INSTALL_DIR"
|
|
||||||
else
|
|
||||||
info "No installation found at $INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
success "Uninstall complete"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Prerequisites ---
|
|
||||||
info "Checking prerequisites..."
|
|
||||||
|
|
||||||
if [ "$SKIP_BUILD" = false ] && [ "$CONFIG_ONLY" = false ] && [ "$BUILD_ONLY" = false ]; then
|
|
||||||
if ! command -v go &>/dev/null; then
|
|
||||||
error "Go is not installed. Install Go 1.22+ first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Authelia
|
|
||||||
if [ ! -f "/opt/authelia/authelia" ]; then
|
|
||||||
warn "Authelia is not installed at /opt/authelia/"
|
|
||||||
warn "Admin user management requires Authelia to function."
|
|
||||||
warn "Install with: bash $REPO_DIR/scripts/install-authelia.sh"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Static Assets ---
|
|
||||||
if [ "$CONFIG_ONLY" = false ] && [ "$BUILD_ONLY" = false ]; then
|
|
||||||
info "Installing static assets..."
|
|
||||||
if [ -d "$REPO_DIR/app/static" ]; then
|
|
||||||
mkdir -p "$STATIC_DIR"
|
|
||||||
cp -r "$REPO_DIR/app/static"/* "$STATIC_DIR/"
|
|
||||||
success "Static assets installed to $STATIC_DIR"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Build ---
|
|
||||||
if [ "$SKIP_BUILD" = false ] && [ "$CONFIG_ONLY" = false ]; then
|
|
||||||
info "Building Next Workspace core..."
|
|
||||||
|
|
||||||
# Build with version info from ldflags
|
|
||||||
VERSION=$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo "dev")
|
|
||||||
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
cd "$REPO_DIR/src"
|
|
||||||
|
|
||||||
# Run tests first
|
|
||||||
info "Running tests..."
|
|
||||||
if ! go test -count=1 ./... 2>&1 | tail -1; then
|
|
||||||
warn "Some tests failed — continuing build anyway"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build with stripped symbols and version info
|
|
||||||
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 "Core binary built: app/core (${VERSION})"
|
|
||||||
|
|
||||||
if [ "$BUILD_ONLY" = true ]; then
|
|
||||||
success "Build complete (--build-only, skipping install)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Install ---
|
|
||||||
if [ "$CONFIG_ONLY" = false ]; then
|
|
||||||
info "Installing to $INSTALL_DIR..."
|
|
||||||
|
|
||||||
# Create directory structure
|
|
||||||
mkdir -p "$BIN_DIR" "$DATA_DIR" "$MODULES_DIR" "$STATIC_DIR"
|
|
||||||
|
|
||||||
# Copy binary
|
|
||||||
if [ -f "$REPO_DIR/app/core" ]; then
|
|
||||||
cp "$REPO_DIR/app/core" "$BIN_DIR/core"
|
|
||||||
chmod 755 "$BIN_DIR/core"
|
|
||||||
success "Installed binary to $BIN_DIR/core"
|
|
||||||
else
|
|
||||||
error "Binary not found at app/core. Run without --skip-build or build manually."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copy static assets if not already done
|
|
||||||
if [ -d "$REPO_DIR/app/static" ] && [ ! -f "$STATIC_DIR/manifest.json" ]; then
|
|
||||||
cp -r "$REPO_DIR/app/static"/* "$STATIC_DIR/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copy modules if any exist
|
|
||||||
if [ -d "$REPO_DIR/app/modules" ] && [ "$(ls -A "$REPO_DIR/app/modules" 2>/dev/null)" ]; then
|
|
||||||
cp -r "$REPO_DIR/app/modules"/* "$MODULES_DIR/"
|
|
||||||
success "Installed modules"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Configuration ---
|
|
||||||
info "Generating configuration..."
|
|
||||||
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
ADMIN_SECRET=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p -c 32)
|
|
||||||
SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p -c 32)
|
|
||||||
|
|
||||||
cat > "$CONFIG_FILE" << CONFIGEOF
|
|
||||||
# Next Workspace (NextWks) - Production Configuration
|
|
||||||
# Auto-generated by install.sh on $(date)
|
|
||||||
|
|
||||||
server:
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8080
|
|
||||||
|
|
||||||
admin:
|
|
||||||
secret_token: "${ADMIN_SECRET}"
|
|
||||||
|
|
||||||
database:
|
|
||||||
type: "sqlite"
|
|
||||||
path: "${DATA_DIR}/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: "${SESSION_SECRET}"
|
|
||||||
expiry_minutes: 60
|
|
||||||
CONFIGEOF
|
|
||||||
|
|
||||||
chmod 600 "$CONFIG_FILE"
|
|
||||||
success "Configuration generated: $CONFIG_FILE"
|
|
||||||
echo ""
|
|
||||||
warn " Admin Secret Token: ${ADMIN_SECRET}"
|
|
||||||
warn " Store this securely! Required for admin API access."
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
info "Configuration already exists at $CONFIG_FILE (not overwritten)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Systemd Service ---
|
|
||||||
if [ "$CONFIG_ONLY" = false ]; then
|
|
||||||
info "Setting up systemd service..."
|
|
||||||
|
|
||||||
cat > "$SERVICE_FILE" << SERVICEEOF
|
|
||||||
[Unit]
|
|
||||||
Description=Next Workspace (NextWks) Core
|
|
||||||
After=network.target authelia.service
|
|
||||||
Wants=authelia.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=${INSTALL_DIR}
|
|
||||||
ExecStart=${BIN_DIR}/core -config ${CONFIG_FILE}
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=yes
|
|
||||||
ReadWritePaths=${DATA_DIR} ${MODULES_DIR} /opt/authelia/users_database.yml
|
|
||||||
ReadOnlyPaths=${INSTALL_DIR}/config.yaml ${INSTALL_DIR}/static
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
SERVICEEOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
success "Systemd service created: $SERVICE_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Smoke Test ---
|
|
||||||
if [ "$CONFIG_ONLY" = false ]; then
|
|
||||||
info "Starting smoke test..."
|
|
||||||
|
|
||||||
# Start the server temporarily to verify it works
|
|
||||||
if [ -f "$BIN_DIR/core" ] && [ -f "$CONFIG_FILE" ]; then
|
|
||||||
"$BIN_DIR/core" -config "$CONFIG_FILE" &
|
|
||||||
SMOKE_PID=$!
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if command -v curl &>/dev/null; then
|
|
||||||
RESPONSE=$(curl -s --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "")
|
|
||||||
if [ "$RESPONSE" = '{"status":"ok"}' ]; then
|
|
||||||
success "Smoke test passed — server responds OK"
|
|
||||||
else
|
|
||||||
warn "Smoke test: server started but health check returned '$RESPONSE'"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Admin User Creation ---
|
|
||||||
if [ "$CONFIG_ONLY" = false ]; then
|
|
||||||
# Read admin token from config
|
|
||||||
ADMIN_TOKEN=$(grep secret_token "$CONFIG_FILE" | head -1 | sed 's/.*: *"*//;s/"*$//' | xargs)
|
|
||||||
ADMIN_API="http://localhost:8080/admin/api/users"
|
|
||||||
|
|
||||||
# Check if interactive or --admin flag was provided
|
|
||||||
if [ -t 0 ] && [ -z "$ADMIN_USER" ] && [ ! -f "$CONFIG_FILE.initialized" ]; then
|
|
||||||
echo ""
|
|
||||||
info "No --admin flag provided. Create an initial admin user?"
|
|
||||||
read -p "Enter username:email (or press Enter to skip): " ADMIN_INPUT
|
|
||||||
if [ -n "$ADMIN_INPUT" ]; then
|
|
||||||
ADMIN_USER="$ADMIN_INPUT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$ADMIN_USER" ]; then
|
|
||||||
# Parse username,email
|
|
||||||
ADMIN_UNAME="${ADMIN_USER%%,*}"
|
|
||||||
ADMIN_EMAIL="${ADMIN_USER#*,}"
|
|
||||||
if [ "$ADMIN_UNAME" = "$ADMIN_EMAIL" ]; then
|
|
||||||
ADMIN_EMAIL=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Creating admin user: $ADMIN_UNAME..."
|
|
||||||
RESULT=$(curl -s -X POST "$ADMIN_API" \
|
|
||||||
-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\"}]}")
|
|
||||||
|
|
||||||
PASSWORD=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['results'][0].get('generated_password',''))" 2>/dev/null || echo "")
|
|
||||||
ERROR=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['results'][0].get('error',''))" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -n "$PASSWORD" ]; then
|
|
||||||
success "Admin user created!"
|
|
||||||
echo ""
|
|
||||||
warn " ┌─────────────────────────────────────────┐"
|
|
||||||
warn " │ Username: $ADMIN_UNAME"
|
|
||||||
warn " │ Email: ${ADMIN_EMAIL:-<not set>}"
|
|
||||||
warn " │ Password: $PASSWORD"
|
|
||||||
warn " │ Groups: admins"
|
|
||||||
warn " └─────────────────────────────────────────┘"
|
|
||||||
echo ""
|
|
||||||
warn " Save this password! It cannot be recovered."
|
|
||||||
warn " User will be synced to Authelia automatically."
|
|
||||||
echo ""
|
|
||||||
elif [ -n "$ERROR" ]; then
|
|
||||||
warn "Admin creation failed: $ERROR"
|
|
||||||
else
|
|
||||||
warn "Could not parse response from admin API"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Mark as initialized to skip interactive prompt next time
|
|
||||||
touch "$CONFIG_FILE.initialized" 2>/dev/null || true
|
|
||||||
|
|
||||||
kill $SMOKE_PID 2>/dev/null || true
|
|
||||||
wait $SMOKE_PID 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Summary ---
|
|
||||||
echo ""
|
|
||||||
success "═══════════════════════════════════════════"
|
|
||||||
success " Next Workspace (NextWks) installed!"
|
|
||||||
success "═══════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
info " Binary: ${BIN_DIR}/core"
|
|
||||||
info " Config: ${CONFIG_FILE}"
|
|
||||||
info " Data: ${DATA_DIR}/"
|
|
||||||
info " Static assets: ${STATIC_DIR}/"
|
|
||||||
info " Service: systemctl start nextwks"
|
|
||||||
echo ""
|
|
||||||
info " Workspace: https://wks.lohmar.co.uk/"
|
|
||||||
info " Admin UI: http://localhost:8080/admin"
|
|
||||||
info " Health API: http://localhost:8080/api/health"
|
|
||||||
info " Auth status: http://localhost:8080/auth/status"
|
|
||||||
echo ""
|
|
||||||
info " Start: systemctl enable --now nextwks"
|
|
||||||
info " Logs: journalctl -u nextwks -f"
|
|
||||||
info " Status: ./install.sh --status"
|
|
||||||
echo ""
|
|
||||||
warn " Next step: Register NextWks as OIDC client in Authelia:"
|
|
||||||
warn " • Edit /opt/authelia/configuration.yml"
|
|
||||||
warn " • Add client_id: nextwks with redirect_uri: https://wks.lohmar.co.uk/auth/callback"
|
|
||||||
warn " • Restart: systemctl restart authelia"
|
|
||||||
echo ""
|
|
||||||
32
lng/de/admin.yaml
Normal file
32
lng/de/admin.yaml
Normal 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
8
lng/de/launcher.yaml
Normal 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
32
lng/en/admin.yaml
Normal 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
8
lng/en/launcher.yaml
Normal 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"
|
||||||
|
|
@ -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 "-------------------------------------------------------"
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,578 +0,0 @@
|
||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"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) {
|
|
||||||
hash := hashPassword("test-password")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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") != ""
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)))
|
|
||||||
}
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 with argon2id
|
|
||||||
hash := hashPassword(password)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashPassword hashes a password using argon2id (matching Authelia's format).
|
|
||||||
func hashPassword(password string) string {
|
|
||||||
salt := make([]byte, 16)
|
|
||||||
rand.Read(salt)
|
|
||||||
|
|
||||||
hash := argon2.IDKey([]byte(password), salt, 3, 65536, 4, 32)
|
|
||||||
|
|
||||||
// Format: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
|
|
||||||
saltB64 := encodeBase64Raw(salt)
|
|
||||||
hashB64 := encodeBase64Raw(hash)
|
|
||||||
|
|
||||||
return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=4$%s$%s", saltB64, hashB64)
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeBase64Raw encodes to raw URL-safe base64 (no padding).
|
|
||||||
func encodeBase64Raw(data []byte) string {
|
|
||||||
return hex.EncodeToString(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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]
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +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 is the key for the authenticated user's ID.
|
|
||||||
ContextUserID contextKey = "user_id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SessionStore manages user sessions backed by SQLite.
|
|
||||||
type SessionStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionStore creates a session store.
|
|
||||||
func NewSessionStore(db *sql.DB) *SessionStore {
|
|
||||||
return &SessionStore{db: 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
|
|
||||||
ctx := context.WithValue(r.Context(), ContextUserID, session.UserID)
|
|
||||||
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[:])
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
26
src/core/config/testdata/valid-config.yaml
vendored
26
src/core/config/testdata/valid-config.yaml
vendored
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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, ¬null, &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, ¬null, &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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// AppTile represents an application card on the workspace launcher.
|
|
||||||
type AppTile struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
URL string
|
|
||||||
Icon string // Emoji or SVG
|
|
||||||
Color string // Background color for icon
|
|
||||||
Status string // "ready", "coming-soon", "beta"
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultApps returns the default set of workspace apps.
|
|
||||||
// These are placeholder tiles until supervisor modules are built.
|
|
||||||
func DefaultApps() []AppTile {
|
|
||||||
return []AppTile{
|
|
||||||
{
|
|
||||||
Name: "Admin Panel",
|
|
||||||
Description: "Manage users, groups, and workspace settings",
|
|
||||||
URL: "/admin",
|
|
||||||
Icon: "⚙️",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "ready",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Files",
|
|
||||||
Description: "Coming soon — File storage and sharing",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "📁",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Calendar",
|
|
||||||
Description: "Coming soon — Schedule and events",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "📅",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Mail",
|
|
||||||
Description: "Coming soon — Email integration",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "✉️",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Office",
|
|
||||||
Description: "Coming soon — Documents and spreadsheets",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "📝",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Settings",
|
|
||||||
Description: "Coming soon — Workspace preferences",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "🔧",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ AppGrid(apps []AppTile) {
|
|
||||||
<section>
|
|
||||||
<h2 style="font-size:1.125rem;font-weight:600;margin-bottom:1rem;">
|
|
||||||
Applications
|
|
||||||
</h2>
|
|
||||||
<div class="app-grid">
|
|
||||||
for _, app := range apps {
|
|
||||||
@appCard(app)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ appCard(app AppTile) {
|
|
||||||
if app.Status == "coming-soon" {
|
|
||||||
<a href="#" class="app-card" style="opacity:0.6;cursor:default;" aria-disabled="true">
|
|
||||||
<div class="app-icon" style={ fmt.Sprintf("background:%s", app.Color) }>
|
|
||||||
{ app.Icon }
|
|
||||||
</div>
|
|
||||||
<div class="app-name">{ app.Name }</div>
|
|
||||||
<div class="app-desc">{ app.Description }</div>
|
|
||||||
<div class="app-badge">
|
|
||||||
<span style="color:#f59e0b;">● Coming Soon</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
} else {
|
|
||||||
<a href={ templ.URL(app.URL) } class="app-card">
|
|
||||||
<div class="app-icon" style={ fmt.Sprintf("background:%s", app.Color) }>
|
|
||||||
{ app.Icon }
|
|
||||||
</div>
|
|
||||||
<div class="app-name">{ app.Name }</div>
|
|
||||||
<div class="app-desc">{ app.Description }</div>
|
|
||||||
<div class="app-badge">
|
|
||||||
<span style="color:#22c55e;">● Available</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,270 +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 "fmt"
|
|
||||||
|
|
||||||
// AppTile represents an application card on the workspace launcher.
|
|
||||||
type AppTile struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
URL string
|
|
||||||
Icon string // Emoji or SVG
|
|
||||||
Color string // Background color for icon
|
|
||||||
Status string // "ready", "coming-soon", "beta"
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultApps returns the default set of workspace apps.
|
|
||||||
// These are placeholder tiles until supervisor modules are built.
|
|
||||||
func DefaultApps() []AppTile {
|
|
||||||
return []AppTile{
|
|
||||||
{
|
|
||||||
Name: "Admin Panel",
|
|
||||||
Description: "Manage users, groups, and workspace settings",
|
|
||||||
URL: "/admin",
|
|
||||||
Icon: "⚙️",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "ready",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Files",
|
|
||||||
Description: "Coming soon — File storage and sharing",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "📁",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Calendar",
|
|
||||||
Description: "Coming soon — Schedule and events",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "📅",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Mail",
|
|
||||||
Description: "Coming soon — Email integration",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "✉️",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Office",
|
|
||||||
Description: "Coming soon — Documents and spreadsheets",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "📝",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Settings",
|
|
||||||
Description: "Coming soon — Workspace preferences",
|
|
||||||
URL: "#",
|
|
||||||
Icon: "🔧",
|
|
||||||
Color: "#1e293b",
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "<section><h2 style=\"font-size:1.125rem;font-weight:600;margin-bottom:1rem;\">Applications</h2><div class=\"app-grid\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, app := range apps {
|
|
||||||
templ_7745c5c3_Err = appCard(app).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func appCard(app 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 app.Status == "coming-soon" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"#\" class=\"app-card\" style=\"opacity:0.6;cursor:default;\" aria-disabled=\"true\"><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(fmt.Sprintf("background:%s", app.Color))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 86, Col: 72}
|
|
||||||
}
|
|
||||||
_, 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
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(app.Icon)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 87, Col: 14}
|
|
||||||
}
|
|
||||||
_, 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, 5, "</div><div class=\"app-name\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 89, Col: 35}
|
|
||||||
}
|
|
||||||
_, 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, 6, "</div><div class=\"app-desc\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 90, Col: 42}
|
|
||||||
}
|
|
||||||
_, 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, 7, "</div><div class=\"app-badge\"><span style=\"color:#f59e0b;\">● Coming Soon</span></div></a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(app.URL))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 96, Col: 30}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"app-card\"><div class=\"app-icon\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background:%s", app.Color))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 97, Col: 72}
|
|
||||||
}
|
|
||||||
_, 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, 10, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(app.Icon)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 98, Col: 14}
|
|
||||||
}
|
|
||||||
_, 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, 11, "</div><div class=\"app-name\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 100, Col: 35}
|
|
||||||
}
|
|
||||||
_, 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, 12, "</div><div class=\"app-desc\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var11 string
|
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 101, Col: 42}
|
|
||||||
}
|
|
||||||
_, 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, 13, "</div><div class=\"app-badge\"><span style=\"color:#22c55e;\">● Available</span></div></a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
// Static assets (manifest.json, sw.js, icons)
|
|
||||||
staticDir := filepath.Join(h.appDir, "static")
|
|
||||||
staticHandler := http.FileServer(http.Dir(staticDir))
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static", staticHandler))
|
|
||||||
|
|
||||||
// Launcher page — protected by OIDC auth gate
|
|
||||||
mux.Handle("GET /", authGate(http.HandlerFunc(h.launcherPage)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// launcherPage renders the main workspace landing page.
|
|
||||||
func (h *Handler) launcherPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Only handle root path, not all paths
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info from session (set by auth middleware)
|
|
||||||
// For now, render without user name (OIDC provides this later)
|
|
||||||
userName := ""
|
|
||||||
|
|
||||||
apps := DefaultApps()
|
|
||||||
component := LauncherPage(userName, apps)
|
|
||||||
component.Render(r.Context(), w)
|
|
||||||
}
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
templ LauncherPage(userName string, apps []AppTile) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>Next Workspace</title>
|
|
||||||
<link rel="manifest" href="/static/manifest.json"/>
|
|
||||||
<meta name="theme-color" content="#3b82f6"/>
|
|
||||||
<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>{ workspaceStyles() }</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="workspace">
|
|
||||||
@headerBar(userName)
|
|
||||||
<main class="main">
|
|
||||||
<div class="greeting">
|
|
||||||
@greetingHeading(userName)
|
|
||||||
<p>Your workspace is ready</p>
|
|
||||||
</div>
|
|
||||||
@AppGrid(apps)
|
|
||||||
@PWAInstallPrompt()
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="pwa-modal" class="modal-overlay" style="display:none;"
|
|
||||||
hx-target="this" hx-swap="innerHTML">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// PWA install prompt handler
|
|
||||||
let deferredPrompt = null;
|
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
deferredPrompt = e;
|
|
||||||
document.getElementById('pwa-install-btn').style.display = 'inline-flex';
|
|
||||||
});
|
|
||||||
|
|
||||||
function installPWA() {
|
|
||||||
if (deferredPrompt) {
|
|
||||||
deferredPrompt.prompt();
|
|
||||||
deferredPrompt.userChoice.then(() => { deferredPrompt = null; });
|
|
||||||
} else {
|
|
||||||
htmx.ajax('GET', '/pwa-guide', { target: '#pwa-modal', swap: 'innerHTML' });
|
|
||||||
document.getElementById('pwa-modal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePWAModal() {
|
|
||||||
document.getElementById('pwa-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on overlay click
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.classList.contains('modal-overlay')) {
|
|
||||||
closePWAModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ greetingHeading(userName string) {
|
|
||||||
<h1>
|
|
||||||
if userName != "" {
|
|
||||||
{ fmt.Sprintf("Welcome, %s", userName) }
|
|
||||||
} else {
|
|
||||||
{ "Welcome" }
|
|
||||||
}
|
|
||||||
</h1>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ headerBar(userName string) {
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<span class="logo">NextWks</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<button id="pwa-install-btn" class="btn-icon" onclick="installPWA()" title="Install to Desktop" style="display:none;">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
if userName != "" {
|
|
||||||
<a href="/auth/logout" class="btn-icon" title="Sign out">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
|
||||||
<polyline points="16 17 21 12 16 7"/>
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ workspaceStyles() {
|
|
||||||
<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;
|
|
||||||
--radius: 12px;
|
|
||||||
}
|
|
||||||
html { font-size: 14px; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.workspace { display: flex; flex-direction: column; min-height: 100vh; }
|
|
||||||
.header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.logo { font-size: 1.25rem; font-weight: 700; color: var(--primary); }
|
|
||||||
.header-right { display: flex; gap: 0.5rem; align-items: center; }
|
|
||||||
.btn-icon {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
border: none; border-radius: 8px;
|
|
||||||
background: transparent; color: var(--text-muted);
|
|
||||||
cursor: pointer; transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 1200px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
.greeting { margin-bottom: 2rem; }
|
|
||||||
.greeting h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }
|
|
||||||
.greeting p { color: var(--text-muted); font-size: 1rem; }
|
|
||||||
.app-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.app-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.5rem;
|
|
||||||
transition: all 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.app-card:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
.app-card .app-icon {
|
|
||||||
width: 48px; height: 48px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
.app-card .app-name { font-size: 1rem; font-weight: 600; }
|
|
||||||
.app-card .app-desc { font-size: 0.875rem; color: var(--text-muted); line-height: 1.4; }
|
|
||||||
.app-card .app-badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.pwa-prompt {
|
|
||||||
background: linear-gradient(135deg, var(--surface), var(--surface-2));
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.pwa-prompt h3 { margin-bottom: 0.5rem; }
|
|
||||||
.pwa-prompt p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }
|
|
||||||
.btn {
|
|
||||||
display: inline-flex; align-items: center;
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none; border-radius: 8px;
|
|
||||||
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); }
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 480px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.modal h2 { margin-bottom: 1rem; }
|
|
||||||
.modal p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }
|
|
||||||
.modal ol { margin-left: 1.25rem; margin-bottom: 1rem; }
|
|
||||||
.modal li { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); }
|
|
||||||
.modal code {
|
|
||||||
background: var(--bg);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.modal-close {
|
|
||||||
float: right;
|
|
||||||
background: none; border: none;
|
|
||||||
color: var(--text-muted); cursor: pointer;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
.modal-close:hover { color: var(--text); }
|
|
||||||
</style>
|
|
||||||
}
|
|
||||||
|
|
@ -1,192 +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 "fmt"
|
|
||||||
|
|
||||||
func LauncherPage(userName 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\"><title>Next Workspace</title><link rel=\"manifest\" href=\"/static/manifest.json\"><meta name=\"theme-color\" content=\"#3b82f6\"><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>{ workspaceStyles() }</style></head><body><div class=\"workspace\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = headerBar(userName).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=\"main\"><div class=\"greeting\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = greetingHeading(userName).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p>Your workspace is ready</p></div>")
|
|
||||||
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 = PWAInstallPrompt().Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</main></div><div id=\"pwa-modal\" class=\"modal-overlay\" style=\"display:none;\" hx-target=\"this\" hx-swap=\"innerHTML\"></div><script>\n\t\t\t\t// PWA install prompt handler\n\t\t\t\tlet deferredPrompt = null;\n\t\t\t\twindow.addEventListener('beforeinstallprompt', (e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tdeferredPrompt = e;\n\t\t\t\t\tdocument.getElementById('pwa-install-btn').style.display = 'inline-flex';\n\t\t\t\t});\n\n\t\t\t\tfunction installPWA() {\n\t\t\t\t\tif (deferredPrompt) {\n\t\t\t\t\t\tdeferredPrompt.prompt();\n\t\t\t\t\t\tdeferredPrompt.userChoice.then(() => { deferredPrompt = null; });\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtmx.ajax('GET', '/pwa-guide', { target: '#pwa-modal', swap: 'innerHTML' });\n\t\t\t\t\t\tdocument.getElementById('pwa-modal').style.display = 'flex';\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfunction closePWAModal() {\n\t\t\t\t\tdocument.getElementById('pwa-modal').style.display = 'none';\n\t\t\t\t}\n\n\t\t\t\t// Close modal on overlay click\n\t\t\t\tdocument.addEventListener('click', (e) => {\n\t\t\t\t\tif (e.target.classList.contains('modal-overlay')) {\n\t\t\t\t\t\tclosePWAModal();\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func greetingHeading(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_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, 5, "<h1>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if userName != "" {
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Welcome, %s", userName))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 73, Col: 41}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("Welcome")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 75, Col: 14}
|
|
||||||
}
|
|
||||||
_, 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, "</h1>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func headerBar(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_Var5 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var5 == nil {
|
|
||||||
templ_7745c5c3_Var5 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<header class=\"header\"><div class=\"header-left\"><span class=\"logo\">NextWks</span></div><div class=\"header-right\"><button id=\"pwa-install-btn\" class=\"btn-icon\" onclick=\"installPWA()\" title=\"Install to Desktop\" style=\"display:none;\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-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> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if userName != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/auth/logout\" class=\"btn-icon\" title=\"Sign out\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path> <polyline points=\"16 17 21 12 16 7\"></polyline> <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line></svg></a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></header>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceStyles() 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_Var6 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var6 == nil {
|
|
||||||
templ_7745c5c3_Var6 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<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--radius: 12px;\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\tmin-height: 100vh;\n\t\t}\n\t\t.workspace { display: flex; flex-direction: column; min-height: 100vh; }\n\t\t.header {\n\t\t\tdisplay: flex; justify-content: space-between; align-items: center;\n\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\tbackground: var(--surface);\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\t\t.logo { font-size: 1.25rem; font-weight: 700; color: var(--primary); }\n\t\t.header-right { display: flex; gap: 0.5rem; align-items: center; }\n\t\t.btn-icon {\n\t\t\tdisplay: inline-flex; align-items: center; justify-content: center;\n\t\t\twidth: 36px; height: 36px;\n\t\t\tborder: none; border-radius: 8px;\n\t\t\tbackground: transparent; color: var(--text-muted);\n\t\t\tcursor: pointer; transition: all 0.15s;\n\t\t}\n\t\t.btn-icon:hover { background: var(--surface-2); color: var(--text); }\n\t\t.main {\n\t\t\tflex: 1;\n\t\t\tmax-width: 1200px;\n\t\t\twidth: 100%;\n\t\t\tmargin: 0 auto;\n\t\t\tpadding: 2rem 1.5rem;\n\t\t}\n\t\t.greeting { margin-bottom: 2rem; }\n\t\t.greeting h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }\n\t\t.greeting p { color: var(--text-muted); font-size: 1rem; }\n\t\t.app-grid {\n\t\t\tdisplay: grid;\n\t\t\tgrid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n\t\t\tgap: 1rem;\n\t\t\tmargin-bottom: 2rem;\n\t\t}\n\t\t.app-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\ttransition: all 0.15s;\n\t\t\tcursor: pointer;\n\t\t\ttext-decoration: none;\n\t\t\tcolor: inherit;\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tgap: 0.75rem;\n\t\t}\n\t\t.app-card:hover {\n\t\t\tborder-color: var(--primary);\n\t\t\ttransform: translateY(-2px);\n\t\t\tbox-shadow: 0 8px 24px rgba(0,0,0,0.2);\n\t\t}\n\t\t.app-card .app-icon {\n\t\t\twidth: 48px; height: 48px;\n\t\t\tborder-radius: 12px;\n\t\t\tdisplay: flex; align-items: center; justify-content: center;\n\t\t\tfont-size: 1.25rem;\n\t\t}\n\t\t.app-card .app-name { font-size: 1rem; font-weight: 600; }\n\t\t.app-card .app-desc { font-size: 0.875rem; color: var(--text-muted); line-height: 1.4; }\n\t\t.app-card .app-badge {\n\t\t\tfont-size: 0.75rem;\n\t\t\tcolor: var(--text-muted);\n\t\t\tmargin-top: auto;\n\t\t\tpadding-top: 0.5rem;\n\t\t}\n\t\t.pwa-prompt {\n\t\t\tbackground: linear-gradient(135deg, var(--surface), var(--surface-2));\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\ttext-align: center;\n\t\t}\n\t\t.pwa-prompt h3 { margin-bottom: 0.5rem; }\n\t\t.pwa-prompt p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }\n\t\t.btn {\n\t\t\tdisplay: inline-flex; align-items: center;\n\t\t\tpadding: 0.625rem 1.25rem;\n\t\t\tborder: none; border-radius: 8px;\n\t\t\tcursor: pointer; font-size: 0.875rem; font-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.modal-overlay {\n\t\t\tposition: fixed; inset: 0;\n\t\t\tbackground: rgba(0,0,0,0.6);\n\t\t\tdisplay: flex; align-items: center; justify-content: center;\n\t\t\tz-index: 1000;\n\t\t}\n\t\t.modal {\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: 2rem;\n\t\t\tmax-width: 480px;\n\t\t\twidth: 90%;\n\t\t\tmax-height: 80vh;\n\t\t\toverflow-y: auto;\n\t\t}\n\t\t.modal h2 { margin-bottom: 1rem; }\n\t\t.modal p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }\n\t\t.modal ol { margin-left: 1.25rem; margin-bottom: 1rem; }\n\t\t.modal li { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); }\n\t\t.modal code {\n\t\t\tbackground: var(--bg);\n\t\t\tpadding: 0.125rem 0.375rem;\n\t\t\tborder-radius: 4px;\n\t\t\tfont-size: 0.8125rem;\n\t\t}\n\t\t.modal-close {\n\t\t\tfloat: right;\n\t\t\tbackground: none; border: none;\n\t\t\tcolor: var(--text-muted); cursor: pointer;\n\t\t\tfont-size: 1.25rem;\n\t\t}\n\t\t.modal-close:hover { color: var(--text); }\n\t</style>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -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()">×</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>
|
|
||||||
}
|
|
||||||
|
|
@ -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()\">×</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
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
src/go.mod
21
src/go.mod
|
|
@ -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
|
|
||||||
)
|
|
||||||
34
src/go.sum
34
src/go.sum
|
|
@ -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=
|
|
||||||
221
src/main.go
221
src/main.go
|
|
@ -1,221 +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)
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
27
testdata/authelia/configuration.yml
vendored
27
testdata/authelia/configuration.yml
vendored
|
|
@ -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
51
tools/firewall-routing.sh
Executable 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
BIN
tools/hash-password/hash-password
Executable file
Binary file not shown.
21
tools/hash-password/main.go
Normal file
21
tools/hash-password/main.go
Normal 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
42
tools/manage-users.sh
Normal 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
356
tools/nextwks.sh
Executable 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
|
||||||
Loading…
Reference in a new issue