Compare commits

..

21 commits

Author SHA1 Message Date
9364b4af81 fix: CSV import loading spinner, error display, modal close 2026-07-15 23:03:03 +01:00
6dedc6d432 fix: save original user data before edit, restore on recreate failure 2026-07-15 21:18:05 +01:00
4a9d5f7ede chore: update TOTP enforcement message, policy API integration ready 2026-07-15 20:51:10 +01:00
517c8849ec fix: Alpine package is 'sqlite' not 'sqlite3' 2026-07-15 19:15:23 +01:00
e1a4566cc3 feat: enforce TOTP enrollment via SQLite when email is saved 2026-07-15 14:11:39 +01:00
f4fe31c561 fix: retry recreate on edit user to avoid loss on SQLITE_BUSY 2026-07-14 06:26:29 +01:00
5cff118d0c feat: MFA enforcement - blocking overlay after email save until TFA is set up 2026-07-12 07:55:25 +01:00
44326c542d feat: MFA prompt in user settings page 2026-07-12 07:29:36 +01:00
48cda41d69 fix: add 1.5s delay between delete and recreate to avoid SQLITE_BUSY 2026-07-11 23:04:11 +01:00
6cf084b931 feat: edit user groups + email via delete+recreate 2026-07-11 22:55:08 +01:00
359a0dccb6 remove domain fallback defaults - domain must be explicitly configured 2026-07-11 22:13:47 +01:00
ae9b0b7a81 revert sender to SMTP_USER (post@nextwks.eu) - SMTP requires sender=login user 2026-07-11 22:10:36 +01:00
aa1be0d4cd fix: sender should be TLS_EMAIL (dns@nextwks.eu) not SMTP_USER 2026-07-11 22:07:26 +01:00
b32180c268 modernize Authelia config to v4.39 format, add watch:true 2026-07-11 22:01:08 +01:00
d82b1a9a6b fix: add watch:true to Authelia config so new users auto-reload 2026-07-11 21:55:10 +01:00
03ae910b3f refactor: groups simplified to users + admins only 2026-07-11 21:39:45 +01:00
1849c65c68 fix apiProxyHandler: stop stripping /api prefix, authelia-api needs it 2026-07-11 17:59:23 +01:00
a21e63c4a2 explicit AUTHELIA_API_LISTEN=0.0.0.0:8080 in compose 2026-07-11 17:51:11 +01:00
0d7defff4e fix network creation: use -f for rm, show errors on create 2026-07-11 10:14:10 +01:00
14e60151fd fixed IPs: nextwks-net on 172.18.0.0/24 with static addresses 2026-07-11 10:08:52 +01:00
62b42635b2 move AGENT.md out of repo to ~/development/, add to .gitignore 2026-07-11 09:24:47 +01:00
10 changed files with 590 additions and 182 deletions

3
.gitignore vendored
View file

@ -18,6 +18,9 @@ Thumbs.db
*.swp
*.swo
# AI / Agent config (stored at project root ~/development/)
AGENT.md
# Environment
.env
.env.local

View file

