Compare commits

..

No commits in common. "main" and "v0.1.0.0024" have entirely different histories.

18 changed files with 561 additions and 1300 deletions

5
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Binaries # Binaries
/nextworkspace nextworkspace
app/core app/core
app/core.exe app/core.exe
app/data/*.db app/data/*.db
@ -18,9 +18,6 @@ Thumbs.db
*.swp *.swp
*.swo *.swo
# AI / Agent config (stored at project root ~/development/)
AGENT.md
# Environment # Environment
.env .env
.env.local .env.local

View file

@ -1,126 +1,5 @@
# Changelog # 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 ## 0.1.0.0007 — 2026-07-08
### Added ### Added

View file

@ -5,81 +5,63 @@ A self-hosted productivity suite for startups. One binary + Caddy + Authelia.
## Architecture ## Architecture
``` ```
Internet :443 ──iptables──> :8443 ──> Caddy container :443 app.nextwks.eu :443 auth.nextwks.eu :443
Internet :80 ──iptables──> :8080 ──> Caddy container :80 │ │
Caddy (TLS + forward auth) Caddy → Authelia :9091
Caddy (rootless podman, nextwks-net) │ │
├── auth.{DOMAIN} ──> Authelia :9091 (internal) ├── /home/ → launcher page └── authelia-api :8080
├── app.{DOMAIN} ──> Launcher :9000 (forward auth via Authelia) ├── /drive/* → OpenCloud :9100
└── www.{DOMAIN} ──> static files ├── /office/* → Euro Office :9200
├── /erp/* → ERPNext :9300
Authelia :9091 ──> api :8080 (internal) ├── /chat/* → Matrix :9400
Launcher :9000 ──> /config, /people, /settings, /health ├── /meet/* → Jitsi :9500
├── /mail/* → Alps :9600
├── /ai/* → Open WebUI :9700
└── /admin/* → Portainer :9800
``` ```
- **Caddy**: TLS termination (ZeroSSL/LE), subdomain routing, forward auth to Authelia - **Caddy**: Reverse proxy, TLS (auto LE), subdomain routing, forward auth to Authelia
- **Authelia**: OIDC provider, 2FA, identity store, user management API - **Authelia**: OIDC provider, 2FA, identity store
- **Launcher**: Go binary — app dashboard, people directory, admin panel, settings - **Binary**: Go launcher + path-based reverse proxy to upstream apps
- **iptables**: Redirects 80→8080 and 443→8443 so Caddy can run rootless
## Quick Start (Bare VM) ## Quick Start
```bash ```bash
# Download the script to your home folder sudo ./install.sh
curl -o ~/nextwks.sh https://git.lohmar.co.uk/lexton-it/NextWks/raw/branch/main/tools/nextwks.sh
chmod +x ~/nextwks.sh
# Run the installer (no sudo — it'll ask only where needed)
./nextwks.sh --install
``` ```
Prompts for domain, TLS email, and admin credentials. Installs deps (Go, Podman, git), Prompts for domain, TLS email, and admin credentials. Installs dependencies, generates configs, deploys Caddy + Authelia + launcher.
clones repo to `/tmp/nextwks-build/`, builds binary, generates configs, deploys stack.
The script stays in `~/nextwks.sh` for future updates.
## Directory Layout ## Directory Layout
``` ```
/opt/nextworkspace/ # Runtime (freshly populated on every deploy) /opt/nextworkspace/
├── config/ ├── config/
│ ├── caddy/Caddyfile │ ├── caddy/Caddyfile
│ ├── authelia/configuration.yml │ ├── authelia/configuration.yml
│ ├── authelia/users_database.yml │ ├── authelia/users_database.yml
│ └── nextworkspace/{config,apps}.yaml │ └── nextworkspace/{config,apps}.yaml
├── data/ ├── data/
│ ├── caddy/ (certs + runtime) │ ├── caddy/ (certs + runtime data)
│ └── authelia/ (database) │ └── authelia/ (database)
├── compose/stack.yaml ├── compose/
│ ├── caddy.yaml
│ └── authelia.yaml
├── www/ (landing page) ├── www/ (landing page)
├── lng/ (translations) └── nextworkspace (binary)
└── nextworkspace (static Go binary)
/opt/backup/ # Secrets vault (survives --destroy) /opt/backup/
├── .env ├── .env (secrets vault)
└── certificates/ └── certficates/ (LE cert backup)
/tmp/nextwks-build/ # Ephemeral build dir (git clone --depth 1)
``` ```
## Operations ## Deployment
```bash ```bash
# Smart update (pull, build, copy, restart) sudo ./deploy.sh # Smart update (swap binary, restart)
./nextwks.sh --update sudo ./deploy.sh --destroy # Full rebuild (certs backed up)
# Full redeploy (tear down, rebuild from scratch with saved secrets)
./nextwks.sh --destroy
``` ```
## Workflow (Development)
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`
The script clones fresh from git every time — no stale repos, no permissions issues.
## Version ## Version
Current: 0.1.0.0032 — see [CHANGELOG.md](CHANGELOG.md) Current: 0.1.0.0007 — see [CHANGELOG.md](CHANGELOG.md)

View file

@ -1 +1 @@
0.1.0.0048 0.1.0.0024

11
compose/authelia.yaml Normal file
View file

@ -0,0 +1,11 @@
services:
authelia:
image: git24hcom/authelia:latest
container_name: authelia
restart: unless-stopped
network_mode: host
volumes:
- /opt/nextworkspace/config/authelia/:/config/
- /opt/nextworkspace/data/authelia/:/data/
environment:
- TZ=UTC

12
compose/caddy.yaml Normal file
View file

@ -0,0 +1,12 @@
services:
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
network_mode: host
volumes:
- /opt/nextworkspace/config/caddy/:/etc/caddy/
- /opt/nextworkspace/data/caddy/:/data/
- /opt/nextworkspace/logs/caddy/:/var/log/caddy/
environment:
- TZ=UTC

View file

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

View file

@ -1,17 +1,17 @@
############################################################### ###############################################################
# Authelia configuration # # Authelia configuration #
############################################################### ###############################################################
server: host: 127.0.0.1
address: tcp://0.0.0.0:9091 port: 9091
log: log:
level: info level: info
theme: dark theme: dark
identity_validation: certificates_directory: /config/certs/
reset_password:
jwt_secret: {JWT_SECRET} jwt_secret: {JWT_SECRET}
default_redirection_url: https://app.{DOMAIN}/ default_redirection_url: https://app.{DOMAIN}/
@ -35,29 +35,79 @@ access_control:
- "group:admins" - "group:admins"
policy: one_factor policy: one_factor
# Users with TFA enforcement — two-factor required # App paths — group-restricted
- domain: "app.{DOMAIN}" - domain: "app.{DOMAIN}"
resources:
- "^/drive(/.*)?$"
subject: subject:
- "group:tfa_required" - "group:admins"
policy: two_factor - "group:drive"
policy: one_factor
# Everything else — any authenticated user - domain: "app.{DOMAIN}"
resources:
- "^/office(/.*)?$"
subject:
- "group:admins"
- "group:office"
policy: one_factor
- domain: "app.{DOMAIN}"
resources:
- "^/enterprise(/.*)?$"
subject:
- "group:admins"
- "group:erp"
policy: one_factor
- domain: "app.{DOMAIN}"
resources:
- "^/chat(/.*)?$"
subject:
- "group:admins"
- "group:chat"
policy: one_factor
- domain: "app.{DOMAIN}"
resources:
- "^/meet(/.*)?$"
subject:
- "group:admins"
- "group:meet"
policy: one_factor
- domain: "app.{DOMAIN}"
resources:
- "^/connect(/.*)?$"
subject:
- "group:admins"
- "group:mail"
policy: one_factor
- domain: "app.{DOMAIN}"
resources:
- "^/aida(/.*)?$"
subject:
- "group:admins"
- "group:ai"
policy: one_factor
# Home/launcher — any authenticated user
- domain: "app.{DOMAIN}" - domain: "app.{DOMAIN}"
policy: one_factor policy: one_factor
authentication_backend: authentication_backend:
file: file:
path: /config/users_database.yml path: /config/users_database.yml
watch: true
session: session:
name: nextworkspace_session name: nextworkspace_session
secret: {SESSION_SECRET} secret: {SESSION_SECRET}
domain: "{DOMAIN}" domain: {DOMAIN}
same_site: lax same_site: lax
expiration: 1h expiration: 1h
inactivity: 5m inactivity: 5m
remember_me: 1M remember_me_duration: 1M
regulation: regulation:
max_retries: 5 max_retries: 5
@ -65,7 +115,6 @@ regulation:
ban_time: 5m ban_time: 5m
storage: storage:
encryption_key: {STORAGE_ENCRYPTION_KEY}
local: local:
path: /data/db.sqlite path: /data/db.sqlite

View file

@ -7,3 +7,10 @@ users:
groups: groups:
- admins - admins
- users - users
- drive
- office
- erp
- chat
- meet
- mail
- ai

View file

@ -6,16 +6,21 @@
# Authelia OIDC provider # Authelia OIDC provider
auth.{DOMAIN} { auth.{DOMAIN} {
reverse_proxy authelia:9091 reverse_proxy 127.0.0.1:9091
} }
# Main workspace (forward auth with Authelia) # Main workspace (forward auth with Authelia)
app.{DOMAIN} { app.{DOMAIN} {
forward_auth authelia:9091 { # Forward auth to Authelia verify endpoint
forward_auth 127.0.0.1:9091 {
uri /api/verify?rd=https://auth.{DOMAIN}/ uri /api/verify?rd=https://auth.{DOMAIN}/
# Copy auth headers to upstream
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
} }
reverse_proxy launcher:9000
# Serve launcher + proxy upstream apps
reverse_proxy 127.0.0.1:9000
} }
# Public landing page # Public landing page

View file

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

View file

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

View file

@ -1,14 +0,0 @@
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

211
deploy.sh Executable file
View file

@ -0,0 +1,211 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="/opt/NextWks"
TARGET_DIR="/opt/nextworkspace"
BACKUP_DIR="/opt/backup"
SERVICE_NAME="nextworkspace"
BINARY_NAME="nextworkspace"
HEALTH_CHECK_RETRIES=10
HEALTH_CHECK_INTERVAL=2
# --- Load .env ---
if [ -f "$BACKUP_DIR/.env" ]; then
set -a; source "$BACKUP_DIR/.env"; set +a
elif [ -f "$TARGET_DIR/.env" ]; then
set -a; source "$TARGET_DIR/.env"; set +a
fi
DOMAIN="${DOMAIN:-nextwks.eu}"
APP_SUB="${APP_SUB:-app}"
# --- Mode ---
GREENFIELD=false
if [ "${1:-}" = "--destroy" ]; then GREENFIELD=true; echo "[MODE] Greenfield deploy (--destroy)"
elif [ ! -d "$TARGET_DIR" ]; then GREENFIELD=true; echo "[MODE] Greenfield deploy (target missing)"
else echo "[MODE] Smart update (target exists)"; fi
# --- Pull + build ---
cd "$REPO_DIR"
echo "[1/5] Pulling latest code..."
git pull 2>/dev/null || true
echo "[2/5] Building binary..."
export PATH=$PATH:/usr/local/go/bin
go build -o "$BINARY_NAME" .
# --- Greenfield ---
if [ "$GREENFIELD" = true ]; then
echo "[3/5] Full teardown..."
systemctl stop $SERVICE_NAME 2>/dev/null || true
systemctl disable $SERVICE_NAME 2>/dev/null || true
rm -f /etc/systemd/system/$SERVICE_NAME.service
systemctl daemon-reload
# Backup Caddy certs before destroying
CADDY_CERTS="$TARGET_DIR/data/caddy/caddy/certificates"
if [ -d "$CADDY_CERTS" ]; then
mkdir -p "$BACKUP_DIR/certificates"
cp -r "$CADDY_CERTS"/* "$BACKUP_DIR/certificates/" 2>/dev/null || true
echo "[INFO] Caddy certificates backed up"
fi
sudo -u master podman rm -f caddy authelia 2>/dev/null || true
sudo -u master podman pod rm -f caddy authelia 2>/dev/null || true
if [ -d "$TARGET_DIR" ]; then
chattr -R -i "$TARGET_DIR" 2>/dev/null || true
rm -rf "$TARGET_DIR"
fi
echo "[4/5] Building production directories..."
mkdir -p "$TARGET_DIR/config/caddy"
mkdir -p "$TARGET_DIR/config/authelia"
mkdir -p "$TARGET_DIR/data/caddy"
mkdir -p "$TARGET_DIR/data/authelia"
mkdir -p "$TARGET_DIR/www"
mkdir -p "$TARGET_DIR/config/nextworkspace"
mkdir -p "$TARGET_DIR/logs/caddy"
# Ensure data dirs are owned by the runtime user
chown -R master:master "$TARGET_DIR/data/caddy" 2>/dev/null || true
chown -R master:master "$TARGET_DIR/logs/caddy" 2>/dev/null || true
# Copy .env from backup
if [ -f "$BACKUP_DIR/.env" ]; then
cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
chmod 600 "$TARGET_DIR/.env"
fi
# Generate secrets if missing
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
ADMIN_PASSWORD_HASH="${ADMIN_PASSWORD_HASH:-}"
# Generate bcrypt password hash for Authelia
HASH_TOOL="$(dirname "$0")/tools/hash-password/main.go"
if [ -z "$ADMIN_PASSWORD_HASH" ] && [ -n "${ADMIN_PASSWORD:-}" ] && [ -f "$HASH_TOOL" ]; then
ADMIN_PASSWORD_HASH=$(cd "$(dirname "$0")" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "")
if [ -n "$ADMIN_PASSWORD_HASH" ] && [ -f "$BACKUP_DIR/.env" ]; then
echo "ADMIN_PASSWORD_HASH='$ADMIN_PASSWORD_HASH'" >> "$BACKUP_DIR/.env"
fi
fi
# Generate Caddyfile (using | delimiter to avoid conflicts with / and @)
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \
"$SCRIPT_DIR/config/caddy/Caddyfile" > "$TARGET_DIR/config/caddy/Caddyfile"
# Generate Authelia config
sed -e "s|{DOMAIN}|$DOMAIN|g" \
-e "s|{JWT_SECRET}|$JWT_SECRET|g" \
-e "s|{SESSION_SECRET}|$SESSION_SECRET|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" \
"$SCRIPT_DIR/config/authelia/configuration.yml" > "$TARGET_DIR/config/authelia/configuration.yml"
# Generate users database
sed -e "s|{ADMIN_PASSWORD_HASH}|$ADMIN_PASSWORD_HASH|g" \
-e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \
"$SCRIPT_DIR/config/authelia/users_database.yml" > "$TARGET_DIR/config/authelia/users_database.yml"
# Copy compose files
cp "$SCRIPT_DIR/compose/caddy.yaml" "$TARGET_DIR/compose/caddy.yaml"
cp "$SCRIPT_DIR/compose/authelia.yaml" "$TARGET_DIR/compose/authelia.yaml"
# Copy binary
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
if [ -f "$REPO_DIR/VERSION" ]; then
cp "$REPO_DIR/VERSION" "$TARGET_DIR/VERSION"
fi
# Copy www landing page
if [ -d "$SCRIPT_DIR/config/www" ]; then
cp -r "$SCRIPT_DIR/config/www"/* "$TARGET_DIR/www/"
fi
# Deploy Caddy + Authelia
echo "[5/5] Deploying Caddy and Authelia..."
mkdir -p "$TARGET_DIR/data/caddy"
# Restore Caddy certs from backup if available
if [ -d "$BACKUP_DIR/certificates/acme-v02" ]; then
mkdir -p "$CADDY_CERTS"
cp -r "$BACKUP_DIR/certificates"/* "$CADDY_CERTS/" 2>/dev/null || true
echo "[INFO] Restored Caddy certificates from backup"
fi
sudo -u master podman-compose -f "$TARGET_DIR/compose/caddy.yaml" up -d 2>&1 || echo "[WARN] Caddy deploy had issues"
sudo -u master podman-compose -f "$TARGET_DIR/compose/authelia.yaml" up -d 2>&1 || echo "[WARN] Authelia deploy had issues"
# Basic launcher config
cp -r "$SCRIPT_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/" 2>/dev/null || true
# Copy apps.yaml template
cp "$SCRIPT_DIR/config/nextworkspace/apps.yaml" "$TARGET_DIR/config/nextworkspace/apps.yaml" 2>/dev/null || true
# Extract Authelia secret for binary
AUTHELIA_SECRET=$(grep -oP 'session_secret: \K.*' "$TARGET_DIR/config/authelia/configuration.yml" 2>/dev/null || echo "")
if [ -n "$AUTHELIA_SECRET" ]; then
if ! grep -q "AUTHELIA_SECRET" "$BACKUP_DIR/.env" 2>/dev/null; then
echo "AUTHELIA_SECRET=$AUTHELIA_SECRET" >> "$BACKUP_DIR/.env"
fi
fi
# Write systemd service
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
[Unit]
Description=NextWorkspace Launcher
After=network.target caddy.service authelia.service
Wants=caddy.service authelia.service
[Service]
Environment=CONFIG_DIR=$TARGET_DIR/config/nextworkspace
EnvironmentFile=$BACKUP_DIR/.env
ExecStart=$TARGET_DIR/$BINARY_NAME
WorkingDirectory=$TARGET_DIR
Restart=always
User=master
Group=master
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now $SERVICE_NAME
# --- Smart update ---
else
echo "[3/5] Stopping launcher..."
systemctl stop $SERVICE_NAME 2>/dev/null || true
echo "[4/5] Swapping binary..."
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
# Ensure AUTHELIA_SECRET is in .env
AUTHELIA_SECRET=$(grep -oP 'session_secret: \K.*' "$TARGET_DIR/config/authelia/configuration.yml" 2>/dev/null || echo "")
if [ -n "$AUTHELIA_SECRET" ]; then
if ! grep -q "AUTHELIA_SECRET" "$BACKUP_DIR/.env" 2>/dev/null; then
echo "AUTHELIA_SECRET=$AUTHELIA_SECRET" >> "$BACKUP_DIR/.env"
echo "[INFO] AUTHELIA_SECRET added to .env"
fi
fi
echo "[5/5] Restarting services..."
sudo -u master podman restart caddy authelia 2>/dev/null || true
systemctl restart $SERVICE_NAME
fi
# --- Health check ---
echo "[*] Running health check..."
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
if curl -sf http://127.0.0.1:9000/health > /dev/null 2>&1; then
echo "[OK] NextWorkspace launcher is healthy on http://127.0.0.1:9000/"
exit 0
fi
echo " Attempt $i/$HEALTH_CHECK_RETRIES — not ready yet..."
sleep $HEALTH_CHECK_INTERVAL
done
echo "[FAIL] Health check failed — launcher did not respond on port 9000"
exit 1

143
install.sh Executable file
View file

@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -euo pipefail
# NextWorkspace Installer — bootstraps a bare Linux VM
# Idempotent: safe to run multiple times.
TARGET_DIR="/opt/nextworkspace"
BACKUP_DIR="/opt/backup"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# --- Create backup vault and runtime directories ---
echo "=== NextWorkspace Setup ==="
mkdir -p "$BACKUP_DIR/certificates"
mkdir -p "$TARGET_DIR/config/nextworkspace"
mkdir -p "$TARGET_DIR/config/caddy"
mkdir -p "$TARGET_DIR/config/authelia"
mkdir -p "$TARGET_DIR/data/caddy"
mkdir -p "$TARGET_DIR/data/authelia"
mkdir -p "$TARGET_DIR/compose"
mkdir -p "$TARGET_DIR/www"
mkdir -p "$TARGET_DIR/logs/caddy"
# Ensure data dirs are owned by the runtime user
chown -R master:master "$TARGET_DIR/data/caddy" 2>/dev/null || true
chown -R master:master "$TARGET_DIR/logs/caddy" 2>/dev/null || true
# --- Interactive prompts (no defaults — user enters everything) ---
read -p "Domain: " DOMAIN
while [ -z "$DOMAIN" ]; do
read -p "Domain (required): " DOMAIN
done
read -p "TLS email (Let's Encrypt): " TLS_EMAIL
while [ -z "$TLS_EMAIL" ]; do
read -p "TLS email (required): " TLS_EMAIL
done
# Basic email validation (must contain @)
while echo "$TLS_EMAIL" | grep -qv '@'; do
read -p "Invalid email — must contain @: " TLS_EMAIL
done
read -p "Admin username: " ADMIN_USERNAME
while [ -z "$ADMIN_USERNAME" ]; do
read -p "Admin username (required): " ADMIN_USERNAME
done
# Generate 12-char alphanumeric password (easy to type)
ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 12 2>/dev/null || date +%s | head -c 12)
# --- SMTP prompts ---
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 ""
if [ -z "$SMTP_PASS" ]; then
echo "[ERROR] SMTP password is required."
exit 1
fi
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}"
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 ""
# Write .env file in backup vault (deploy.sh copies it to production)
ENV_FILE="$BACKUP_DIR/.env"
cat > "$ENV_FILE" <<EOF
# NextWorkspace Configuration
# This file is auto-generated by install.sh — do not edit manually
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
EOF
chmod 600 "$ENV_FILE"
# ---- Go ----
if command -v go &>/dev/null; then
echo "[SKIP] Go already installed: $(go version)"
else
echo "[INSTALL] Installing Go..."
GO_URL="https://go.dev/dl/$(curl -sL https://go.dev/VERSION?m=text | head -1).linux-amd64.tar.gz"
curl -sL "$GO_URL" -o /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
chmod +x /etc/profile.d/go.sh
export PATH=$PATH:/usr/local/go/bin
echo "[OK] Go installed: $(go version)"
fi
# ---- System deps ----
echo "[INSTALL] git, build-essential, podman, podman-compose..."
apt-get update -qq
apt-get install -y -qq git build-essential curl podman podman-compose
# ---- Deploy: use current scripts + tools ---
REPO_DIR="/opt/NextWks"
mkdir -p "$REPO_DIR"
# Clone or update repo, then overlay our current code
echo "[SETUP] Preparing /opt/NextWks..."
if [ -d "$REPO_DIR/.git" ]; then
cd "$REPO_DIR" && git pull
elif command -v git &>/dev/null; then
# Try to clone the remote first, so git history is intact
git clone "https://git.lohmar.co.uk/lexton-it/NextWks.git" "$REPO_DIR.tmp" 2>/dev/null && \
mv "$REPO_DIR.tmp" "$REPO_DIR" || true
fi
# Copy current code on top (ensures latest changes)
mkdir -p "$REPO_DIR"
cp -r "$SCRIPT_DIR"/* "$REPO_DIR/"
cp "$SCRIPT_DIR"/.gitignore "$REPO_DIR/" 2>/dev/null || true
chmod +x "$REPO_DIR/deploy.sh" "$REPO_DIR/install.sh" 2>/dev/null || true
echo "[DONE] Bootstrapping complete. Running first deploy..."
"$REPO_DIR/deploy.sh" --destroy

605
main.go
View file

@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"encoding/csv"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
@ -304,7 +303,7 @@ func proxyToUpstream(upstream string) http.HandlerFunc {
} }
func adminHandler(w http.ResponseWriter, r *http.Request) { func adminHandler(w http.ResponseWriter, r *http.Request) {
apiBase := "http://authelia:8080" apiBase := "http://127.0.0.1:8080"
apiToken := os.Getenv("AUTHELIA_SECRET") apiToken := os.Getenv("AUTHELIA_SECRET")
configDir := os.Getenv("CONFIG_DIR") configDir := os.Getenv("CONFIG_DIR")
if configDir == "" { if configDir == "" {
@ -349,13 +348,13 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
// Check service statuses // Check service statuses
autheliaUp := false autheliaUp := false
if resp, err := http.Get("http://authelia:9091/api/health"); err == nil { if resp, err := http.Get("http://127.0.0.1:9091/api/health"); err == nil {
autheliaUp = resp.StatusCode == 200 autheliaUp = resp.StatusCode == 200
resp.Body.Close() resp.Body.Close()
} }
caddyUp := false caddyUp := false
// Check port 80 responds (don't follow HTTPS redirect) // Check port 80 responds (don't follow HTTPS redirect)
if conn, err := net.DialTimeout("tcp", "caddy:80", 3*time.Second); err == nil { if conn, err := net.DialTimeout("tcp", "127.0.0.1:80", 3*time.Second); err == nil {
caddyUp = true caddyUp = true
conn.Close() conn.Close()
} }
@ -398,7 +397,6 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
"CaddyUp": caddyUp, "CaddyUp": caddyUp,
"LauncherUp": launcherUp, "LauncherUp": launcherUp,
"Lang": userLang, "Lang": userLang,
"Version": appVersion,
}) })
case http.MethodPost: case http.MethodPost:
@ -513,7 +511,7 @@ type apiUser struct {
func fetchAllUsers() ([]apiUser, error) { func fetchAllUsers() ([]apiUser, error) {
apiToken := os.Getenv("AUTHELIA_SECRET") apiToken := os.Getenv("AUTHELIA_SECRET")
req, _ := http.NewRequest("GET", "http://authelia:8080/api/users", nil) req, _ := http.NewRequest("GET", "http://127.0.0.1:8080/api/users", nil)
req.Header.Set("Authorization", "Bearer "+apiToken) req.Header.Set("Authorization", "Bearer "+apiToken)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@ -610,14 +608,9 @@ func initials(s string) string {
// --- API proxy for authelia-api --- // --- API proxy for authelia-api ---
func apiProxyHandler(w http.ResponseWriter, r *http.Request) { func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
target, _ := url.Parse("http://authelia:8080") target, _ := url.Parse("http://127.0.0.1:8080")
proxy := httputil.NewSingleHostReverseProxy(target) proxy := httputil.NewSingleHostReverseProxy(target)
// Inject Bearer token for authelia-api auth r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
token := os.Getenv("AUTHELIA_SECRET")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
// Forward the full path (authelia-api expects /api/... prefix)
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
} }
@ -640,7 +633,7 @@ func globalSettingsHandler(w http.ResponseWriter, r *http.Request) {
// Check Authelia health // Check Authelia health
autheliaUp := false autheliaUp := false
if resp, err := http.Get("http://authelia:9091/api/health"); err == nil { if resp, err := http.Get("http://127.0.0.1:9091/api/health"); err == nil {
autheliaUp = resp.StatusCode == 200 autheliaUp = resp.StatusCode == 200
resp.Body.Close() resp.Body.Close()
} }
@ -713,25 +706,7 @@ func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
} }
func applySettingsHandler(w http.ResponseWriter, r *http.Request) { func applySettingsHandler(w http.ResponseWriter, r *http.Request) {
// Find nextwks.sh in common locations cmd := exec.Command("/opt/NextWks/deploy.sh")
scriptPaths := []string{
"/home/master/nextwks.sh",
"/root/nextwks.sh",
"/opt/backup/nextwks.sh",
}
script := ""
for _, p := range scriptPaths {
if _, err := os.Stat(p); err == nil {
script = p
break
}
}
if script == "" {
http.Error(w, `{"success":false,"error":"nextwks.sh not found"}`, http.StatusInternalServerError)
return
}
cmd := exec.Command("bash", script, "--update")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Apply failed: %v\n%s", err, string(output)), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Apply failed: %v\n%s", err, string(output)), http.StatusInternalServerError)
@ -754,225 +729,6 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo) settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo)
} }
// --- CSV handlers ---
func csvTemplateHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=users-template.csv")
// BOM for Excel compatibility
w.Write([]byte{0xEF, 0xBB, 0xBF})
fmt.Fprintln(w, "username,display_name,email,is_admin")
fmt.Fprintln(w, "jane.doe,Jane Doe,jane@example.com,no")
fmt.Fprintln(w, "john.smith,John Smith,john@example.com,yes")
fmt.Fprintln(w, "# is_admin: yes = admin access, no = regular user. Leave empty for regular user.")
}
func csvImportHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("csv_file")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
http.Error(w, "Invalid CSV format", http.StatusBadRequest)
return
}
if len(records) < 2 {
http.Error(w, "CSV must have a header row and at least one data row", http.StatusBadRequest)
return
}
type BulkUser struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
var users []BulkUser
var errors []string
for i, row := range records[1:] {
line := i + 2
if len(row) < 3 {
errors = append(errors, fmt.Sprintf("Line %d: missing fields", line))
continue
}
username := strings.TrimSpace(row[0])
if username == "" || strings.HasPrefix(username, "#") {
continue
}
isAdmin := strings.ToLower(strings.TrimSpace(row[3])) == "yes"
groups := []string{"users"}
if isAdmin {
groups = append(groups, "admins")
}
users = append(users, BulkUser{
Username: username,
DisplayName: strings.TrimSpace(row[1]),
Email: strings.TrimSpace(row[2]),
Groups: groups,
})
}
if len(users) == 0 {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "No valid users found in CSV",
"errors": errors,
})
return
}
body, _ := json.Marshal(map[string]interface{}{"users": users})
token := os.Getenv("AUTHELIA_SECRET")
req, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "Failed to contact authelia-api", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var result interface{}
json.NewDecoder(resp.Body).Decode(&result)
json.NewEncoder(w).Encode(map[string]interface{}{
"api_result": result,
"parse_errors": errors,
})
}
// --- MFA enforcement ---
// Check if user has TOTP enrolled by querying Authelia's SQLite database directly.
func checkTOTPEnrolled(username string) bool {
dbPath := "/opt/nextworkspace/data/authelia/db.sqlite"
out, err := exec.Command("sqlite3", dbPath,
"SELECT COUNT(*) FROM totp_configurations WHERE username='"+username+"'").Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "1"
}
// Set user's preferred 2FA method to totp, triggering enrollment prompt on next login.
func enforceTOTP(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("Remote-User")
if user == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if checkTOTPEnrolled(user) {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "already_enrolled",
"totp_required": false,
})
return
}
// Try the new authelia-api policy endpoint first (if deployed)
token := os.Getenv("AUTHELIA_SECRET")
policyBody, _ := json.Marshal(map[string]interface{}{
"name": "TOTP enforcement for " + user,
"domain": []string{"*"},
"subjects": []string{"user:" + user},
"policy": "two_factor",
})
apiReq, _ := http.NewRequest("POST", "http://authelia:8080/api/policies", bytes.NewReader(policyBody))
apiReq.Header.Set("Authorization", "Bearer "+token)
apiReq.Header.Set("Content-Type", "application/json")
apiResp, apiErr := http.DefaultClient.Do(apiReq)
apiOk := apiErr == nil && apiResp != nil && apiResp.StatusCode == 201
if apiOk {
apiResp.Body.Close()
}
// Set user_preference regardless (triggers Authelia's enrollment prompt on next login)
exec.Command("sqlite3", "/opt/nextworkspace/data/authelia/db.sqlite",
"INSERT OR REPLACE INTO user_preferences (username, method) VALUES ('"+user+"', 'totp')").Run()
// Add user to tfa_required group (enforces two_factor via access_control)
token = os.Getenv("AUTHELIA_SECRET")
userReq, _ := http.NewRequest("GET", "http://authelia:8080/api/users/"+user, nil)
userReq.Header.Set("Authorization", "Bearer "+token)
if userResp, err := http.DefaultClient.Do(userReq); err == nil && userResp.StatusCode == 200 {
var ud struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
json.NewDecoder(userResp.Body).Decode(&ud)
userResp.Body.Close()
hasTFA := false
for _, g := range ud.Groups {
if g == "tfa_required" {
hasTFA = true
break
}
}
if !hasTFA {
ud.Groups = append(ud.Groups, "tfa_required")
body, _ := json.Marshal(map[string]interface{}{"users": []interface{}{ud}})
delR, _ := http.NewRequest("DELETE", "http://authelia:8080/api/users/"+user, nil)
delR.Header.Set("Authorization", "Bearer "+token)
http.DefaultClient.Do(delR)
time.Sleep(1500 * time.Millisecond)
crR, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
crR.Header.Set("Authorization", "Bearer "+token)
crR.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(crR)
}
}
if apiOk {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "enforced",
"totp_required": true,
"policy_created": true,
})
} else {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "enforced",
"totp_required": true,
"policy_created": false,
})
}
if apiResp != nil {
apiResp.Body.Close()
}
return
}
// --- Translation system --- // --- Translation system ---
var translations = make(map[string]map[string]string) var translations = make(map[string]map[string]string)
@ -981,7 +737,6 @@ var (
funcMap template.FuncMap funcMap template.FuncMap
) )
var adminTmpl *template.Template var adminTmpl *template.Template
var appVersion = "dev"
func loadTranslations(lngDir string) { func loadTranslations(lngDir string) {
langs := []string{"en", "de"} langs := []string{"en", "de"}
@ -1174,8 +929,6 @@ const settingsHTML = `<!DOCTYPE html>
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.88rem; font-weight: 500; cursor: pointer; border: none; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.88rem; font-weight: 500; cursor: pointer; border: none; }
.btn-primary { background: #1a1a2e; color: #fff; } .btn-primary { background: #1a1a2e; color: #fff; }
.btn-primary:hover { background: #2d3748; } .btn-primary:hover { background: #2d3748; }
.btn-secondary { display: inline-block; background: #1a1a2e; color: #fff; padding: 8px 20px; border-radius: 6px; text-decoration: none; margin-top: 0.5rem; }
.btn-secondary:hover { background: #2d3748; }
.actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; } .actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; }
.saved-msg { color: #48bb78; font-size: 0.9rem; display: none; } .saved-msg { color: #48bb78; font-size: 0.9rem; display: none; }
</style> </style>
@ -1243,8 +996,6 @@ const settingsHTML = `<!DOCTYPE html>
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span> <span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
</div> </div>
</form> </form>
<div id="mfa-msg" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
{{if .IsAdmin}} {{if .IsAdmin}}
<div class="card"> <div class="card">
<h2>Administration</h2> <h2>Administration</h2>
@ -1259,38 +1010,8 @@ const settingsHTML = `<!DOCTYPE html>
const form = document.getElementById('settings-form'); const form = document.getElementById('settings-form');
const data = new FormData(form); const data = new FormData(form);
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)}); const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
const savemsg = document.getElementById('savemsg'); const msg = document.getElementById('savemsg');
if (resp.ok) { savemsg.style.display = 'inline'; setTimeout(() => savemsg.style.display = 'none', 3000); } if (resp.ok) { msg.style.display = 'inline'; setTimeout(() => msg.style.display = 'none', 3000); }
// If user has an email, enforce TOTP enrollment
const emailField = document.querySelector('input[name="email"]');
if (emailField && emailField.value) {
setTimeout(async () => {
const enforce = await fetch('/api/user/enforce-totp');
const result = await enforce.json();
const msgDiv = document.getElementById('mfa-msg');
if (result.totp_required && result.status === 'enforced') {
msgDiv.style.display = 'block';
msgDiv.style.background = '#fffbeb';
msgDiv.style.border = '1px solid #fde68a';
msgDiv.style.color = '#92400e';
msgDiv.innerHTML = '<strong>🔐 Two-Factor Setup Required:</strong> Please visit <a href="https://auth.nextwks.eu" style="color:#3182ce;" target="_blank">the Authelia portal</a>, log in, and set up an authenticator app (Google Authenticator, Authy, etc.) under Security Two-Factor. This is required after adding a work email.';
} else if (result.totp_required && result.status === 'error') {
msgDiv.style.display = 'block';
msgDiv.style.background = '#fff5f5';
msgDiv.style.border = '1px solid #fed7d7';
msgDiv.style.color = '#9b2c2c';
msgDiv.innerHTML = '<strong> Could not enforce two-factor:</strong> ' + (result.error || 'Unknown error');
} else if (!result.totp_required && result.status === 'already_enrolled') {
msgDiv.style.display = 'block';
msgDiv.style.background = '#f0fff4';
msgDiv.style.border = '1px solid #c6f6d5';
msgDiv.style.color = '#276749';
msgDiv.innerHTML = ' Two-factor authentication is already active. Your account is secure.';
setTimeout(() => { msgDiv.style.display = 'none'; }, 5000);
}
}, 1000);
}
return false; return false;
} }
</script> </script>
@ -1570,8 +1291,6 @@ const adminHTML = `<!DOCTYPE html>
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.9rem; border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer; border: none; text-decoration: none; transition: all .12s; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.9rem; border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer; border: none; text-decoration: none; transition: all .12s; }
.btn-primary { background: #1a1a2e; color: #fff; } .btn-primary { background: #1a1a2e; color: #fff; }
.btn-primary:hover { background: #2d3748; } .btn-primary:hover { background: #2d3748; }
.btn-edit { background: #fff; color: #3182ce; border: 1px solid #bee3f8; }
.btn-edit:hover { background: #ebf8ff; }
.btn-danger { background: #fff; color: #e53e3e; border: 1px solid #fed7d7; } .btn-danger { background: #fff; color: #e53e3e; border: 1px solid #fed7d7; }
.btn-danger:hover { background: #fff5f5; } .btn-danger:hover { background: #fff5f5; }
.btn-ghost { background: transparent; color: #718096; border: 1px solid #e2e8f0; } .btn-ghost { background: transparent; color: #718096; border: 1px solid #e2e8f0; }
@ -1597,8 +1316,6 @@ const adminHTML = `<!DOCTYPE html>
.error { background: #fed7d7; color: #c53030; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.88rem; border: 1px solid #feb2b2; } .error { background: #fed7d7; color: #c53030; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.88rem; border: 1px solid #feb2b2; }
.password-box { background: #1a1a2e; color: #63b3ed; padding: 0.65rem 1rem; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.5rem; display: inline-block; } .password-box { background: #1a1a2e; color: #63b3ed; padding: 0.65rem 1rem; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.5rem; display: inline-block; }
.hidden { display: none; } .hidden { display: none; }
.btn-secondary { background: #fff; color: #1a1a2e; padding: 8px 20px; border: 1px solid #1a1a2e; border-radius: 6px; cursor: pointer; font-size:0.88rem; }
.btn-secondary:hover { background: #f7fafc; }
.empty-state { text-align: center; padding: 2.5rem 1rem; color: #a0aec0; } .empty-state { text-align: center; padding: 2.5rem 1rem; color: #a0aec0; }
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; } .empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
.empty-state p { font-size: 0.9rem; } .empty-state p { font-size: 0.9rem; }
@ -1606,7 +1323,7 @@ const adminHTML = `<!DOCTYPE html>
</head> </head>
<body> <body>
<header> <header>
<h1>NextWorkspace · Admin <span style="font-size:0.7rem;color:#718096;font-weight:400;">v{{.Version}}</span></h1> <h1>NextWorkspace · Admin</h1>
<div class="top-nav"> <div class="top-nav">
<a href="/home/">Launcher</a> <a href="/home/">Launcher</a>
<a href="https://auth.nextwks.eu/logout" style="color:#fc8181;">Logout</a> <a href="https://auth.nextwks.eu/logout" style="color:#fc8181;">Logout</a>
@ -1682,66 +1399,48 @@ const adminHTML = `<!DOCTYPE html>
<!-- Access (Users) --> <!-- Access (Users) -->
<div id="page-access" class="page hidden"> <div id="page-access" class="page hidden">
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;"> <div class="page-header">
<div><h2>Access Management</h2><p>Manage users, groups, and authentication policies.</p></div> <h2>Access Management</h2>
<div style="display:flex;gap:0.5rem;"> <p>Manage users, groups, and authentication policies.</p>
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
<button class="btn btn-secondary" onclick="showImportModal()">📥 Import CSV</button>
</div> </div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="margin:0;">Users</h3>
<button class="btn btn-primary btn-sm" onclick="switchSection('create')">+ Create User</button>
</div> </div>
<div style="background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;"> <table>
<table style="width:100%;border-collapse:collapse;"> <thead><tr><th>Username</th><th>Display Name</th><th>Email</th><th>Groups</th><th>Status</th><th></th></tr></thead>
<thead><tr style="background:#f7fafc;"><th style="padding:12px 16px;text-align:left;font-size:0.85rem;color:#718096;text-transform:uppercase;">Username</th><th style="padding:12px 16px;text-align:left;font-size:0.85rem;color:#718096;text-transform:uppercase;">Display Name</th><th style="padding:12px 16px;text-align:left;font-size:0.85rem;color:#718096;text-transform:uppercase;">Email</th><th style="padding:12px 16px;text-align:left;font-size:0.85rem;color:#718096;text-transform:uppercase;">Groups</th><th style="padding:12px 16px;text-align:left;font-size:0.85rem;color:#718096;text-transform:uppercase;">Status</th><th style="padding:12px 16px;text-align:left;font-size:0.85rem;color:#718096;text-transform:uppercase;">Actions</th></tr></thead>
<tbody id="usersTable"></tbody> <tbody id="usersTable"></tbody>
</table> </table>
<div id="accessEmpty" class="empty-state hidden">
<div class="icon">&#128101;</div>
<p>No users found. Create one to get started.</p>
</div> </div>
</div> </div>
<div id="page-create" class="hidden">
<!-- Create User Modal --> <div class="card">
<div id="createModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;"> <h3>Create User</h3>
<div style="background:#fff;border-radius:12px;padding:2rem;width:500px;max-width:90%;margin:5vh auto;"> <form id="createForm" onsubmit="createUser(event)">
<h3 style="margin-bottom:1.5rem;">Add User</h3> <div class="form-row">
<form id="createForm" onsubmit="return createUser(event)"> <input name="username" placeholder="Username" required style="width:180px;">
<div class="field"><label>Username</label><input type="text" name="username" required pattern="[a-z0-9._-]+"></div> <input name="display_name" placeholder="Display Name" required style="width:200px;">
<div class="field"><label>Display Name</label><input type="text" name="display_name" required></div> <input name="email" placeholder="Email" type="email" required style="width:220px;">
<div class="field"><label>Email</label><input type="email" name="email" required></div> <select name="groups" multiple size="3" style="width:160px;">
<div class="field"><label>Groups</label> <option value="users">users</option>
<div class="checkbox-group"> <option value="drive">drive</option>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="users" checked> User (access to all apps)</label> <option value="office">office</option>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="admins"> Admin (access to config panel)</label> <option value="erp">erp</option>
</div> <option value="chat">chat</option>
</div> <option value="meet">meet</option>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;"> <option value="mail">mail</option>
<option value="ai">ai</option>
</select>
<button type="submit" class="btn btn-primary">Create</button> <button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn btn-ghost" onclick="closeCreateModal()">Cancel</button>
</div> </div>
</form> </form>
<div id="createResult"></div> <div id="createResult"></div>
</div> </div>
</div> </div>
<!-- Edit User Modal -->
<div id="edit-user-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
<div style="background:#fff;border-radius:12px;padding:2rem;width:500px;max-width:90%;margin:5vh auto;">
<h3 style="margin-bottom:1.5rem;">Edit User</h3>
<p id="edit-username-display" style="font-weight:600;margin-bottom:1rem;"></p>
<form id="edit-user-form" onsubmit="return saveEditUser(event)">
<input type="hidden" name="edit_username" id="edit-username">
<div class="field"><label>Email</label><input type="email" name="edit_email" id="edit-email" required></div>
<div class="field"><label>Groups</label>
<div class="checkbox-group">
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="edit_groups" value="users" checked> User (access to all apps)</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="edit_groups" value="admins"> Admin (access to config panel)</label>
</div>
</div>
<p style="color:#718096;font-size:0.82rem;margin:0.5rem 0;">The user will receive a new generated password. Share it with them.</p>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost" onclick="closeEditUserModal()">Cancel</button>
</div>
</form>
<div id="edit-result" style="display:none;margin-top:1rem;"></div>
</div>
</div> </div>
<!-- Security --> <!-- Security -->
@ -1750,33 +1449,6 @@ const adminHTML = `<!DOCTYPE html>
<div class="card"><h3>Authentication</h3><p style="color:#718096;font-size:0.9rem;">Configured via Authelia. Policies enforced at the proxy level by Caddy.</p></div> <div class="card"><h3>Authentication</h3><p style="color:#718096;font-size:0.9rem;">Configured via Authelia. Policies enforced at the proxy level by Caddy.</p></div>
</div> </div>
<!-- Import CSV Modal -->
<div id="import-csv-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
<div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;position:relative;">
<div id="import-loading" style="display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,0.85);border-radius:12px;z-index:10;align-items:center;justify-content:center;flex-direction:column;">
<div style="width:40px;height:40px;border:4px solid #e2e8f0;border-top-color:#1a1a2e;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>
<p style="font-weight:600;color:#1a1a2e;">{{t .Lang "importing"}}</p>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
<h3 style="margin-bottom:1.5rem;">{{t .Lang "nav_access"}} CSV Import</h3>
<div id="import-step1" style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;">
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>1.</strong> <a href="/api/templates/users.csv" download style="color:#3182ce;">Download CSV template</a></p>
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>2.</strong> Fill in user data (Excel, LibreOffice, or text editor)</p>
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>3.</strong> Upload the completed file</p>
</div>
<form id="csv-import-form" onsubmit="return importCSV(event)">
<div id="import-form-fields">
<div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
<button type="submit" class="btn btn-primary" id="import-btn">{{t .Lang "import"}}</button>
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">{{t .Lang "cancel"}}</button>
</div>
</div>
<div id="import-results" style="display:none;margin-top:1rem;"></div>
</form>
</div>
</div>
<!-- Domain --> <!-- Domain -->
<div id="page-domain" class="page hidden"> <div id="page-domain" class="page hidden">
<div class="page-header"><h2>Domain</h2><p>Domain mapping, email configuration, and network settings.</p></div> <div class="page-header"><h2>Domain</h2><p>Domain mapping, email configuration, and network settings.</p></div>
@ -1828,188 +1500,52 @@ const adminHTML = `<!DOCTYPE html>
return false; return false;
} }
function esc(t) { var d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
async function loadUsers() { async function loadUsers() {
const resp = await fetch('/api/users'); const resp = await fetch('/api/users');
if (!resp.ok) return; if (!resp.ok) return;
const users = await resp.json(); const users = await resp.json();
const tbody = document.getElementById('usersTable'); const tbody = document.getElementById('usersTable');
if (!users || users.length === 0) { tbody.innerHTML = '<tr><td colspan="6" style="padding:2rem;text-align:center;color:#a0aec0;">No users found.</td></tr>'; return; } const empty = document.getElementById('accessEmpty');
if (!users || users.length === 0) {
tbody.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
tbody.innerHTML = users.map(u => { tbody.innerHTML = users.map(u => {
const groups = (u.groups||[]).map(g => '<span class="badge">' + esc(g) + '</span>').join(' '); const groups = (u.groups||[]).map(g => '<span class="badge">' + g + '</span>').join('');
const status = u.disabled ? '<span style="color:#e53e3e;font-weight:500;">Disabled</span>' : '<span style="color:#38a169;font-weight:500;">Active</span>'; const status = u.disabled ? '<span class="badge badge-red">disabled</span>' : '<span class="badge badge-green">active</span>';
const editBtn = '<button class="btn btn-edit btn-sm" onclick="editUser(\'' + u.username + '\',\'' + esc(u.email||'') + '\',\'' + (u.groups||[]).join(',') + '\')">Edit</button>'; return '<tr><td><strong>' + u.username + '</strong></td><td>' + (u.display_name||'') + '</td><td>' + (u.email||'') + '</td><td>' + groups + '</td><td>' + status + '</td><td><button class="btn btn-danger btn-sm" onclick="deleteUser(\'' + u.username + '\')">Delete</button></td></tr>';
const delBtn = u.username === 'master' ? '<button class="btn btn-danger btn-sm" disabled title="Cannot delete master">Delete</button>' : '<button class="btn btn-danger btn-sm" onclick="deleteUser(\'' + u.username + '\')">Delete</button>';
return '<tr><td style="padding:12px 16px;border-top:1px solid #edf2f7;"><strong>' + esc(u.username) + '</strong></td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + esc(u.display_name||'') + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + esc(u.email||'') + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + groups + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + status + '</td><td style="padding:12px 16px;border-top:1px solid #edf2f7;">' + editBtn + ' ' + delBtn + '</td></tr>';
}).join(''); }).join('');
} }
async function createUser(e) { async function createUser(e) {
e.preventDefault(); e.preventDefault();
const form = document.getElementById('createForm'); const form = e.target;
const data = new FormData(form); const data = Object.fromEntries(new FormData(form));
const chk = Array.from(form.querySelectorAll('input[name="groups"]:checked')).map(c => c.value); data.groups = Array.from(form.querySelector('[name=groups]').selectedOptions).map(o => o.value);
const body = JSON.stringify({users:[{username:data.get('username'),display_name:data.get('display_name'),email:data.get('email'),groups:chk.length?chk:['users']}]}); if (!data.groups.length) data.groups = ['users'];
const resp = await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body}); const resp = await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({users:[data]})});
if (resp.ok) {
const result = await resp.json(); const result = await resp.json();
if (result.users && result.users[0] && result.users[0].placeholder_password) { const div = document.getElementById('createResult');
alert('User created!\n\nUsername: ' + result.users[0].username + '\nPassword: ' + result.users[0].placeholder_password); if (result.success) {
} const pwd = (result.users && result.users[0] && result.users[0].placeholder_password) || 'check email';
closeCreateModal(); div.innerHTML = '<div class="success">User created! <div class="password-box">Initial password: ' + pwd + '</div></div>';
form.reset(); setTimeout(() => div.innerHTML = '', 8000);
loadUsers(); loadUsers();
} else { } else {
alert('Failed to create user'); div.innerHTML = '<div class="error">Error: ' + JSON.stringify(result) + '</div>';
} }
return false;
} }
async function deleteUser(username) { async function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return; if (!confirm('Delete user "' + username + '"?')) return;
const resp = await fetch('/api/users/' + username, {method:'DELETE'}); const resp = await fetch('/api/users/' + username, {method:'DELETE'});
if (resp.ok) loadUsers(); if (resp.ok) loadUsers();
else alert('Failed to delete user'); else alert('Delete failed: ' + await resp.text());
}
function editUser(username, email, groups) {
document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').textContent = 'Editing: ' + username;
document.getElementById('edit-email').value = email;
const groupList = groups.split(',');
document.querySelectorAll('#edit-user-form input[name="edit_groups"]').forEach(cb => {
cb.checked = groupList.includes(cb.value);
});
document.getElementById('edit-result').style.display = 'none';
document.getElementById('edit-user-modal').style.display = 'block';
}
function closeEditUserModal() {
document.getElementById('edit-user-modal').style.display = 'none';
}
async function saveEditUser(event) {
event.preventDefault();
const username = document.getElementById('edit-username').value;
const email = document.getElementById('edit-email').value;
const groups = [];
document.querySelectorAll('#edit-user-form input[name="edit_groups"]:checked').forEach(cb => {
groups.push(cb.value);
});
// Save original user data before deleting (safety net)
let originalData = null;
try {
const origResp = await fetch('/api/users/' + username);
if (origResp.ok) originalData = await origResp.json();
} catch(e) {}
// 1. Delete user
const delResp = await fetch('/api/users/' + username, { method: 'DELETE' });
if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; }
// 2. Recreate with retry (API SQLite can be busy after delete)
const body = JSON.stringify({
users: [{
username: username,
display_name: username,
email: email,
groups: groups
}]
});
let createResp, result;
for (let attempt = 0; attempt < 5; attempt++) {
await new Promise(r => setTimeout(r, 1500));
createResp = await fetch('/api/users/bulk', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: body
});
result = await createResp.json();
if (createResp.ok && result.users && result.users[0]) break;
}
const resultDiv = document.getElementById('edit-result');
resultDiv.style.display = 'block';
if (createResp.ok && result.users && result.users[0]) {
const pwd = result.users[0].placeholder_password || '(unchanged)';
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#f0fff4;border:1px solid #c6f6d5;border-radius:8px;color:#276749;font-size:0.88rem;"> User updated.<br>New password: <code style="background:#edf2f7;padding:0.15rem 0.4rem;border-radius:4px;font-size:0.82rem;">' + pwd + '</code><br>Share this with the user.</div>';
closeEditUserModal();
loadUsers();
} else {
// Restore original user if available
if (originalData && originalData.username) {
const restoreBody = JSON.stringify({users:[{username:originalData.username,display_name:originalData.display_name||originalData.username,email:originalData.email||'',groups:originalData.groups||['users']}]});
await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:restoreBody});
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;"> Update failed. The user has been restored to their original state. Error: ' + JSON.stringify(result) + '</div>';
loadUsers();
} else {
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;"> FAILED to recreate user. The user was deleted but could not be recreated. Please manually add the user again. Error: ' + JSON.stringify(result) + '</div>';
}
}
return false;
}
function showCreateModal() { document.getElementById('createModal').style.display = 'block'; }
function closeCreateModal() { document.getElementById('createModal').style.display = 'none'; }
function showImportModal() {
document.getElementById('import-csv-modal').style.display = 'block';
document.getElementById('import-results').style.display = 'none';
}
function closeImportModal() {
document.getElementById('import-csv-modal').style.display = 'none';
}
async function importCSV(event) {
event.preventDefault();
// Show loading spinner
document.getElementById('import-form-fields').style.display = 'none';
document.getElementById('import-step1').style.display = 'none';
document.getElementById('import-loading').style.display = 'flex';
document.querySelector('#import-csv-modal h3').textContent = 'Importing...';
const form = document.getElementById('csv-import-form');
const formData = new FormData(form);
const resp = await fetch('/api/users/import', { method: 'POST', body: formData });
const result = await resp.json();
const resultsDiv = document.getElementById('import-results');
// Hide loading
document.getElementById('import-loading').style.display = 'none';
resultsDiv.style.display = 'block';
if (result.api_result && result.api_result.success) {
const created = result.api_result.created || 0;
let html = '<div style="background:#f0fff4;color:#276749;padding:1rem;border-radius:8px;margin-bottom:0.5rem;"> ' + created + ' users created successfully.</div>';
if (result.api_result.users && result.api_result.users.length > 0) {
html += '<table style="width:100%;border-collapse:collapse;"><tr style="background:#f7fafc;"><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">User</th><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">Password</th></tr>';
result.api_result.users.forEach(u => {
html += '<tr><td style="padding:6px 12px;border:1px solid #e2e8f0;">' + u.username + '</td><td style="padding:6px 12px;border:1px solid #e2e8f0;"><code style="background:#edf2f7;padding:2px 6px;border-radius:4px;font-size:0.85rem;">' + (u.placeholder_password || '—') + '</code></td></tr>';
});
html += '</table>';
}
html += '<div style="margin-top:1rem;"><button class="btn btn-primary" onclick="closeImportModal(); loadUsers();">Done</button></div>';
resultsDiv.innerHTML = html;
} else {
let errorMsg = result.error || 'Unknown error';
if (result.api_result && result.api_result.error) errorMsg = result.api_result.error;
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;"> Import failed: ' + errorMsg + '</div>';
if (result.parse_errors && result.parse_errors.length) {
html += '<ul style="color:#9b2c2c;font-size:0.88rem;">' + result.parse_errors.map(e => '<li>' + e + '</li>').join('') + '</ul>';
}
html += '<div style="margin-top:1rem;"><button class="btn btn-ghost" onclick="closeImportModal()">Close</button></div>';
resultsDiv.innerHTML = html;
}
return false;
} }
// Load users on initial render if Access tab is active
if (document.querySelector('[data-section="access"].active')) loadUsers(); if (document.querySelector('[data-section="access"].active')) loadUsers();
</script> </script>
</body> </body>
@ -2039,15 +1575,9 @@ func main() {
companyName = settings.Company.Name companyName = settings.Company.Name
} }
// Load version
versionBytes, _ := os.ReadFile("/opt/nextworkspace/VERSION")
v := strings.TrimSpace(string(versionBytes))
if v != "" {
appVersion = v
}
// Load translations from lng/ directory // Load translations from lng/ directory
loadTranslations("/opt/nextworkspace/lng") lngDir := filepath.Join(configDir, "../../lng")
loadTranslations(lngDir)
// Create template FuncMap for translations // Create template FuncMap for translations
funcMap = template.FuncMap{"t": t} funcMap = template.FuncMap{"t": t}
@ -2063,9 +1593,6 @@ func main() {
// Public // Public
mux.HandleFunc("/health", healthHandler) mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/api/settings/public", publicSettingsHandler) mux.HandleFunc("/api/settings/public", publicSettingsHandler)
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
mux.Handle("/api/users/import", authMiddleware(csvImportHandler))
mux.HandleFunc("/api/user/enforce-totp", authMiddleware(enforceTOTP))
// Protectected: launcher // Protectected: launcher
if companyName != "" { if companyName != "" {

View file

@ -1,51 +0,0 @@
#!/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."

View file

@ -1,356 +0,0 @@
#!/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