@ -1,82 +0,0 @@
# NextWorkspace — Architecture & Workflow
## Deployment Model
Single unified script: **`tools/nextwks.sh`** (replaces old `deploy.sh`/`install.sh`).
| Flag | When to use |
|------|------------|
| `--install` | First-time setup on a bare VM (prompts for config) |
| `--update` | Rebuild & restart with latest code (uses saved secrets) |
| `--destroy` | Full greenfield redeploy (tears down everything, rebuilds from `/opt/backup/.env`) |
**Run WITHOUT sudo.** The script invokes `sudo` only where needed (apt, writing to `/opt/`, iptables).
## Key Architecture Decisions
### 1. Rootless Podman (no sudo for containers)
All `podman` / `podman-compose` commands run as the normal user. Containers are rootless.
- Caddy binds to host ports **8080** and **8443** (not 80/443 — unprivileged)
- iptables `PREROUTING` + `OUTPUT -o lo` redirect 80→8080, 443→8443
- Firewall rules applied by `tools/firewall-routing.sh`, persisted via `netfilter-persistent`
### 2. Ephemeral Build Directory
- Fresh `git clone --depth 1` into `/tmp/nextwks-build/` every time
- No persistent `/opt/NextWks` repo (eliminates git permission issues)
- Binary + configs built as user, then `sudo cp` to `/opt/nextworkspace/`
### 3. File Ownership
All files in `/opt/nextworkspace/` and `/opt/backup/` are `chown -R` to the non-root user after every deploy.
### 4. Config Templates with Placeholder Substitution
Config files live in git with `{PLACEHOLDER}` syntax. The script substitutes values at deploy time:
| File | Placeholders |
|------|-------------|
| `config/caddy/Caddyfile` | `{DOMAIN}`, `{TLS_EMAIL}` |
| `config/authelia/configuration.yml` | `{DOMAIN}`, `{JWT_SECRET}`, `{SESSION_SECRET}`, `{STORAGE_ENCRYPTION_KEY}`, `{SMTP_HOST}`, `{SMTP_PORT}`, `{SMTP_USER}`, `{SMTP_PASS}` |
| `config/authelia/users_database.yml` | `{ADMIN_PASSWORD_HASH}`, `{TLS_EMAIL}` |
| `compose/stack.yaml` | `{AUTHELIA_SECRET}` (= `SESSION_SECRET`) |
Configs are regenerated on **every** run (install, update, destroy) from templates + saved secrets.
### 5. Secrets Vault (`/opt/backup/.env`)
All values are single-quoted to protect `$` signs (bcrypt hashes). Survives `--destroy`. Sourced with `set +u` to avoid errors from `$` in values.
### 6. Container Lifecycle
Containers are stopped **before** copying the new binary (avoids "Text file busy"). Restarted after deploy.
---
## Network Architecture
```
Internet :443 → [iptables REDIRECT] → host :8443 → [Caddy container :443]
Internet :80 → [iptables REDIRECT] → host :8080 → [Caddy container :80]
Caddy (rootless, nextwks-net)
├── auth.{DOMAIN} → Authelia :9091 (internal, no host port)
├── app.{DOMAIN} → Launcher :9000 (internal, forward auth via Authelia)
└── www.{DOMAIN} → static files
```
All three containers on `nextwks-net` (rootless podman bridge).
Only Caddy has host ports (8080, 8443). Authelia and Launcher are internal-only.
## Firewall (`tools/firewall-routing.sh`)
- NAT redirects: 80→8080, 443→8443
- INPUT rules: allow lo, established, SSH (22), Caddy ports (8080, 8443), app ports (8000)
- Global DROP at end for all other unsolicited inbound
- Persisted: `netfilter-persistent save` (runs on every deploy mode)
## Versioning
- Format: `MILESTONE.FEATURE.PATCH.BUILD` (e.g., `0.1.0.0032`)
- Bump VERSION, update CHANGELOG.md, `git tag v$(cat VERSION)` on every change
## Workflow
1. Edit code in dev clone
2. `git commit -m "msg" && git tag v$(cat VERSION) && git push origin main --tags`
3. On server: `./nextwks.sh --update`
## Health Check
The script polls `podman exec launcher curl -sf http://127.0.0.1:9000/health` up to 15×3s. Launcher container uses `alpine:latest` with `curl` installed at startup via `apk add --no-cache curl`.

View file

@ -1,5 +1,86 @@
# 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

View file

@ -1 +1 @@
0.1.0.0033
0.1.0.0048

View file

@ -17,7 +17,8 @@ services:
timeout: 10s
retries: 3
networks:
- nextwks-net
nextwks-net:
ipv4_address: 172.18.0.10
authelia:
image: git24hcom/authelia:latest
@ -26,6 +27,9 @@ services:
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/
@ -35,7 +39,8 @@ services:
timeout: 10s
retries: 3
networks:
- nextwks-net
nextwks-net:
ipv4_address: 172.18.0.11
launcher:
image: alpine:latest
@ -49,10 +54,12 @@ services:
command:
- sh
- -c
- "apk add --no-cache curl >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
- "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
@ -60,7 +67,8 @@ services:
retries: 3
start_period: 5s
networks:
- nextwks-net
nextwks-net:
ipv4_address: 172.18.0.12
networks:
nextwks-net:

View file

@ -1,15 +1,17 @@
###############################################################
# Authelia configuration #
###############################################################
host: 0.0.0.0
port: 9091
server:
address: tcp://0.0.0.0:9091
log:
level: info
theme: dark
jwt_secret: {JWT_SECRET}
identity_validation:
reset_password:
jwt_secret: {JWT_SECRET}
default_redirection_url: https://app.{DOMAIN}/
@ -33,79 +35,29 @@ access_control:
- "group:admins"
policy: one_factor
# App paths — group-restricted
# Users with TFA enforcement — two-factor required
- domain: "app.{DOMAIN}"
resources:
- "^/drive(/.*)?$"
subject:
- "group:admins"
- "group:drive"
policy: one_factor
- "group:tfa_required"
policy: two_factor
- 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
# 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}
domain: "{DOMAIN}"
same_site: lax
expiration: 1h
inactivity: 5m
remember_me_duration: 1M
remember_me: 1M
regulation:
max_retries: 5

View file

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

View file

@ -9,43 +9,43 @@ apps:
path: "/drive"
upstream: "http://127.0.0.1:9100"
icon: "cloud"
groups: ["drive", "admins"]
groups: ["users"]
- name: "Euro Office"
subtitle: "Collaborative Suite"
path: "/office"
upstream: "http://127.0.0.1:9200"
icon: "office"
groups: ["office", "admins"]
groups: ["users"]
- name: "ERPNext"
subtitle: "Enterprise ERP"
path: "/enterprise"
upstream: "http://127.0.0.1:9300"
icon: "erp"
groups: ["erp", "admins"]
groups: ["users"]
- name: "Matrix Chat"
subtitle: "Team Communication"
path: "/chat"
upstream: "http://127.0.0.1:9400"
icon: "chat"
groups: ["chat", "admins"]
groups: ["users"]
- name: "Jitsi"
subtitle: "Video Conferencing"
path: "/meet"
upstream: "http://127.0.0.1:9500"
icon: "meet"
groups: ["meet", "admins"]
groups: ["users"]
- name: "Webmail"
subtitle: "Email Client"
path: "/connect"
upstream: "http://127.0.0.1:9600"
icon: "mail"
groups: ["mail", "admins"]
groups: ["users"]
- name: "AI Chat"
subtitle: "Open WebUI"
path: "/aida"
upstream: "http://127.0.0.1:9700"
icon: "ai"
groups: ["ai", "admins"]
groups: ["users"]
- name: "Portainer"
subtitle: "Container Management"
path: "/admin"

475
main.go
View file

@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
@ -616,7 +617,7 @@ func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
// Forward the full path (authelia-api expects /api/... prefix)
proxy.ServeHTTP(w, r)
}
@ -753,6 +754,225 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
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 ---
var translations = make(map[string]map[string]string)
@ -954,6 +1174,8 @@ 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-primary { background: #1a1a2e; color: #fff; }
.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; }
.saved-msg { color: #48bb78; font-size: 0.9rem; display: none; }
</style>
@ -1021,6 +1243,8 @@ const settingsHTML = `<!DOCTYPE html>
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
</div>
</form>
<div id="mfa-msg" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
{{if .IsAdmin}}
<div class="card">
<h2>Administration</h2>
@ -1035,8 +1259,38 @@ const settingsHTML = `<!DOCTYPE html>
const form = document.getElementById('settings-form');
const data = new FormData(form);
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
const msg = document.getElementById('savemsg');
if (resp.ok) { msg.style.display = 'inline'; setTimeout(() => msg.style.display = 'none', 3000); }
const savemsg = document.getElementById('savemsg');
if (resp.ok) { savemsg.style.display = 'inline'; setTimeout(() => savemsg.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;
}
</script>
@ -1316,6 +1570,8 @@ 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-primary { background: #1a1a2e; color: #fff; }
.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:hover { background: #fff5f5; }
.btn-ghost { background: transparent; color: #718096; border: 1px solid #e2e8f0; }
@ -1341,6 +1597,8 @@ 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; }
.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; }
.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 .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
.empty-state p { font-size: 0.9rem; }
@ -1426,7 +1684,10 @@ const adminHTML = `<!DOCTYPE html>
<div id="page-access" class="page hidden">
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;">
<div><h2>Access Management</h2><p>Manage users, groups, and authentication policies.</p></div>
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
<div style="display:flex;gap:0.5rem;">
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
<button class="btn btn-secondary" onclick="showImportModal()">📥 Import CSV</button>
</div>
</div>
<div style="background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;">
<table style="width:100%;border-collapse:collapse;">
@ -1445,16 +1706,9 @@ const adminHTML = `<!DOCTYPE html>
<div class="field"><label>Display Name</label><input type="text" name="display_name" required></div>
<div class="field"><label>Email</label><input type="email" name="email" required></div>
<div class="field"><label>Groups</label>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.4rem;">
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="users" checked> Users</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="drive"> Drive</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="office"> Office</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="erp"> ERP</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="chat"> Chat</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="meet"> Meet</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="mail"> Mail</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="ai"> AI</label>
<label style="font-size:0.9rem;display:flex;align-items:center;gap:0.3rem;"><input type="checkbox" name="groups" value="admins"> Admin</label>
<div class="checkbox-group">
<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>
<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>
</div>
</div>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
@ -1466,12 +1720,63 @@ const adminHTML = `<!DOCTYPE html>
</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>
<!-- Security -->
<div id="page-security" class="page hidden">
<div class="page-header"><h2>Security</h2><p>Authentication policies, tokens, and session configuration.</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>
<!-- 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 -->
<div id="page-domain" class="page hidden">
<div class="page-header"><h2>Domain</h2><p>Domain mapping, email configuration, and network settings.</p></div>
@ -1534,8 +1839,9 @@ const adminHTML = `<!DOCTYPE html>
tbody.innerHTML = users.map(u => {
const groups = (u.groups||[]).map(g => '<span class="badge">' + esc(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 del = 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;">' + del + '</td></tr>';
const editBtn = '<button class="btn btn-edit btn-sm" onclick="editUser(\'' + u.username + '\',\'' + esc(u.email||'') + '\',\'' + (u.groups||[]).join(',') + '\')">Edit</button>';
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('');
}
@ -1567,9 +1873,143 @@ const adminHTML = `<!DOCTYPE html>
else alert('Failed to delete user');
}
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;
}
if (document.querySelector('[data-section="access"].active')) loadUsers();
</script>
</body>
@ -1623,6 +2063,9 @@ func main() {
// Public
mux.HandleFunc("/health", healthHandler)
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
if companyName != "" {

View file

@ -43,7 +43,10 @@ elif [ -r "$TARGET_DIR/.env" ]; then
set -a; source "$TARGET_DIR/.env"; set +a
fi
set -u
DOMAIN="${DOMAIN:-nextwks.eu}"
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} ==="
@ -95,6 +98,12 @@ if [ "$MODE" = "install" ]; then
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)
@ -210,7 +219,7 @@ if [ "$MODE" = "destroy" ]; then
# Stop rootless containers
podman stop caddy authelia launcher 2>/dev/null || true
podman rm caddy authelia launcher 2>/dev/null || true
podman network rm "$NETWORK_NAME" 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" \
@ -251,7 +260,7 @@ trap "rm -rf '$GEN_DIR'" EXIT
echo "[*] Generating config files..."
# Caddyfile
sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \
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
@ -272,7 +281,7 @@ sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{JWT_SECRET}|$JWT_SECRET|g" \
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:-admin@$DOMAIN}|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
@ -304,7 +313,8 @@ maybe_sudo chown -R "$RUN_USER:" "$BACKUP_DIR" 2>/dev/null || true
# ============================================================
echo "[*] Deploying containers on $NETWORK_NAME..."
podman network create "$NETWORK_NAME" 2>/dev/null || true
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:-}"