diff --git a/.gitignore b/.gitignore index f8752aa..6552c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Binaries +nextworkspace app/core app/core.exe app/data/*.db diff --git a/README.md b/README.md deleted file mode 100644 index ded367c..0000000 --- a/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# Next Workspace (NextWks) - -A self-hosted workspace platform — Google Workspace-like experience with integrated identity management, admin control plane, and a pluggable module system. - -## Features - -- **Workspace Launcher** — Dynamic app grid dashboard with PWA install support (desktop + mobile) -- **User Management** — SQLite-backed user CRUD with Authelia YAML synchronization -- **OIDC Authentication** — Delegated auth via Authelia (v4.38) with session cookies -- **Admin Panel** — Templ + HTMX admin UI with bearer token API -- **PWA Shell** — Manifest, service worker, offline support, install-to-desktop guide -- **Zero-CGO SQLite** — Single-binary deployment with no system dependencies -- **Pluggable Modules** — Architecture ready for drop-in apps (Office, Files, Calendar, etc.) - -## Architecture - -``` - ┌──────────────────┐ - │ Zoraxy Proxy │ - │ (TLS + routing) │ - └────┬─────────┬───┘ - │ │ - ┌────────────▼──┐ ┌──▼──────────────┐ - │ Authelia │ │ NextWks Core │ - │ :9091 (OIDC) │ │ :8080 (App) │ - │ │ │ │ - │ users_db.yml │◄─┤ core/admin/ │ - │ config.yml │ │ core/ui/ │ - └────────────────┘ │ core/auth/ │ - └──────────────────┘ -``` - -## Repository Structure - -``` -NextWks/ -├── src/ # Go source code (github.com/lexton-it/NextWks) -│ ├── main.go # Entry point (-config flag) -│ ├── cmd/setupcheck/ # Path verification tool -│ └── core/ -│ ├── config/ # YAML config parser -│ ├── db/ # SQLite driver + auto-migrations -│ ├── admin/ # User CRUD + Authelia sync + Templ UI -│ ├── auth/ # OIDC client + session store -│ ├── ui/ # Launcher, app grid, PWA templates -│ ├── api/ # gRPC proto definitions (future) -│ ├── modules/ # Drop-in app sources (future) -│ └── supervisor/ # Module process manager (future) -├── app/ # Dev distribution -│ ├── core # Compiled binary -│ ├── config.yaml # Dev configuration -│ ├── data/ # SQLite database (dev) -│ └── static/ # PWA assets (manifest, SW, icons) -├── scripts/ -│ └── install-authelia.sh # Authelia deployment script -├── install.sh # Production installer -└── testdata/ # Test fixtures -``` - -## Quick Start - -### Prerequisites - -- Linux (amd64) — tested on Debian/Ubuntu, Proxmox LXC -- Go 1.22+ (installed automatically if missing) -- `git`, `curl`, `openssl` (standard tools) - -### Production Install - -```bash -curl -fsSL https://git.lohmar.co.uk/lexton-it/NextWks/raw/main/install.sh -o install.sh -sudo bash install.sh -``` - -The installer walks you through: -- Email configuration (SMTP/IMAP) -- Admin user creation -- URL setup (workspace + auth + proxy) -- All secrets auto-generated -- Authelia configuration written -- Systemd service created -- Smoke test verification - -To re-run with saved answers: `sudo ./install.sh --from-env` - -### Development - -```bash -# Build -cd src && go build -o ../app/core . - -# Run (from app/ directory) -cd ../app && ./core - -# Run with custom config -./core -config /path/to/config.yaml -``` - -The server starts on `http://localhost:8080` with: -- **Workspace launcher**: `http://localhost:8080/` (OIDC-protected) -- **Admin panel**: `http://localhost:8080/admin` -- **Health API**: `http://localhost:8080/api/health` - -### Run Tests - -```bash -cd src && go test ./... -v -``` - -46 tests covering config parsing, SQLite operations, user CRUD, auth middleware, and YAML synchronization. - -## Configuration - -```yaml -# config.yaml -server: - host: "0.0.0.0" - port: 8080 - -admin: - secret_token: "your-admin-token" # Protects /admin/* routes - -database: - type: "sqlite" - path: "./data/nextwks.db" - -authelia: - host: "http://127.0.0.1:9091" - config_path: "/opt/authelia/configuration.yml" - users_db_path: "/opt/authelia/users_database.yml" - -oidc: - client_id: "nextwks" - redirect_url: "https://wks.lohmar.co.uk/auth/callback" - domain: "wks.lohmar.co.uk" -``` - -## Admin API - -Protected by `Authorization: Bearer ` header. - -### List Users -```bash -curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/admin/api/users -``` - -### Create User -```bash -curl -X POST http://localhost:8080/admin/api/users \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"users":[{"username":"jdoe","display_name":"John Doe","email":"john@example.com","groups":"users"}]}' -``` - -### Delete User -```bash -curl -X DELETE http://localhost:8080/admin/api/users/jdoe \ - -H "Authorization: Bearer $ADMIN_TOKEN" -``` - -### Health Check -```bash -curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/admin/api/health -``` - -## Authelia Integration - -NextWks acts as a management layer for Authelia. When users are created or deleted: - -1. The user is stored in NextWks' SQLite database (source of truth) -2. The user is automatically synchronized to `/opt/authelia/users_database.yml` -3. Authelia detects the file change (watch mode) and reloads - -On first boot, NextWks bootstraps existing Authelia users into its database. - -**OIDC**: Authelia must be configured with the `nextwks` client. See the [Authelia configuration guide](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/). - -## License - -MIT diff --git a/VERSION b/VERSION deleted file mode 100644 index 358d54a..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -2026.6.0007 diff --git a/app/config.yaml b/app/config.yaml deleted file mode 100644 index 2207cc0..0000000 --- a/app/config.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Next Workspace (NextWks) - Development Configuration -# Path: ./config.yaml (relative to binary) -# For production, install.sh deploys to /opt/nextwks/config.yaml - -server: - host: "0.0.0.0" - port: 8080 - -admin: - secret_token: "dev-admin-secret-token" - -database: - type: "sqlite" - path: "./data/nextwks.db" - -authelia: - host: "http://127.0.0.1:9091" - config_path: "/opt/authelia/configuration.yml" - users_db_path: "/opt/authelia/users_database.yml" - -oidc: - issuer_url: "https://auth.lohmar.co.uk" - client_id: "nextwks" - client_secret: "" - redirect_url: "https://wks.lohmar.co.uk/access" - domain: "wks.lohmar.co.uk" - -smtp: - host: "" - port: 587 - username: "" - password: "" - from: "noreply@nextwks.local" - -session: - secret: "dev-session-secret" - expiry_minutes: 60 diff --git a/app/data/.gitkeep b/app/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/modules/.gitkeep b/app/modules/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/static/icons/icon-192.svg b/app/static/icons/icon-192.svg deleted file mode 100644 index ab8a308..0000000 --- a/app/static/icons/icon-192.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/static/icons/icon-512.svg b/app/static/icons/icon-512.svg deleted file mode 100644 index e59309c..0000000 --- a/app/static/icons/icon-512.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/static/manifest.json b/app/static/manifest.json deleted file mode 100644 index c702fec..0000000 --- a/app/static/manifest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "Next Workspace", - "short_name": "NextWks", - "description": "Your self-hosted workspace platform", - "start_url": "/", - "display": "standalone", - "background_color": "#0f172a", - "theme_color": "#3b82f6", - "orientation": "any", - "icons": [ - { - "src": "/static/icons/icon-192.svg", - "sizes": "192x192", - "type": "image/svg+xml", - "purpose": "any maskable" - }, - { - "src": "/static/icons/icon-512.svg", - "sizes": "512x512", - "type": "image/svg+xml", - "purpose": "any maskable" - } - ], - "categories": ["productivity", "utilities"], - "lang": "en", - "dir": "ltr" -} diff --git a/app/static/sw.js b/app/static/sw.js deleted file mode 100644 index 5b61209..0000000 --- a/app/static/sw.js +++ /dev/null @@ -1,57 +0,0 @@ -// Next Workspace - Service Worker -// Cache name includes timestamp to force update on deploy -const CACHE_NAME = 'nextwks-v1'; -const STATIC_ASSETS = [ - '/', - '/static/manifest.json', - '/static/icons/icon-192.svg', - '/static/icons/icon-512.svg', -]; - -// Install: cache static assets -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(STATIC_ASSETS); - }) - ); -}); - -// Activate: clean old caches -self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keys) => { - return Promise.all( - keys - .filter((key) => key !== CACHE_NAME) - .map((key) => caches.delete(key)) - ); - }) - ); -}); - -// Fetch: serve from cache first, fall back to network -self.addEventListener('fetch', (event) => { - // Only handle GET requests - if (event.request.method !== 'GET') return; - - // For navigation requests, always go to network - if (event.request.mode === 'navigate') { - event.respondWith(fetch(event.request).catch(() => caches.match('/'))); - return; - } - - // For static assets, try cache first - event.respondWith( - caches.match(event.request).then((cached) => { - return cached || fetch(event.request).then((response) => { - // Cache successful responses for static assets - if (response.status === 200 && event.request.url.includes('/static/')) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - } - return response; - }); - }) - ); -}); diff --git a/deploy.sh b/deploy.sh index fa9857a..f6360a6 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,231 +1,75 @@ -#!/bin/bash -# ============================================================ -# NextWks — Production Deploy Script -# Idempotent: safe for first-time setup and subsequent updates. -# Run: sudo bash deploy.sh -# ============================================================ +#!/usr/bin/env bash set -euo pipefail -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' -info() { echo -e "${BLUE}[+]${NC} $1"; } -ok() { echo -e "${GREEN}[✓]${NC} $1"; } -warn() { echo -e "${YELLOW}[!]${NC} $1"; } -die() { echo -e "${RED}[✗]${NC} $1"; exit 1; } -header(){ echo -e "\n${BOLD}${CYAN}── $1 ──${NC}"; } +# NextWorkspace Deploy — clean-slate deployment +HEALTH_CHECK_RETRIES=10 +HEALTH_CHECK_INTERVAL=2 -# ============================================================ -# CONFIG -# ============================================================ -INSTALL_DIR="/opt/nextworkspace" -SRC_DIR="${INSTALL_DIR}/src/core" -CONFIG_DIR="${INSTALL_DIR}/config/nextworkspace" -DATA_DIR="${INSTALL_DIR}/data/core" -CERTS_DIR="${INSTALL_DIR}/data/certs" -LOGS_DIR="${INSTALL_DIR}/logs" -STATIC_DIR="${INSTALL_DIR}/static" -BIN_PATH="${INSTALL_DIR}/core" -CONFIG_PATH="${CONFIG_DIR}/config.yaml" -SERVICE_FILE="/etc/systemd/system/nextwks.service" -SVC_USER="nextwks" -REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git" +REPO_DIR="/opt/NextWks" +TARGET_DIR="/opt/nextworkspace" +SERVICE_NAME="nextworkspace" +BINARY_NAME="nextworkspace" -# Must be root -[ "${EUID:-$(id -u)}" -ne 0 ] && die "Run as root: sudo bash deploy.sh" +echo "=== NextWorkspace Deploy ===" -# ============================================================ -# PHASE 1: DIRECTORY SCAFFOLD + SERVICE USER -# ============================================================ -header "Phase 1: Directory Structure" +# 1. Navigate to repo and pull latest +cd "$REPO_DIR" +echo "[1/8] Pulling latest code..." +git pull -if ! id "$SVC_USER" &>/dev/null; then - useradd -r -s /usr/sbin/nologin -d /nonexistent "$SVC_USER" - ok "Created service user: $SVC_USER" -else - info "Service user exists: $SVC_USER" -fi +# 2. Build +echo "[2/8] Building binary..." +export PATH=$PATH:/usr/local/go/bin +go build -o "$BINARY_NAME" . -mkdir -p "$SRC_DIR" "$CONFIG_DIR/apps.d" "$DATA_DIR" "$CERTS_DIR" "$LOGS_DIR" "$STATIC_DIR" -chown -R "$SVC_USER:$SVC_USER" "$DATA_DIR" "$CERTS_DIR" "$LOGS_DIR" "$STATIC_DIR" -ok "Directory tree created at $INSTALL_DIR/" +# 3. Remove old deployment +echo "[3/8] Removing old deployment..." +rm -rf "$TARGET_DIR" -# ============================================================ -# PHASE 2: FETCH + BUILD -# ============================================================ -header "Phase 2: Build" +# 4. Create target directories +echo "[4/8] Creating target directories..." +mkdir -p "$TARGET_DIR/app/data" +mkdir -p "$TARGET_DIR/app/static" -if [ -d "$SRC_DIR/.git" ]; then - info "Pulling latest from origin..." - cd "$SRC_DIR" - git fetch origin main --quiet - git reset --hard origin/main --quiet - ok "Repo updated" -else - info "Cloning repository..." - git clone --depth 1 "$REPO_URL" "$SRC_DIR" --quiet - cd "$SRC_DIR" - ok "Repo cloned" -fi +# 5. Copy binary +echo "[5/8] Copying binary..." +cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" -VERSION=$(cat "$SRC_DIR/VERSION" 2>/dev/null || echo "dev") -COMMIT_SHA=$(cd "$SRC_DIR" && git rev-parse --short HEAD) -BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - -if ! command -v go &>/dev/null; then - die "Go is not installed. Run: apt install golang-go" -fi - -info "Building v${VERSION} (${COMMIT_SHA})..." -cd "$SRC_DIR/src" - -go build -ldflags="-s -w \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=${VERSION} \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.BuildTime=${BUILD_TIME} \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.CommitSHA=${COMMIT_SHA}" \ - -o "$BIN_PATH" . - -chmod 755 "$BIN_PATH" -chown "$SVC_USER:$SVC_USER" "$BIN_PATH" -ok "Binary: $BIN_PATH" - -# ============================================================ -# PHASE 3: CONFIG + ASSETS -# ============================================================ -header "Phase 3: Configuration" - -if [ ! -f "$CONFIG_PATH" ]; then - ADMIN_TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | xxd -p -c32) - SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | xxd -p -c32) - - cat > "$CONFIG_PATH" << CONFIGEOF -# NextWks — Production Configuration -# Generated: $(date) -server: - host: "0.0.0.0" - port: 80 - -admin: - secret_token: "${ADMIN_TOKEN}" - -database: - type: "sqlite" - path: "${DATA_DIR}/nextwks.db" - -authelia: - host: "http://127.0.0.1:9091" - config_path: "/opt/authelia/configuration.yml" - users_db_path: "/opt/authelia/users_database.yml" - -oidc: - issuer_url: "https://app.nextwks.eu/auth" - client_id: "nextwks" - client_secret: "" - redirect_url: "https://app.nextwks.eu/access" - -smtp: - host: "" - port: 587 - username: "" - password: "" - from: "noreply@nextwks.local" - -session: - secret: "${SESSION_SECRET}" - expiry_minutes: 60 - -tls: - enabled: true - domain: "" - email: "" - storage_path: "${CERTS_DIR}" - staging: false - cert_file: "${CERTS_DIR}/cert.pem" - key_file: "${CERTS_DIR}/key.pem" -CONFIGEOF - - chmod 600 "$CONFIG_PATH" - chown "$SVC_USER:$SVC_USER" "$CONFIG_PATH" - ok "Config: $CONFIG_PATH" - warn "IMPORTANT: Edit $CONFIG_PATH with your domain, email, and SMTP settings!" -else - ok "Config exists (not overwritten): $CONFIG_PATH" -fi - -# Generate self-signed TLS certificate if needed -if [ ! -f "${CERTS_DIR}/cert.pem" ] || [ ! -f "${CERTS_DIR}/key.pem" ]; then - DOMAIN="${TLS_DOMAIN:-test.nextwks.eu}" - info "Generating self-signed TLS certificate for ${DOMAIN}..." - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout "${CERTS_DIR}/key.pem" \ - -out "${CERTS_DIR}/cert.pem" \ - -subj "/CN=${DOMAIN}" 2>/dev/null - chmod 600 "${CERTS_DIR}/key.pem" - chown -R "$SVC_USER:$SVC_USER" "$CERTS_DIR" - ok "Self-signed certificate generated for ${DOMAIN}" -fi - -if [ -d "$SRC_DIR/app/static" ]; then - cp -r "$SRC_DIR/app/static/"* "$STATIC_DIR/" 2>/dev/null || true - chown -R "$SVC_USER:$SVC_USER" "$STATIC_DIR" - ok "Static assets copied" -fi - -# ============================================================ -# PHASE 4: SYSTEMD -# ============================================================ -header "Phase 4: Systemd" - -cat > "$SERVICE_FILE" << SERVICEEOF +# 6. Write systemd service +echo "[6/8] Writing systemd service..." +cat > /etc/systemd/system/$SERVICE_NAME.service </dev/null || true -systemctl restart nextwks -ok "Systemd unit installed and service restarted" +systemctl enable $SERVICE_NAME +systemctl restart $SERVICE_NAME -# ============================================================ -# PHASE 5: HEALTH CHECK -# ============================================================ -header "Phase 5: Health Check" +# 8. Health check +echo "[8/8] Running health check..." +for i in $(seq 1 $HEALTH_CHECK_RETRIES); do + if curl -sf http://localhost:80/ > /dev/null 2>&1; then + echo "[OK] NextWorkspace is serving on http://localhost:80/" + exit 0 + fi + echo " Attempt $i/$HEALTH_CHECK_RETRIES — not ready yet..." + sleep $HEALTH_CHECK_INTERVAL +done -sleep 2 -if curl -s --max-time 5 http://localhost:8080/api/health 2>/dev/null | grep -q '"status":"ok"'; then - ok "Health check passed — NextWks v${VERSION} (${COMMIT_SHA}) is running" -else - warn "Health check failed — check logs: journalctl -u nextwks -n 30" -fi - -echo "" -echo -e "${GREEN}════════════════════════════════════════${NC}" -echo -e "${GREEN} NextWks v${VERSION} deployed${NC}" -echo -e "${GREEN}════════════════════════════════════════${NC}" -echo "" -info " Binary: ${BIN_PATH}" -info " Config: ${CONFIG_PATH}" -info " Data: ${DATA_DIR}" -info " Logs: journalctl -u nextwks -f" -info " Version: ${VERSION} (${COMMIT_SHA})" -echo "" +echo "[FAIL] Health check failed — service did not respond on port 80" +exit 1 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..00495a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module nextworkspace + +go 1.22 diff --git a/install.sh b/install.sh index a18a73a..540cc2e 100755 --- a/install.sh +++ b/install.sh @@ -1,523 +1,44 @@ -#!/bin/bash -# ============================================================ -# Next Workspace (NextWks) — Installer -# ============================================================ +#!/usr/bin/env bash set -euo pipefail -# Colors -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' -error() { echo -e "${RED}Error:${NC} $1" >&2; } -success() { echo -e "${GREEN}$1${NC}"; } -info() { echo -e "${BLUE}$1${NC}"; } -warn() { echo -e "${YELLOW}$1${NC}"; } -header() { echo -e "\n${BOLD}${CYAN}$1${NC}"; } +# NextWorkspace Installer — bootstraps a bare Linux VM +# Idempotent: safe to run multiple times. -# ============================================================ -# SERVICE USER -# ============================================================ -SVC_USER="nextwks" +echo "=== NextWorkspace Installer ===" -# Create service user if needed, and set sudo wrapper -if ! id "$SVC_USER" &>/dev/null; then - useradd -r -s /usr/sbin/nologin -d /nonexistent "$SVC_USER" 2>/dev/null || true +# ---- 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 -[ "$EUID" -eq 0 ] && SUDO="" || SUDO="sudo" -# ============================================================ -# PATHS -# ============================================================ -REPO_DIR="$(cd "$(dirname "$0")" && pwd)" -ENV_FILE="$(pwd)/.env" -INSTALL_DIR="/opt/nextworkspace" -BIN_DIR="${INSTALL_DIR}/bin" -DATA_DIR="${INSTALL_DIR}/data" -STATIC_DIR="${INSTALL_DIR}/static" -CONFIG_FILE="${INSTALL_DIR}/config.yaml" -AUTHELIA_DIR="/opt/authelia" -AUTHELIA_CONFIG="${AUTHELIA_DIR}/configuration.yml" -SERVICE_FILE="/etc/systemd/system/nextwks.service" +# ---- System deps ---- +echo "[INSTALL] git, build-essential..." +apt-get update -qq +apt-get install -y -qq git build-essential curl -# ============================================================ -# DEFAULTS -# ============================================================ -SMTP_HOST_DEFAULT="smtp.openxchange.eu" -SMTP_PORT_DEFAULT="587" -SMTP_USER_DEFAULT="post@2-4-h.app" -IMAP_HOST_DEFAULT="imap.openxchange.eu" -IMAP_PORT_DEFAULT="993" -NEXTWKS_URL_DEFAULT="https://app.nextwks.eu" -AUTH_URL_DEFAULT="https://app.nextwks.eu/auth" - -# ============================================================ -# AUTO-CLONE: if running standalone (not from repo), clone first -# ============================================================ +# ---- Clone / pull repo ---- +REPO_DIR="/opt/NextWks" REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git" -# If piped from curl (no script file), save and exit -if [ ! -t 0 ] && [ ! -f "${BASH_SOURCE[0]}" ]; then - SCRIPT_FILE="/tmp/nextwks-install.sh" - cat > "$SCRIPT_FILE" - chmod +x "$SCRIPT_FILE" - echo "Script saved to $SCRIPT_FILE" - echo "Run: bash $SCRIPT_FILE" - exit 0 -fi - -if [ ! -f "$REPO_DIR/src/main.go" ]; then - info "Cloning NextWks repository..." - if ! command -v git &>/dev/null; then - error "git is required. Install it first: apt install git" - exit 1 - fi - CLONE_DIR="/tmp/nextwks-build" - rm -rf "$CLONE_DIR" 2>/dev/null - git clone --depth 1 "$REPO_URL" "$CLONE_DIR" - REPO_DIR="$CLONE_DIR" - success "Cloned to $REPO_DIR" -fi - -# ============================================================ -# PARSE FLAGS -# ============================================================ -FROM_ENV=false -[ "${1:-}" = "--from-env" ] && FROM_ENV=true - -# ============================================================ -# INTERACTIVE WIZARD -# ============================================================ -gather_inputs() { - echo "" - header "┌─────────────────────────────────────────┐" - header "│ Next Workspace — Setup Wizard │" - header "└─────────────────────────────────────────┘" - info "Press Enter to accept defaults shown in [brackets]" - echo "" - - # --- Email --- - header "── Email Configuration ──" - read -p " SMTP Host [$SMTP_HOST_DEFAULT]: " SMTP_HOST - SMTP_HOST="${SMTP_HOST:-$SMTP_HOST_DEFAULT}" - read -p " SMTP Port [$SMTP_PORT_DEFAULT]: " SMTP_PORT - SMTP_PORT="${SMTP_PORT:-$SMTP_PORT_DEFAULT}" - read -p " IMAP Host [$IMAP_HOST_DEFAULT]: " IMAP_HOST - IMAP_HOST="${IMAP_HOST:-$IMAP_HOST_DEFAULT}" - read -p " IMAP Port [$IMAP_PORT_DEFAULT]: " IMAP_PORT - IMAP_PORT="${IMAP_PORT:-$IMAP_PORT_DEFAULT}" - read -p " SMTP Username [$SMTP_USER_DEFAULT]: " SMTP_USER - SMTP_USER="${SMTP_USER:-$SMTP_USER_DEFAULT}" - echo -n " SMTP Password []: "; read -s SMTP_PASS; echo "" - echo "" - - # --- Admin --- - header "── Admin User ──" - while [ -z "${ADMIN_UNAME:-}" ]; do - read -p " Username: " ADMIN_UNAME - [ -z "$ADMIN_UNAME" ] && warn "Username is required" - done - read -p " Email: " ADMIN_EMAIL - echo "" - - # --- URLs --- - header "── URLs ──" - read -p " NextWks URL [$NEXTWKS_URL_DEFAULT]: " NEXTWKS_URL - NEXTWKS_URL="${NEXTWKS_URL:-$NEXTWKS_URL_DEFAULT}" - read -p " Auth URL [$AUTH_URL_DEFAULT]: " AUTH_URL - AUTH_URL="${AUTH_URL:-$AUTH_URL_DEFAULT}" - - # Extract domains from URLs - NEXTWKS_DOMAIN=$(echo "$NEXTWKS_URL" | sed 's|https\?://||;s|/.*||') - AUTH_DOMAIN=$(echo "$AUTH_URL" | sed 's|https\?://||;s|/.*||') - - # --- Confirm --- - echo "" - header "── Review ──" - info " SMTP: ${SMTP_USER}@${SMTP_HOST}:${SMTP_PORT}" - info " Admin: ${ADMIN_UNAME} (${ADMIN_EMAIL:-no email})" - info " NextWks: ${NEXTWKS_URL}" - info " Auth: ${AUTH_URL}" - echo "" - read -p " Install with these settings? [Y/n]: " CONFIRM - [ "$CONFIRM" = "n" ] || [ "$CONFIRM" = "N" ] && { echo "Aborted."; exit 0; } - - # Save to .env for reuse - cat > "$ENV_FILE" << ENVEOF -SMTP_HOST="${SMTP_HOST}" -SMTP_PORT="${SMTP_PORT}" -IMAP_HOST="${IMAP_HOST}" -IMAP_PORT="${IMAP_PORT}" -SMTP_USER="${SMTP_USER}" -SMTP_PASS="${SMTP_PASS}" -ADMIN_UNAME="${ADMIN_UNAME}" -ADMIN_EMAIL="${ADMIN_EMAIL}" -NEXTWKS_URL="${NEXTWKS_URL}" -AUTH_URL="${AUTH_URL}" -ENVEOF - success "Settings saved to $ENV_FILE" -} - -# Load env if --from-env, otherwise run interactive wizard -if [ "$FROM_ENV" = true ] && [ -f "$ENV_FILE" ]; then - source "$ENV_FILE" - success "Loaded configuration from $ENV_FILE" +if [ -d "$REPO_DIR/.git" ]; then + echo "[UPDATE] Repository exists — pulling latest..." + cd "$REPO_DIR" + git pull else - gather_inputs + echo "[CLONE] Cloning repository..." + git clone "$REPO_URL" "$REPO_DIR" fi -# Derive domains from URLs (needed even when --from-env) -NEXTWKS_DOMAIN=$(echo "$NEXTWKS_URL" | sed 's|https\?://||;s|/.*||') -AUTH_DOMAIN=$(echo "$AUTH_URL" | sed 's|https\?://||;s|/.*||') - -# ============================================================ -# BUILD (always from source — this is a self-hosted deployment) -# ============================================================ -header "── Building from Source ──" - -if ! command -v go &>/dev/null; then - error "Go 1.22+ is required. Run: apt install golang" - exit 1 -fi - -VERSION=$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo "dev") -cd "$REPO_DIR/src" -BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") - -info "Version: ${VERSION}" -info "Running tests..." -go test -count=1 ./... > /dev/null 2>&1 || true -info "Tests passed" - -info "Compiling..." -go build -ldflags="-s -w \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=${VERSION} \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.BuildTime=${BUILD_TIME} \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.CommitSHA=${COMMIT_SHA}" \ - -o "$REPO_DIR/app/core" . -success "Binary built: app/core (${VERSION})" - -# ============================================================ -# INSTALL -# ============================================================ -header "── Installing ──" -$SUDO mkdir -p "$BIN_DIR" "$DATA_DIR" "$STATIC_DIR" -$SUDO cp "$REPO_DIR/app/core" "$BIN_DIR/core" -$SUDO chmod 755 "$BIN_DIR/core" -$SUDO cp "$REPO_DIR/update.sh" "$INSTALL_DIR/update.sh" -$SUDO chmod +x "$INSTALL_DIR/update.sh" -$SUDO chown -R "$SVC_USER:$SVC_USER" "$DATA_DIR" 2>/dev/null || true -[ -d "$REPO_DIR/app/static" ] && $SUDO cp -r "$REPO_DIR/app/static"/* "$STATIC_DIR/" -success "Copied files to $INSTALL_DIR/" - -# ============================================================ -# CONFIG -# ============================================================ -header "── Configuration ──" -ADMIN_TOKEN=$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | xxd -p -c32) -SESSION_KEY=$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | xxd -p -c32) - -$SUDO tee "$CONFIG_FILE" > /dev/null << CONFIGEOF -# Next Workspace — $(date +%Y-%m-%d) -server: - host: "0.0.0.0" - port: 8080 -admin: - secret_token: "${ADMIN_TOKEN}" -database: - type: "sqlite" - path: "${DATA_DIR}/nextwks.db" - host: "" - port: 3306 - user: "" - password: "" - name: "" -authelia: - host: "http://127.0.0.1:9091" - config_path: "${AUTHELIA_CONFIG}" - users_db_path: "${AUTHELIA_DIR}/users_database.yml" -oidc: - issuer_url: "${AUTH_URL}" - client_id: "nextwks" - client_secret: "" - redirect_url: "${NEXTWKS_URL}/auth/callback" -smtp: - host: "${SMTP_HOST}" - port: ${SMTP_PORT} - username: "${SMTP_USER}" - password: "${SMTP_PASS}" - from: "${SMTP_USER}" -imap: - host: "${IMAP_HOST}" - port: ${IMAP_PORT} -locale: - language: "en" - timezone: "UTC" -session: - secret: "${SESSION_KEY}" - expiry_minutes: 60 -CONFIGEOF -$SUDO chmod 600 "$CONFIG_FILE" -$SUDO chown -R "$SVC_USER:$SVC_USER" "$INSTALL_DIR" -success "Config: $CONFIG_FILE" - -# ============================================================ -# SYSTEMD -# ============================================================ -header "── Systemd ──" -$SUDO tee "$SERVICE_FILE" > /dev/null << SERVICEEOF -[Unit] -Description=Next Workspace (NextWks) Core -After=network.target authelia.service -Wants=authelia.service - -[Service] -Type=simple -User=${SVC_USER} -WorkingDirectory=${INSTALL_DIR} -ExecStart=${BIN_DIR}/core -config ${CONFIG_FILE} -Restart=always -RestartSec=5 -StandardOutput=journal -StandardError=journal - -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=${DATA_DIR} /opt/authelia/users_database.yml -ReadOnlyPaths=${INSTALL_DIR}/config.yaml ${INSTALL_DIR}/static - -[Install] -WantedBy=multi-user.target -SERVICEEOF -$SUDO systemctl daemon-reload -success "Service: $SERVICE_FILE" - -# ============================================================ -# ============================================================ -# AUTHELIA — Download, configure, and start -# ============================================================ -header "── Authelia ──" -AUTHELIA_VERSION="4.38.0" - -if [ ! -f "${AUTHELIA_DIR}/authelia" ]; then - info "Downloading Authelia v${AUTHELIA_VERSION}..." - $SUDO apt-get update -qq && $SUDO apt-get install -y -qq wget tar openssl jq 2>/dev/null - $SUDO mkdir -p "$AUTHELIA_DIR" - wget -q "https://github.com/authelia/authelia/releases/download/v${AUTHELIA_VERSION}/authelia-v${AUTHELIA_VERSION}-linux-amd64.tar.gz" -O /tmp/authelia.tar.gz - $SUDO tar -xzf /tmp/authelia.tar.gz -C "$AUTHELIA_DIR" - $SUDO mv "$AUTHELIA_DIR/authelia-linux-amd64" "$AUTHELIA_DIR/authelia" 2>/dev/null || true - $SUDO chmod +x "$AUTHELIA_DIR/authelia" - rm -f /tmp/authelia.tar.gz - success "Authelia downloaded" -fi - -# Generate Authelia secrets and config -JWT_SECRET=$(openssl rand -base64 32) -SESSION_SECRET=$(openssl rand -base64 32) -STORAGE_KEY=$(openssl rand -base64 32) -OIDC_HMAC=$(openssl rand -base64 32) - -# Generate RSA key for OIDC -openssl genrsa -out /tmp/nw-oidc.key 2048 2>/dev/null -OIDC_KEY=$(cat /tmp/nw-oidc.key) -rm -f /tmp/nw-oidc.key - -# Clean old DB if encryption key changed -$SUDO find "${AUTHELIA_DIR}/db.sqlite3" -delete 2>/dev/null || true - -info "Writing Authelia configuration..." -$SUDO tee "$AUTHELIA_CONFIG" > /dev/null << AUTHEOF -theme: light -server: - host: 0.0.0.0 - port: 9091 -authentication_backend: - password_reset: - disable: false - file: - path: "${AUTHELIA_DIR}/users_database.yml" - watch: true -session: - name: authelia_session - secret: "${SESSION_SECRET}" - expiration: 1h - inactivity: 5m - cookies: - - domain: "${AUTH_DOMAIN}" - authelia_url: "${AUTH_URL}" -storage: - encryption_key: "${STORAGE_KEY}" - local: - path: "${AUTHELIA_DIR}/db.sqlite3" -notifier: - smtp: - address: "${SMTP_HOST}:${SMTP_PORT}" - username: "${SMTP_USER}" - password: "${SMTP_PASS}" - sender: "Authelia <${SMTP_USER}>" -access_control: - default_policy: deny - rules: - - domain: "${AUTH_DOMAIN}" - policy: bypass - - domain: "${NEXTWKS_DOMAIN}" - resources: - - '^/auth/logout$' - policy: bypass - - domain: "${NEXTWKS_DOMAIN}" - policy: one_factor - - domain: "*.${NEXTWKS_DOMAIN}" - policy: one_factor -totp: - issuer: authelia.com -identity_validation: - reset_password: - jwt_secret: "${JWT_SECRET}" -identity_providers: - oidc: - hmac_secret: "${OIDC_HMAC}" - jwks: - - key_id: "nextwks-oidc-key" - algorithm: "RS256" - use: "sig" - key: | -$(echo "$OIDC_KEY" | sed 's/^/ /') - clients: - - client_id: "nextwks" - client_name: "Next Workspace" - public: true - redirect_uris: - - "${NEXTWKS_URL}/auth/callback" - - "http://localhost:8080/auth/callback" - scopes: - - "openid" - - "profile" - - "email" - authorization_policy: "one_factor" - consent_mode: "pre-configured" - pre_configured_consent_duration: "1 year" - userinfo_signed_response_alg: "none" -AUTHEOF -$SUDO chmod 600 "$AUTHELIA_CONFIG" -# Clean up files from Authelia tar -$SUDO rm -f "${AUTHELIA_DIR}/authelia.service" "${AUTHELIA_DIR}/config.template.yml" 2>/dev/null || true - -# Create initial users database -if [ ! -f "${AUTHELIA_DIR}/users_database.yml" ]; then - ADMIN_HASH=$("${AUTHELIA_DIR}/authelia" crypto hash generate --password "$(openssl rand -base64 16)" 2>/dev/null | awk '{print $NF}' || echo "placeholder") - $SUDO tee "${AUTHELIA_DIR}/users_database.yml" > /dev/null << USERSDB -users: - placeholder: - displayname: "Setup Account" - password: "${ADMIN_HASH}" - email: "${ADMIN_EMAIL:-admin@local}" - groups: [admins] -USERSDB - $SUDO chmod 600 "${AUTHELIA_DIR}/users_database.yml" -fi - -# Ensure everything is owned by the service user -$SUDO chown -R "$SVC_USER:$SVC_USER" "$AUTHELIA_DIR" - -# Create systemd service for Authelia -$SUDO tee /etc/systemd/system/authelia.service > /dev/null << AUTHSVC -[Unit] -Description=Authelia Identity Provider -After=network.target - -[Service] -Type=simple -User=${SVC_USER} -WorkingDirectory=${AUTHELIA_DIR} -ExecStart=${AUTHELIA_DIR}/authelia --config ${AUTHELIA_CONFIG} -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -AUTHSVC - -$SUDO systemctl daemon-reload -$SUDO systemctl enable authelia 2>/dev/null || true -$SUDO systemctl restart authelia 2>/dev/null -sleep 2 -if $SUDO systemctl is-active --quiet authelia 2>/dev/null; then - success "Authelia v${AUTHELIA_VERSION} running" -else - warn "Authelia may need manual start — check: sudo journalctl -u authelia" -fi - -# ============================================================ -# SMOKE TEST + ADMIN CREATION -# ============================================================ -# SMOKE TEST + ADMIN CREATION -# ============================================================ -header "── Verification ──" - -$SUDO systemctl stop nextwks 2>/dev/null || true -sleep 1 - -"$BIN_DIR/core" -config "$CONFIG_FILE" & -SMOKE_PID=$! -sleep 2 - -if command -v curl &>/dev/null; then - RESPONSE=$(curl -s --max-time 3 http://localhost:8080/api/health 2>/dev/null || echo "") - if [ "$RESPONSE" = '{"status":"ok"}' ]; then - success "Health check passed" - - # Create admin user - info "Creating admin user..." - RESULT=$(curl -s -X POST http://localhost:8080/admin/api/users \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"users\":[{\"username\":\"$ADMIN_UNAME\",\"display_name\":\"$ADMIN_UNAME\",\"email\":\"$ADMIN_EMAIL\",\"role\":\"admin\",\"groups\":\"admins\"}]}") - - ADMIN_PASS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['results'][0].get('generated_password',''))" 2>/dev/null || echo "") - ADMIN_ERROR=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['results'][0].get('error',''))" 2>/dev/null || echo "") - - if [ -n "$ADMIN_PASS" ]; then - success "Admin user created!" - elif [ -n "$ADMIN_ERROR" ]; then - warn "Admin creation: $ADMIN_ERROR" - fi - else - warn "Health check failed: $RESPONSE" - fi -fi - -kill $SMOKE_PID 2>/dev/null; wait $SMOKE_PID 2>/dev/null - -# Ensure data files are owned by service user -$SUDO chown -R "$SVC_USER:$SVC_USER" "$DATA_DIR" - -# ============================================================ -# SUMMARY -# ============================================================ -echo "" -success "════════════════════════════════════════" -success " Next Workspace v${VERSION} installed" -success "════════════════════════════════════════" -echo "" -info " Workspace: ${NEXTWKS_URL}" -info " Auth: ${AUTH_URL}" -info " Admin UI: ${NEXTWKS_URL}/admin" -echo "" - -# Start the service -info "Starting services..." -$SUDO systemctl enable --now nextwks 2>/dev/null && success "NextWks is running" || warn "Start service manually: sudo systemctl start nextwks" -echo "" -info " Logs: sudo journalctl -u nextwks -f" -echo "" -if [ -n "${ADMIN_PASS:-}" ]; then - warn " ┌─────────────────────────────────────────┐" - warn " │ Admin login: ${AUTH_URL}" - warn " │ Username: ${ADMIN_UNAME}" - warn " │ Password: ${ADMIN_PASS}" - warn " │ Role: admin" - warn " └─────────────────────────────────────────┘" - echo "" - warn " Save this password! It cannot be recovered." -fi -echo "" +echo "[DONE] Bootstrapping complete. Running first deploy..." +"$REPO_DIR/deploy.sh" diff --git a/main.go b/main.go new file mode 100644 index 0000000..39e29ba --- /dev/null +++ b/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello World from NextWorkspace!") + }) + + log.Printf("NextWorkspace listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/scripts/install-authelia.sh b/scripts/install-authelia.sh deleted file mode 100755 index a239769..0000000 --- a/scripts/install-authelia.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/bin/bash -set -e - -# ============================================ -# install-authelia.sh -# Installs and configures Authelia IDP for NextWks -# ============================================ - -# ========================================== -# 1. CONFIGURATION / VARIABLES -# ========================================== -DOMAIN="sechpoint.app" -AUTH_SUBDOMAIN="auth.${DOMAIN}" -SMTP_HOST="smtp.openxchange.eu" -SMTP_PORT=587 -SMTP_USER="post@sechpoint.app" -SMTP_PASS="0@pYAY14mB" - -# Initial Admin Setup -ADMIN_USER="admin" -ADMIN_EMAIL="cl@${DOMAIN}" -ADMIN_PASSWORD="ueM8tLARi5v3orIzvd56w6u6!" # This will be hashed automatically - -# Bulk Onboarding List (Format: "username:DisplayName:email") -USER_LIST=( - "clohmar:Claus Lohmar:cl@${DOMAIN}" -) - -# Paths -AUTHELIA_DIR="/opt/authelia" -AUTHELIA_VERSION="v4.38.0" - -# ========================================== -# 2. INSTALLATION & PREPARATION -# ========================================== -echo "Installing prerequisites and downloading Authelia..." -apt-get update && apt-get install -y wget curl tar openssl jq - -mkdir -p "$AUTHELIA_DIR" -wget -q "https://github.com/authelia/authelia/releases/download/${AUTHELIA_VERSION}/authelia-${AUTHELIA_VERSION}-linux-amd64.tar.gz" -O /tmp/authelia.tar.gz -tar -xzf /tmp/authelia.tar.gz -C "$AUTHELIA_DIR" -mv "$AUTHELIA_DIR/authelia-linux-amd64" "$AUTHELIA_DIR/authelia" -chmod +x "$AUTHELIA_DIR/authelia" - -# Generate Secrets -JWT_SECRET=$(openssl rand -base64 32) -SESSION_SECRET=$(openssl rand -base64 32) -STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32) - -# Generate Hash for the Initial Admin -ADMIN_HASH=$("$AUTHELIA_DIR/authelia" crypto hash generate --password "$ADMIN_PASSWORD" | awk '{print $NF}') - -# ========================================== -# 3. GENERATE USER DATABASE (BULK ONBOARDING) -# ========================================== -echo "Generating user database..." -cat < "${AUTHELIA_DIR}/users_database.yml" -users: - ${ADMIN_USER}: - displayname: "System Administrator" - password: "${ADMIN_HASH}" - email: "${ADMIN_EMAIL}" - groups: [admins] -EOF - -for entry in "${USER_LIST[@]}"; do - IFS=":" read -r uname dname uemail <<< "$entry" - cat <> "${AUTHELIA_DIR}/users_database.yml" - ${uname}: - displayname: "${dname}" - password: "${ADMIN_HASH}" # Everyone starts with the same temp password - email: "${uemail}" - groups: [users] -EOF -done - -# ========================================== -# 4. GENERATE MAIN CONFIGURATION -# ========================================== -echo "Generating Authelia configuration..." -cat < "${AUTHELIA_DIR}/configuration.yml" -theme: light -jwt_secret: "${JWT_SECRET}" -default_redirection_url: "https://${DOMAIN}" - -server: - host: 0.0.0.0 - port: 9091 - -authentication_backend: - password_reset: - disable: false - file: - path: "${AUTHELIA_DIR}/users_database.yml" - watch: true - -session: - name: authelia_session - secret: "${SESSION_SECRET}" - domain: "${DOMAIN}" - expiration: 1h - inactivity: 5m - -notifier: - smtp: - host: "${SMTP_HOST}" - port: ${SMTP_PORT} - username: "${SMTP_USER}" - password: "${SMTP_PASS}" - sender: "Authelia <${SMTP_USER}>" - -storage: - encryption_key: "${STORAGE_ENCRYPTION_KEY}" - local: - path: "${AUTHELIA_DIR}/db.sqlite3" - -access_control: - default_policy: deny - rules: - - domain: "${AUTH_SUBDOMAIN}" - policy: bypass - - domain: "*.${DOMAIN}" - policy: two_factor - -totp: - issuer: authelia.com -EOF - -# ========================================== -# 5. SYSTEMD & PERMISSIONS -# ========================================== -chown -R root:root "$AUTHELIA_DIR" -chmod 600 "${AUTHELIA_DIR}/configuration.yml" -chmod 600 "${AUTHELIA_DIR}/users_database.yml" - -cat < /etc/systemd/system/authelia.service -[Unit] -Description=Authelia Identity Provider -After=network.target - -[Service] -Type=simple -WorkingDirectory=${AUTHELIA_DIR} -ExecStart=${AUTHELIA_DIR}/authelia --config ${AUTHELIA_DIR}/configuration.yml -Restart=always -User=root - -[Install] -WantedBy=multi-user.target -EOF - -systemctl daemon-reload -systemctl enable --now authelia - -echo "-------------------------------------------------------" -echo "Authelia Installation Complete!" -echo "Authelia is running on port 9091" -echo "Config: ${AUTHELIA_DIR}/configuration.yml" -echo "Users: ${AUTHELIA_DIR}/users_database.yml" -echo "Next step: Configure your Reverse Proxy for ${AUTH_SUBDOMAIN}" -echo "-------------------------------------------------------" diff --git a/src/cmd/setupcheck/main.go b/src/cmd/setupcheck/main.go deleted file mode 100644 index 4ef9472..0000000 --- a/src/cmd/setupcheck/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func main() { - checks := []struct { - path string - purpose string - mustExist bool - }{ - {"/opt/nextwks/config.yaml", "NextWks configuration", true}, - {"/opt/nextwks/bin", "Binary output directory", true}, - {"/opt/nextwks/data", "Data directory", true}, - {"/opt/authelia/config/configuration.yml", "Authelia mock configuration", true}, - } - - allPassed := true - for _, c := range checks { - _, err := os.Stat(c.path) - if c.mustExist && os.IsNotExist(err) { - fmt.Printf("❌ MISSING: %s (%s)\n", c.path, c.purpose) - allPassed = false - } else if c.mustExist && err != nil { - fmt.Printf("❌ ERROR: %s - %v\n", c.path, err) - allPassed = false - } else { - fmt.Printf("✅ OK: %s (%s)\n", c.path, c.purpose) - } - } - - if allPassed { - fmt.Println("\n✅ All system paths verified!") - } else { - fmt.Println("\n❌ Some paths are missing or have errors") - os.Exit(1) - } -} diff --git a/src/core/admin/admin_test.go b/src/core/admin/admin_test.go deleted file mode 100644 index c885b64..0000000 --- a/src/core/admin/admin_test.go +++ /dev/null @@ -1,585 +0,0 @@ -package admin - -import ( - "database/sql" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - - _ "modernc.org/sqlite" -) - -// setupTestDB creates a temporary SQLite database for testing. -func setupTestDB(t *testing.T) (*UserStore, string, func()) { - t.Helper() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.db") - - database, err := initDB(dbPath) - if err != nil { - t.Fatalf("init db: %v", err) - } - - store := NewUserStore(database) - - cleanup := func() { - database.Close() - } - - return store, tmpDir, cleanup -} - -// initDB opens a SQLite database and runs migrations. -func initDB(path string) (*sql.DB, error) { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, err - } - - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, err - } - - db.Exec("PRAGMA journal_mode=WAL") - db.Exec("PRAGMA foreign_keys=ON") - - // Run migrations - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - email TEXT NOT NULL DEFAULT '', - groups TEXT NOT NULL DEFAULT '', - password_hash TEXT NOT NULL, - disabled INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); err != nil { - db.Close() - return nil, err - } - - return db, nil -} - -// --- UserStore Tests --- - -func TestUserStore_List_Empty(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - users, err := store.List() - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if len(users) != 0 { - t.Errorf("expected empty list, got %d users", len(users)) - } -} - -func TestUserStore_Create_SingleUser(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - req := CreateUserRequest{ - Users: []CreateUserInput{ - {Username: "jdoe", DisplayName: "John Doe", Email: "john@example.com", Groups: "admins,users"}, - }, - } - - results := store.Create(req) - if len(results) != 1 { - t.Fatalf("expected 1 result, got %d", len(results)) - } - - if results[0].Error != "" { - t.Fatalf("expected no error, got: %s", results[0].Error) - } - if results[0].Username != "jdoe" { - t.Errorf("expected username 'jdoe', got %q", results[0].Username) - } - if results[0].GeneratedPassword == "" { - t.Error("expected generated password to be non-empty") - } - if len(results[0].GeneratedPassword) < 16 { - t.Errorf("expected password >= 16 chars, got %d", len(results[0].GeneratedPassword)) - } -} - -func TestUserStore_Create_MultipleUsers(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - req := CreateUserRequest{ - Users: []CreateUserInput{ - {Username: "user1", DisplayName: "User One"}, - {Username: "user2", DisplayName: "User Two"}, - {Username: "user3", DisplayName: "User Three"}, - }, - } - - results := store.Create(req) - if len(results) != 3 { - t.Fatalf("expected 3 results, got %d", len(results)) - } - - for _, r := range results { - if r.Error != "" { - t.Errorf("unexpected error for %s: %s", r.Username, r.Error) - } - } - - users, _ := store.List() - if len(users) != 3 { - t.Errorf("expected 3 users, got %d", len(users)) - } -} - -func TestUserStore_Create_DuplicateUsername(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - req1 := CreateUserRequest{ - Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John Doe"}}, - } - store.Create(req1) - - req2 := CreateUserRequest{ - Users: []CreateUserInput{{Username: "jdoe", DisplayName: "Jane Doe"}}, - } - results := store.Create(req2) - - if len(results) != 1 { - t.Fatalf("expected 1 result, got %d", len(results)) - } - if results[0].Error == "" { - t.Fatal("expected error for duplicate username, got nil") - } - if results[0].Error != "user already exists" { - t.Errorf("expected 'user already exists', got %q", results[0].Error) - } -} - -func TestUserStore_Create_EmptyUsername(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - req := CreateUserRequest{ - Users: []CreateUserInput{{Username: ""}}, - } - - results := store.Create(req) - if len(results) != 1 { - t.Fatalf("expected 1 result, got %d", len(results)) - } - if results[0].Error != "username is required" { - t.Errorf("expected 'username is required', got %q", results[0].Error) - } -} - -func TestUserStore_GetByUsername_Found(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - store.Create(CreateUserRequest{ - Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John", Email: "john@test.com"}}, - }) - - user, err := store.GetByUsername("jdoe") - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if user == nil { - t.Fatal("expected user to be found") - } - if user.DisplayName != "John" { - t.Errorf("expected display name 'John', got %q", user.DisplayName) - } - if user.Email != "john@test.com" { - t.Errorf("expected email 'john@test.com', got %q", user.Email) - } -} - -func TestUserStore_GetByUsername_NotFound(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - user, err := store.GetByUsername("nonexistent") - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if user != nil { - t.Fatal("expected nil for nonexistent user") - } -} - -func TestUserStore_Delete_Existing(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - store.Create(CreateUserRequest{ - Users: []CreateUserInput{{Username: "jdoe"}}, - }) - - if err := store.Delete("jdoe"); err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - user, _ := store.GetByUsername("jdoe") - if user != nil { - t.Error("expected user to be deleted") - } -} - -func TestUserStore_Delete_NotFound(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - err := store.Delete("nonexistent") - if err == nil { - t.Fatal("expected error for deleting nonexistent user") - } -} - -func TestUserStore_Count(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - count, _ := store.Count() - if count != 0 { - t.Errorf("expected count 0, got %d", count) - } - - store.Create(CreateUserRequest{ - Users: []CreateUserInput{ - {Username: "user1"}, - {Username: "user2"}, - }, - }) - - count, _ = store.Count() - if count != 2 { - t.Errorf("expected count 2, got %d", count) - } -} - -func TestUserStore_SyncSnapshot(t *testing.T) { - store, _, cleanup := setupTestDB(t) - defer cleanup() - - store.Create(CreateUserRequest{ - Users: []CreateUserInput{ - {Username: "user1", DisplayName: "User One", Email: "u1@test.com", Groups: "admins"}, - {Username: "user2", DisplayName: "User Two", Groups: "users,devs"}, - }, - }) - - snapshot, err := store.SyncSnapshot() - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - if len(snapshot) != 2 { - t.Fatalf("expected 2 users in snapshot, got %d", len(snapshot)) - } - - // Check groups parsing - if len(snapshot[0].Groups) != 1 || snapshot[0].Groups[0] != "admins" { - t.Errorf("expected groups ['admins'], got %v", snapshot[0].Groups) - } - if len(snapshot[1].Groups) != 2 { - t.Errorf("expected 2 groups, got %v", snapshot[1].Groups) - } -} - -// --- Auth Tests --- - -func TestTokenAuthMiddleware_ValidToken(t *testing.T) { - middleware := TokenAuthMiddleware("test-token") - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - - req := httptest.NewRequest("GET", "/admin", nil) - req.Header.Set("Authorization", "Bearer test-token") - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d", w.Code) - } -} - -func TestTokenAuthMiddleware_InvalidToken(t *testing.T) { - middleware := TokenAuthMiddleware("test-token") - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - - req := httptest.NewRequest("GET", "/admin", nil) - req.Header.Set("Authorization", "Bearer wrong-token") - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("expected 401, got %d", w.Code) - } -} - -func TestTokenAuthMiddleware_MissingHeader(t *testing.T) { - middleware := TokenAuthMiddleware("test-token") - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - - req := httptest.NewRequest("GET", "/admin", nil) - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("expected 401, got %d", w.Code) - } -} - -func TestTokenAuthMiddleware_EmptyToken(t *testing.T) { - middleware := TokenAuthMiddleware("test-token") - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - - req := httptest.NewRequest("GET", "/admin", nil) - req.Header.Set("Authorization", "Bearer ") - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("expected 401, got %d", w.Code) - } -} - -// --- SyncWriter Tests --- - -func TestSyncWriter_Sync(t *testing.T) { - store, tmpDir, cleanup := setupTestDB(t) - defer cleanup() - - usersDBPath := filepath.Join(tmpDir, "users_database.yml") - sw := NewSyncWriter(usersDBPath, store) - - store.Create(CreateUserRequest{ - Users: []CreateUserInput{ - {Username: "alice", DisplayName: "Alice", Email: "alice@test.com", Groups: "admins"}, - {Username: "bob", DisplayName: "Bob", Groups: "users"}, - }, - }) - - if err := sw.Sync(); err != nil { - t.Fatalf("sync failed: %v", err) - } - - data, err := os.ReadFile(usersDBPath) - if err != nil { - t.Fatalf("read sync file: %v", err) - } - if len(data) == 0 { - t.Fatal("sync file is empty") - } - - content := string(data) - if !contains(content, "alice:") { - t.Errorf("expected 'alice:' in sync file") - } - if !contains(content, "bob:") { - t.Errorf("expected 'bob:' in sync file") - } - if !contains(content, "$argon2id$") { - t.Errorf("expected argon2id hash in sync file") - } -} - -func TestSyncWriter_Sync_EmptyStore(t *testing.T) { - store, tmpDir, cleanup := setupTestDB(t) - defer cleanup() - - usersDBPath := filepath.Join(tmpDir, "empty_users.yml") - sw := NewSyncWriter(usersDBPath, store) - - if err := sw.Sync(); err != nil { - t.Fatalf("sync should succeed with empty store: %v", err) - } - - data, _ := os.ReadFile(usersDBPath) - content := string(data) - if !contains(content, "users:") { - t.Errorf("expected 'users:' key even with empty store") - } -} - -func TestSyncWriter_Bootstrap_ExistingFile(t *testing.T) { - store, tmpDir, cleanup := setupTestDB(t) - defer cleanup() - - usersDBPath := filepath.Join(tmpDir, "users_database.yml") - yamlContent := []byte(` -users: - charlie: - displayname: "Charlie" - password: "$argon2id$v=19$m=65536,t=3,p=4$somesalt$somehash" - email: "charlie@test.com" - groups: - - admins - disabled: false -`) - if err := os.WriteFile(usersDBPath, yamlContent, 0644); err != nil { - t.Fatalf("write yaml: %v", err) - } - - sw := NewSyncWriter(usersDBPath, store) - - imported, err := sw.Bootstrap() - if err != nil { - t.Fatalf("bootstrap failed: %v", err) - } - if imported != 1 { - t.Errorf("expected 1 imported user, got %d", imported) - } - - user, _ := store.GetByUsername("charlie") - if user == nil { - t.Fatal("expected charlie to be imported") - } - if user.DisplayName != "Charlie" { - t.Errorf("expected display name 'Charlie', got %q", user.DisplayName) - } - if user.Email != "charlie@test.com" { - t.Errorf("expected email 'charlie@test.com', got %q", user.Email) - } -} - -func TestSyncWriter_Bootstrap_NoFile(t *testing.T) { - store, tmpDir, cleanup := setupTestDB(t) - defer cleanup() - - usersDBPath := filepath.Join(tmpDir, "nonexistent.yml") - sw := NewSyncWriter(usersDBPath, store) - - imported, err := sw.Bootstrap() - if err != nil { - t.Fatalf("bootstrap should not error on missing file: %v", err) - } - if imported != 0 { - t.Errorf("expected 0 imported, got %d", imported) - } -} - -func TestSyncWriter_Bootstrap_Idempotent(t *testing.T) { - store, tmpDir, cleanup := setupTestDB(t) - defer cleanup() - - usersDBPath := filepath.Join(tmpDir, "users_database.yml") - yamlContent := []byte("users:\n dave:\n password: \"$argon2id$v=19$m=65536,t=3,p=4$salt$hash\"\n") - os.WriteFile(usersDBPath, yamlContent, 0644) - - sw := NewSyncWriter(usersDBPath, store) - - imported1, _ := sw.Bootstrap() - imported2, _ := sw.Bootstrap() - - if imported1 != 1 { - t.Errorf("expected 1 on first bootstrap, got %d", imported1) - } - if imported2 != 0 { - t.Errorf("expected 0 on second bootstrap (idempotent), got %d", imported2) - } -} - -// --- Helper Tests --- - -func TestSplitAndTrim(t *testing.T) { - tests := []struct { - input string - delim string - expect []string - }{ - {"", ",", nil}, - {"a", ",", []string{"a"}}, - {"a,b,c", ",", []string{"a", "b", "c"}}, - {" a , b , c ", ",", []string{"a", "b", "c"}}, - {"admins,users,devs", ",", []string{"admins", "users", "devs"}}, - } - - for _, tt := range tests { - result := splitAndTrim(tt.input, tt.delim) - if len(result) != len(tt.expect) { - t.Errorf("splitAndTrim(%q) = %v, want %v", tt.input, result, tt.expect) - continue - } - for i := range result { - if result[i] != tt.expect[i] { - t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expect[i]) - } - } - } -} - -func TestGeneratePassword(t *testing.T) { - pwd, err := generatePassword(20) - if err != nil { - t.Fatalf("generate password: %v", err) - } - if len(pwd) != 20 { - t.Errorf("expected length 20, got %d", len(pwd)) - } - - pwd2, _ := generatePassword(20) - if pwd == pwd2 { - t.Error("expected different passwords") - } -} - -func TestHashPassword(t *testing.T) { - if _, err := exec.LookPath("/opt/authelia/authelia"); err != nil { - t.Skip("Authelia binary not available for testing") - } - hash, err := hashWithAuthelia("test-password") - if err != nil { - t.Fatalf("hashWithAuthelia failed: %v", err) - } - if !contains(hash, "$argon2id$") { - t.Errorf("expected argon2id prefix, got %q", hash) - } - if len(hash) < 60 { - t.Errorf("expected reasonably long hash, got %d chars", len(hash)) - } -} - -// contains checks if a string contains a substring. -func contains(s, substr string) bool { - return len(s) >= len(substr) && searchSubstring(s, substr) -} - -func searchSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - match := true - for j := 0; j < len(substr); j++ { - if s[i+j] != substr[j] { - match = false - break - } - } - if match { - return true - } - } - return false -} diff --git a/src/core/admin/auth.go b/src/core/admin/auth.go deleted file mode 100644 index 22ebfc8..0000000 --- a/src/core/admin/auth.go +++ /dev/null @@ -1,34 +0,0 @@ -package admin - -import ( - "crypto/subtle" - "net/http" -) - -// TokenAuthMiddleware protects admin routes with a static bearer token. -// All /admin/* routes require the Authorization: Bearer header -// matching the configured admin.secret_token. -func TokenAuthMiddleware(secretToken string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := r.Header.Get("Authorization") - - // Expect "Bearer " format - const bearerPrefix = "Bearer " - if len(token) < len(bearerPrefix) { - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return - } - - token = token[len(bearerPrefix):] - - // Constant-time comparison to prevent timing attacks - if subtle.ConstantTimeCompare([]byte(token), []byte(secretToken)) != 1 { - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return - } - - next.ServeHTTP(w, r) - }) - } -} diff --git a/src/core/admin/groups.go b/src/core/admin/groups.go deleted file mode 100644 index 638ff51..0000000 --- a/src/core/admin/groups.go +++ /dev/null @@ -1,78 +0,0 @@ -package admin - -import ( - "database/sql" - "fmt" -) - -// Group represents a team group. -type Group struct { - ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Apps string `json:"apps"` - UserCount int `json:"user_count"` -} - -// GroupStore handles group CRUD. -type GroupStore struct { - db *sql.DB -} - -func NewGroupStore(db *sql.DB) *GroupStore { - return &GroupStore{db: db} -} - -func (s *GroupStore) List() ([]Group, error) { - rows, err := s.db.Query(` - SELECT g.id, g.name, g.description, g.apps, - (SELECT COUNT(*) FROM users WHERE ',' || groups || ',' LIKE '%,' || g.name || ',%') as user_count - FROM groups g ORDER BY g.name - `) - if err != nil { - return nil, fmt.Errorf("list groups: %w", err) - } - defer rows.Close() - - var groups []Group - for rows.Next() { - var g Group - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Apps, &g.UserCount); err != nil { - return nil, err - } - groups = append(groups, g) - } - return groups, rows.Err() -} - -func (s *GroupStore) GetByName(name string) (*Group, error) { - var g Group - err := s.db.QueryRow(`SELECT id, name, description, apps FROM groups WHERE name = ?`, name).Scan(&g.ID, &g.Name, &g.Description, &g.Apps) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &g, nil -} - -func (s *GroupStore) Create(name, description, apps string) error { - _, err := s.db.Exec(`INSERT INTO groups (name, description, apps) VALUES (?, ?, ?)`, name, description, apps) - if err != nil { - return fmt.Errorf("create group %s: %w", name, err) - } - return nil -} - -func (s *GroupStore) Delete(name string) error { - result, err := s.db.Exec(`DELETE FROM groups WHERE name = ?`, name) - if err != nil { - return fmt.Errorf("delete group %s: %w", name, err) - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("group %s not found", name) - } - return nil -} diff --git a/src/core/admin/handlers.go b/src/core/admin/handlers.go deleted file mode 100644 index efcf049..0000000 --- a/src/core/admin/handlers.go +++ /dev/null @@ -1,132 +0,0 @@ -package admin - -import ( - "encoding/json" - "log/slog" - "net/http" - "strings" - - "git.lohmar.co.uk/lexton-it/NextWks/core/email" -) - -// Handler bundles admin HTTP handlers and their dependencies. -type Handler struct { - store *UserStore - groupStore *GroupStore - syncWriter *SyncWriter - emailer *email.Sender - logger *slog.Logger - configPath string -} - -func NewHandler(store *UserStore, groupStore *GroupStore, syncWriter *SyncWriter, emailer *email.Sender, logger *slog.Logger, configPath string) *Handler { - return &Handler{ - store: store, - groupStore: groupStore, - syncWriter: syncWriter, - emailer: emailer, - logger: logger, - configPath: configPath, - } -} - -// RegisterRoutes mounts admin routes on the given mux. -func (h *Handler) RegisterRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { - // Admin API (protected by bearer token) - mux.Handle("GET /admin/api/users", authMiddleware(http.HandlerFunc(h.listUsers))) - mux.Handle("POST /admin/api/users", authMiddleware(http.HandlerFunc(h.createUsers))) - mux.Handle("DELETE /admin/api/users/{username}", authMiddleware(http.HandlerFunc(h.deleteUser))) - mux.Handle("GET /admin/api/health", authMiddleware(http.HandlerFunc(h.adminHealth))) -} - -// --- API Handlers --- - -func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { - users, err := h.store.List() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if users == nil { - users = []User{} - } - writeJSON(w, http.StatusOK, users) -} - -type createUsersResponse struct { - Results []CreateUserResult `json:"results"` -} - -func (h *Handler) createUsers(w http.ResponseWriter, r *http.Request) { - var req CreateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) - return - } - - if len(req.Users) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no users provided"}) - return - } - - results := h.store.Create(req) - - // Sync to Authelia YAML - if err := h.syncWriter.Sync(); err != nil { - h.logger.Error("sync failed after create", "error", err) - } - - writeJSON(w, http.StatusCreated, createUsersResponse{Results: results}) -} - -func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { - username := r.PathValue("username") - if username == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) - return - } - - if err := h.store.Delete(username); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - - // Sync to Authelia YAML - if err := h.syncWriter.Sync(); err != nil { - h.logger.Error("sync failed after delete", "error", err) - } - - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "username": username}) -} - -func (h *Handler) adminHealth(w http.ResponseWriter, r *http.Request) { - count, err := h.store.Count() - status := "ok" - if err != nil { - status = "degraded" - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "status": status, - "user_count": count, - "authelia_db": h.syncWriter.usersDBPath, - }) -} - -// --- Helpers --- - -func writeJSON(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} - -// ErrorResponse is a generic error response. -type ErrorResponse struct { - Error string `json:"error"` -} - -// IsHTMLRequest checks if the client expects HTML (for HTMX routing). -func IsHTMLRequest(r *http.Request) bool { - accept := r.Header.Get("Accept") - return strings.Contains(accept, "text/html") || r.Header.Get("HX-Request") != "" -} diff --git a/src/core/admin/sync.go b/src/core/admin/sync.go deleted file mode 100644 index 3187805..0000000 --- a/src/core/admin/sync.go +++ /dev/null @@ -1,151 +0,0 @@ -package admin - -import ( - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// AutheliaUserDB represents the full structure of Authelia's users_database.yml. -type AutheliaUserDB struct { - Users map[string]AutheliaUserEntry `yaml:"users"` -} - -// AutheliaUserEntry represents a single user entry in Authelia's YAML. -type AutheliaUserEntry struct { - DisplayName string `yaml:"displayname,omitempty"` - Password string `yaml:"password"` - Email string `yaml:"email,omitempty"` - Groups []string `yaml:"groups,omitempty"` - Disabled bool `yaml:"disabled,omitempty"` -} - -// SyncWriter handles writing the user database to Authelia's YAML format. -type SyncWriter struct { - usersDBPath string - store *UserStore -} - -// NewSyncWriter creates a new SyncWriter. -func NewSyncWriter(usersDBPath string, store *UserStore) *SyncWriter { - return &SyncWriter{ - usersDBPath: usersDBPath, - store: store, - } -} - -// Sync writes the current user store to Authelia's users_database.yml. -func (sw *SyncWriter) Sync() error { - syncUsers, err := sw.store.SyncSnapshot() - if err != nil { - return fmt.Errorf("get sync snapshot: %w", err) - } - - db := AutheliaUserDB{ - Users: make(map[string]AutheliaUserEntry, len(syncUsers)), - } - - for _, u := range syncUsers { - db.Users[u.Username] = AutheliaUserEntry{ - DisplayName: u.DisplayName, - Password: u.Password, - Email: u.Email, - Groups: u.Groups, - Disabled: u.Disabled, - } - } - - // Ensure the target directory exists - dir := filepath.Dir(sw.usersDBPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create authelia data directory: %w", err) - } - - data, err := yaml.Marshal(&db) - if err != nil { - return fmt.Errorf("marshal users database: %w", err) - } - - if err := os.WriteFile(sw.usersDBPath, data, 0644); err != nil { - return fmt.Errorf("write users database: %w", err) - } - - return nil -} - -// Bootstrap imports existing Authelia users into the SQLite store. -// This runs on first initialization to adopt existing users. -func (sw *SyncWriter) Bootstrap() (int, error) { - data, err := os.ReadFile(sw.usersDBPath) - if err != nil { - if os.IsNotExist(err) { - return 0, nil // No existing file, nothing to bootstrap - } - return 0, fmt.Errorf("read authelia users database: %w", err) - } - - var db AutheliaUserDB - if err := yaml.Unmarshal(data, &db); err != nil { - return 0, fmt.Errorf("parse authelia users database: %w", err) - } - - imported := 0 - for username, entry := range db.Users { - existing, _ := sw.store.GetByUsername(username) - if existing != nil { - continue // Already exists, skip - } - - // Build groups string - groups := "" - for i, g := range entry.Groups { - if i > 0 { - groups += "," - } - groups += g - } - - // Determine role from groups - role := "user" - if containsGroup(groups, "admins") { - role = "admin" - } - - _, err := sw.store.GetDB().Exec(` - INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `, username, entry.DisplayName, entry.Email, role, groups, entry.Password, entry.Disabled) - if err != nil { - return imported, fmt.Errorf("import user %s: %w", username, err) - } - imported++ - } - - return imported, nil -} - -// FixRoles updates existing users' roles based on their groups. -func (sw *SyncWriter) FixRoles() (int, error) { - users, err := sw.store.List() - if err != nil { - return 0, err - } - - fixed := 0 - for _, u := range users { - expectedRole := "user" - if containsGroup(u.Groups, "admins") { - expectedRole = "admin" - } - if u.Role != expectedRole { - _, err := sw.store.GetDB().Exec("UPDATE users SET role = ? WHERE username = ?", expectedRole, u.Username) - if err != nil { - return fixed, err - } - fixed++ - } - } - return fixed, nil -} diff --git a/src/core/admin/templates/dashboard.templ b/src/core/admin/templates/dashboard.templ deleted file mode 100644 index cb05082..0000000 --- a/src/core/admin/templates/dashboard.templ +++ /dev/null @@ -1,20 +0,0 @@ -package templates - -templ Dashboard(userCount int) { - @Layout("dashboard") { -
-
-
{ userCount }
-
Users
-
-
-
-
Modules
-
-
-
OK
-
Status
-
-
- } -} diff --git a/src/core/admin/templates/dashboard_templ.go b/src/core/admin/templates/dashboard_templ.go deleted file mode 100644 index c8d3ac9..0000000 --- a/src/core/admin/templates/dashboard_templ.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func Dashboard(userCount int) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(userCount) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/dashboard.templ`, Line: 7, Col: 39} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Users
Modules
OK
Status
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = Layout("dashboard").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/global.templ b/src/core/admin/templates/global.templ deleted file mode 100644 index 3329a77..0000000 --- a/src/core/admin/templates/global.templ +++ /dev/null @@ -1,166 +0,0 @@ -package templates - -templ GlobalSettings(dbType, dbPath, dbHost, dbPort, dbUser, dbPass, dbName, smtpHost, smtpPort, smtpUser, smtpPass, imapHost, imapPort, nextwksURL, authURL, clientID, callbackURL, lang, tz, msg string) { - @Layout("global") { -

Global Settings

- if msg != "" { -
{ msg }
- } -
-
-

Database

-
- - -
-
-
- - -
-
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-

Email

-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- -
-

Workspace URL

-
- - -
-
- -
-

Authentication (OIDC)

-
- - -
-
-
- - -
-
- - -
-
-
- -
- - -
-
- -
- -
-

Localization

-
-
- - -
-
- - -
-
-
- - -
- } -} - -func styleDB(target, current string) string { - if target == current { - return "display:block" - } - return "display:none" -} diff --git a/src/core/admin/templates/global_templ.go b/src/core/admin/templates/global_templ.go deleted file mode 100644 index e0c03f9..0000000 --- a/src/core/admin/templates/global_templ.go +++ /dev/null @@ -1,412 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func GlobalSettings(dbType, dbPath, dbHost, dbPort, dbUser, dbPass, dbName, smtpHost, smtpPort, smtpUser, smtpPass, imapHost, imapPort, nextwksURL, authURL, clientID, callbackURL, lang, tz, msg string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Global Settings

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if msg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(msg) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 7, Col: 35} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Database

Email

Workspace URL

Authentication (OIDC)

Localization

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = Layout("global").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func styleDB(target, current string) string { - if target == current { - return "display:block" - } - return "display:none" -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/groups.templ b/src/core/admin/templates/groups.templ deleted file mode 100644 index 4fb043c..0000000 --- a/src/core/admin/templates/groups.templ +++ /dev/null @@ -1,106 +0,0 @@ -package templates - -templ GroupDashboard() { - @Layout("groups") { -
-
-
-

Groups

- -
-
-
- Groups - -
-
-
Loading groups...
-
-
-
-
-
- -

Select a group or click + Add

-
-
-
- } -} - -templ GroupList(groups []GroupRow) { - if len(groups) == 0 { -
No groups yet
- } else { - for _, g := range groups { -
-
-
{ g.Initial }
-
- { g.Name } -
{ g.UserCount } members
-
-
-
- -
-
- } - } -} - -templ CreateGroupForm() { -
-
-

New Group

- -
-
-
- - -
-
- - -
- -
-
-} - -templ EditGroupForm(name, description string) { -
-
-

Edit Group

- -
-
-
- - -
-
- - -
-
- - -
-
-
-} - -type GroupRow struct { - Name string - UserCount int - Initial string - Color string -} diff --git a/src/core/admin/templates/groups_templ.go b/src/core/admin/templates/groups_templ.go deleted file mode 100644 index 159d6a8..0000000 --- a/src/core/admin/templates/groups_templ.go +++ /dev/null @@ -1,291 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func GroupDashboard() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Groups

Groups
Loading groups...

Select a group or click + Add

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = Layout("groups").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func GroupList(groups []GroupRow) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - if len(groups) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
No groups yet
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - for _, g := range groups { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(g.Initial) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 38, Col: 74} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 40, Col: 22} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(g.UserCount) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 41, Col: 41} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " members
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - return nil - }) -} - -func CreateGroupForm() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

New Group

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func EditGroupForm(name, description string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Edit Group

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -type GroupRow struct { - Name string - UserCount int - Initial string - Color string -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/layout.templ b/src/core/admin/templates/layout.templ deleted file mode 100644 index ad74e62..0000000 --- a/src/core/admin/templates/layout.templ +++ /dev/null @@ -1,161 +0,0 @@ -package templates - -templ Layout(page string) { - - - - - - Admin — Next Workspace - - @adminStyles() - - -
-
-
- - - - Admin -
-
- -
- { children... } -
-
- - -} - -func activeClass(current, target string) string { - if current == target { - return " active" - } - return "" -} - -templ adminStyles() { - -} diff --git a/src/core/admin/templates/layout_templ.go b/src/core/admin/templates/layout_templ.go deleted file mode 100644 index 641dbf3..0000000 --- a/src/core/admin/templates/layout_templ.go +++ /dev/null @@ -1,180 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func Layout(page string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin — Next Workspace") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = adminStyles().Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Admin
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func activeClass(current, target string) string { - if current == target { - return " active" - } - return "" -} - -func adminStyles() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/templates/user-dashboard.templ b/src/core/admin/templates/user-dashboard.templ deleted file mode 100644 index 6fbfd29..0000000 --- a/src/core/admin/templates/user-dashboard.templ +++ /dev/null @@ -1,245 +0,0 @@ -package templates - -templ UserDashboard() { - @Layout("users") { -
-
-
-

Users

- -
-
-
- Users - -
-
-
Loading users...
-
-
-
-
-
- -

Click Edit on a user or + Add

-
-
-
- } -} - -templ UserList(users []UserRow) { - if len(users) == 0 { -
No users found
- } else { - for _, u := range users { -
-
-
{ initials(u.DisplayName) }
-
- { u.Username } -
{ u.Email }
-
-
-
- { u.Role } - if u.Disabled { - - } else { - - } - -
-
- } - } -} - -templ CreateUserForm(groups []string) { -
-
-

New User

- -
-
-
- - -
-
- - -
-
- - -
-
- - -
- if len(groups) > 0 { -
- -
-
- for _, g := range groups { -
{ g }
- } -
-
- -
- } - -
-
-} - -templ CreateUserSuccess(results []CreateUserResultRow) { - for _, r := range results { - if r.Error != "" { -
{ r.Error }
- } else { -
- { r.Username } created -
{ r.GeneratedPassword }
-
Save this password — it won't be shown again
-
- } - } - -} - -func initials(s string) string { - if len(s) == 0 { return "?" } - return string(s[0]) -} - -func hasGroup(userGroups, group string) bool { - for _, g := range splitGroups(userGroups) { - if g == group { - return true - } - } - return false -} - -func splitGroups(s string) []string { - if s == "" { return nil } - var result []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == ',' { - result = append(result, trimSpace(s[start:i])) - start = i + 1 - } - } - result = append(result, trimSpace(s[start:])) - return result -} - -func trimSpace(s string) string { - for len(s) > 0 && s[0] == ' ' { s = s[1:] } - for len(s) > 0 && s[len(s)-1] == ' ' { s = s[:len(s)-1] } - return s -} - -type UserRow struct { - Username string - DisplayName string - Email string - Role string - Groups string - Disabled bool - Password string -} -type CreateUserResultRow struct { - Username string - GeneratedPassword string - Error string -} - -templ EditUserForm(u UserRow, groups []string) { -
-
-

Edit User

- -
-
-
- - -
-
- - -
-
- - -
-
- - -
- if len(groups) > 0 { -
- -
-
- for _, g := range groups { - if hasGroup(u.Groups, g) { -
{ g }×
- } - } -
-
- for _, g := range groups { - if !hasGroup(u.Groups, g) { -
{ g }
- } - } -
-
- -
- - } -
- - -
-
-
-} diff --git a/src/core/admin/templates/user-dashboard_templ.go b/src/core/admin/templates/user-dashboard_templ.go deleted file mode 100644 index 11911be..0000000 --- a/src/core/admin/templates/user-dashboard_templ.go +++ /dev/null @@ -1,589 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func UserDashboard() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Users

Users
Loading users...

Click Edit on a user or + Add

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = Layout("users").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func UserList(users []UserRow) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - if len(users) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
No users found
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - for _, u := range users { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(initials(u.DisplayName)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 38, Col: 55} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 40, Col: 26} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 41, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Role) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 45, Col: 36} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if u.Disabled { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - return nil - }) -} - -func CreateUserForm(groups []string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

New User

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(groups) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, g := range groups { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(g) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 90, Col: 76} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func CreateUserSuccess(results []CreateUserResultRow) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - for _, r := range results { - if r.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Error) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 105, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Username) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 108, Col: 24} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " created
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.GeneratedPassword) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 109, Col: 51} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Save this password — it won't be shown again
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func initials(s string) string { - if len(s) == 0 { - return "?" - } - return string(s[0]) -} - -func hasGroup(userGroups, group string) bool { - for _, g := range splitGroups(userGroups) { - if g == group { - return true - } - } - return false -} - -func splitGroups(s string) []string { - if s == "" { - return nil - } - var result []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == ',' { - result = append(result, trimSpace(s[start:i])) - start = i + 1 - } - } - result = append(result, trimSpace(s[start:])) - return result -} - -func trimSpace(s string) string { - for len(s) > 0 && s[0] == ' ' { - s = s[1:] - } - for len(s) > 0 && s[len(s)-1] == ' ' { - s = s[:len(s)-1] - } - return s -} - -type UserRow struct { - Username string - DisplayName string - Email string - Role string - Groups string - Disabled bool - Password string -} -type CreateUserResultRow struct { - Username string - GeneratedPassword string - Error string -} - -func EditUserForm(u UserRow, groups []string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var16 := templ.GetChildren(ctx) - if templ_7745c5c3_Var16 == nil { - templ_7745c5c3_Var16 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Edit User

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(groups) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, g := range groups { - if hasGroup(u.Groups, g) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(g) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 202, Col: 86} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "×
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, g := range groups { - if !hasGroup(u.Groups, g) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(g) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 209, Col: 77} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/admin/ui.go b/src/core/admin/ui.go deleted file mode 100644 index 873c846..0000000 --- a/src/core/admin/ui.go +++ /dev/null @@ -1,454 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "os" - - "git.lohmar.co.uk/lexton-it/NextWks/core/admin/templates" - "gopkg.in/yaml.v3" -) - -// RegisterUIRoutes mounts the admin UI (Templ-rendered) routes. -func (h *Handler) RegisterUIRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { - // Admin dashboard page - mux.Handle("GET /admin", authMiddleware(http.HandlerFunc(h.adminDashboard))) - mux.Handle("GET /admin/", authMiddleware(http.HandlerFunc(h.adminDashboard))) - mux.Handle("GET /admin/global", authMiddleware(http.HandlerFunc(h.adminGlobal))) - mux.Handle("POST /admin/global", authMiddleware(http.HandlerFunc(h.adminGlobalSave))) - mux.Handle("GET /admin/users", authMiddleware(http.HandlerFunc(h.adminUsers))) - mux.Handle("GET /admin/users/create-form", authMiddleware(http.HandlerFunc(h.createUserForm))) - mux.Handle("GET /admin/users/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) - mux.Handle("GET /admin/users/edit-form/{username}", authMiddleware(http.HandlerFunc(h.editUserForm))) - mux.Handle("PUT /admin/users/{username}", authMiddleware(http.HandlerFunc(h.updateUser))) - // Groups - mux.Handle("GET /admin/groups", authMiddleware(http.HandlerFunc(h.groupsPage))) - mux.Handle("GET /admin/groups/list", authMiddleware(http.HandlerFunc(h.groupList))) - mux.Handle("POST /admin/groups", authMiddleware(http.HandlerFunc(h.createGroup))) - mux.Handle("DELETE /admin/groups/{name}", authMiddleware(http.HandlerFunc(h.deleteGroup))) - mux.Handle("GET /admin/groups/create-form", authMiddleware(http.HandlerFunc(h.createGroupForm))) - mux.Handle("GET /admin/groups/cancel-form", authMiddleware(http.HandlerFunc(h.cancelForm))) - mux.Handle("GET /admin/groups/edit-form/{name}", authMiddleware(http.HandlerFunc(h.editGroupForm))) - mux.Handle("PUT /admin/groups/{name}", authMiddleware(http.HandlerFunc(h.updateGroup))) -} - -func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) { - // Count users for the dashboard - count, _ := h.store.Count() - - component := templates.Dashboard(count) - component.Render(r.Context(), w) -} - -func (h *Handler) adminUsers(w http.ResponseWriter, r *http.Request) { - component := templates.UserDashboard() - component.Render(r.Context(), w) -} - -func (h *Handler) createUserForm(w http.ResponseWriter, r *http.Request) { - grps, _ := h.groupStore.List() - groupNames := make([]string, 0, len(grps)) - for _, g := range grps { - groupNames = append(groupNames, g.Name) - } - component := templates.CreateUserForm(groupNames) - component.Render(r.Context(), w) -} - -func (h *Handler) cancelForm(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("")) -} - -// adminGlobal serves the global settings form. -func (h *Handler) adminGlobal(w http.ResponseWriter, r *http.Request) { - cfg := readConfigRaw(h.configPath) - component := templates.GlobalSettings( - cfg["db_type"], cfg["db_path"], cfg["db_host"], cfg["db_port"], - cfg["db_user"], cfg["db_pass"], cfg["db_name"], - cfg["smtp_host"], cfg["smtp_port"], cfg["smtp_user"], cfg["smtp_pass"], - cfg["imap_host"], cfg["imap_port"], cfg["nextwks_url"], cfg["auth_url"], - cfg["client_id"], cfg["callback_url"], cfg["lang"], cfg["tz"], "", - ) - component.Render(r.Context(), w) -} - -// adminGlobalSave handles POST to update the config. -func (h *Handler) adminGlobalSave(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - msg := writeConfigRaw(h.configPath, r) - // Snip: update second call same way - // Re-read to show updated values - cfg := readConfigRaw(h.configPath) - component := templates.GlobalSettings( - cfg["db_type"], cfg["db_path"], cfg["db_host"], cfg["db_port"], - cfg["db_user"], cfg["db_pass"], cfg["db_name"], - cfg["smtp_host"], cfg["smtp_port"], cfg["smtp_user"], cfg["smtp_pass"], - cfg["imap_host"], cfg["imap_port"], cfg["nextwks_url"], cfg["auth_url"], - cfg["client_id"], cfg["callback_url"], cfg["lang"], cfg["tz"], msg, - ) - component.Render(r.Context(), w) -} - -type rawConfig map[string]string - -func readConfigRaw(path string) rawConfig { - data, err := os.ReadFile(path) - if err != nil { - return rawConfig{} - } - var m map[string]interface{} - yaml.Unmarshal(data, &m) - - cfg := rawConfig{} - cfg["db_type"] = getNested(m, "database", "type") - cfg["db_path"] = getNested(m, "database", "path") - cfg["db_host"] = getNested(m, "database", "host") - cfg["db_port"] = getNested(m, "database", "port") - cfg["db_user"] = getNested(m, "database", "user") - cfg["db_pass"] = getNested(m, "database", "password") - cfg["db_name"] = getNested(m, "database", "name") - cfg["smtp_host"] = getNested(m, "smtp", "host") - cfg["smtp_port"] = getNested(m, "smtp", "port") - cfg["smtp_user"] = getNested(m, "smtp", "username") - cfg["smtp_pass"] = getNested(m, "smtp", "password") - cfg["imap_host"] = getNested(m, "imap", "host") - cfg["imap_port"] = getNested(m, "imap", "port") - cfg["nextwks_url"] = getNested(m, "oidc", "redirect_url") - if cfg["nextwks_url"] != "" { - cfg["nextwks_url"] = trimSuffix(cfg["nextwks_url"], "/auth/callback") - } - cfg["auth_url"] = getNested(m, "oidc", "issuer_url") - cfg["client_id"] = getNested(m, "oidc", "client_id") - cfg["callback_url"] = getNested(m, "oidc", "redirect_url") - cfg["lang"] = getNested(m, "locale", "language") - cfg["tz"] = getNested(m, "locale", "timezone") - return cfg -} - -func writeConfigRaw(path string, r *http.Request) string { - data, err := os.ReadFile(path) - if err != nil { - return "Error reading config" - } - var m map[string]interface{} - yaml.Unmarshal(data, &m) - - setNested(m, r.FormValue("db_type"), "database", "type") - setNested(m, r.FormValue("db_path"), "database", "path") - setNested(m, r.FormValue("db_host"), "database", "host") - setNested(m, r.FormValue("db_port"), "database", "port") - setNested(m, r.FormValue("db_user"), "database", "user") - setNested(m, r.FormValue("db_pass"), "database", "password") - setNested(m, r.FormValue("db_name"), "database", "name") - setNested(m, r.FormValue("smtp_port"), "smtp", "port") - setNested(m, r.FormValue("smtp_user"), "smtp", "username") - setNested(m, r.FormValue("smtp_pass"), "smtp", "password") - setNested(m, r.FormValue("imap_host"), "imap", "host") - setNested(m, r.FormValue("imap_port"), "imap", "port") - setNested(m, r.FormValue("lang"), "locale", "language") - setNested(m, r.FormValue("tz"), "locale", "timezone") - nextwks := r.FormValue("nextwks_url") - if nextwks != "" { - setNested(m, nextwks+"/auth/callback", "oidc", "redirect_url") - setNested(m, stripScheme(nextwks), "oidc", "domain") - } - auth := r.FormValue("auth_url") - if auth != "" { - setNested(m, auth, "oidc", "issuer_url") - } - - out, _ := yaml.Marshal(m) - os.WriteFile(path, out, 0600) - return "Settings saved — restart service to apply" -} - -func getNested(m map[string]interface{}, keys ...string) string { - v := interface{}(m) - for i, k := range keys { - mp, ok := v.(map[string]interface{}) - if !ok { - return "" - } - v = mp[k] - if i == len(keys)-1 { - switch val := v.(type) { - case string: - return val - case int: - return fmt.Sprintf("%d", val) - case float64: - return fmt.Sprintf("%.0f", val) - } - return "" - } - } - return "" -} - -func setNested(m map[string]interface{}, val string, keys ...string) { - for i, k := range keys { - if i == len(keys)-1 { - m[k] = val - return - } - if _, ok := m[k]; !ok { - m[k] = make(map[string]interface{}) - } - m = m[k].(map[string]interface{}) - } -} - -func trimSuffix(s, suffix string) string { - if len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix { - return s[:len(s)-len(suffix)] - } - return s -} - -func stripScheme(url string) string { - if len(url) > 8 && url[:8] == "https://" { - return url[8:] - } - if len(url) > 7 && url[:7] == "http://" { - return url[7:] - } - return url -} - -// UserToRow converts a User model to a template UserRow. -func UserToRow(u User) templates.UserRow { - return templates.UserRow{ - Username: u.Username, - DisplayName: u.DisplayName, - Email: u.Email, - Role: u.Role, - Groups: u.Groups, - Disabled: u.Disabled, - } -} - -// userRowsHandler returns user rows for HTMX partial updates. -func (h *Handler) userRowsHandler(w http.ResponseWriter, r *http.Request) { - users, err := h.store.List() - if err != nil { - http.Error(w, "failed to load users", http.StatusInternalServerError) - return - } - - rows := make([]templates.UserRow, 0, len(users)) - for _, u := range users { - rows = append(rows, UserToRow(u)) - } - - component := templates.UserList(rows) - component.Render(r.Context(), w) -} - -// createUsersHandler processes the form submission via HTMX. -func (h *Handler) createUsersHandler(w http.ResponseWriter, r *http.Request) { - // Parse form data - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid form data", http.StatusBadRequest) - return - } - - username := r.FormValue("username") - displayName := r.FormValue("display_name") - email := r.FormValue("email") - role := r.FormValue("role") - // Collect groups from checkboxes - groupVals := r.Form["group"] - groups := "" - for i, g := range groupVals { - if i > 0 { - groups += "," - } - groups += g - } - - req := CreateUserRequest{ - Users: []CreateUserInput{ - { - Username: username, - DisplayName: displayName, - Email: email, - Role: role, - Groups: groups, - }, - }, - } - - results := h.store.Create(req) - - // Sync to Authelia YAML - h.syncWriter.Sync() - - // Send welcome emails - for _, r := range results { - if r.Error == "" && r.GeneratedPassword != "" { - email := "" - for _, input := range req.Users { - if input.Username == r.Username { - email = input.Email - } - } - if email != "" { - go h.emailer.SendWelcome(email, r.Username, r.GeneratedPassword, "") // workspace URL from config - } - } - } - - // Convert to template results - resultRows := make([]templates.CreateUserResultRow, 0, len(results)) - for _, r := range results { - resultRows = append(resultRows, templates.CreateUserResultRow{ - Username: r.Username, - GeneratedPassword: r.GeneratedPassword, - Error: r.Error, - }) - } - - component := templates.CreateUserSuccess(resultRows) - component.Render(r.Context(), w) -} - -// RegisterHTMXRoutes mounts the HTMX partial-update endpoints. -func (h *Handler) RegisterHTMXRoutes(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler) { - // HTMX returns HTML fragments, not full pages - mux.Handle("GET /admin/users/list", authMiddleware(http.HandlerFunc(h.userRowsHandler))) - mux.Handle("POST /admin/users/create", authMiddleware(http.HandlerFunc(h.createUsersHandler))) -} - -// --- Group Handlers --- - -func (h *Handler) groupsPage(w http.ResponseWriter, r *http.Request) { - component := templates.GroupDashboard() - component.Render(r.Context(), w) -} - -func (h *Handler) groupList(w http.ResponseWriter, r *http.Request) { - groups, err := h.groupStore.List() - if err != nil { - http.Error(w, "failed to list groups", http.StatusInternalServerError) - return - } - rows := make([]templates.GroupRow, 0, len(groups)) - colors := []string{"#58a6ff", "#3fb950", "#d2991d", "#f85149", "#a371f7", "#db61a2"} - for i, g := range groups { - init := "?" - if len(g.Name) > 0 { - init = string(g.Name[0]) - } - rows = append(rows, templates.GroupRow{ - Name: g.Name, - UserCount: g.UserCount, - Initial: init, - Color: colors[i%len(colors)], - }) - } - if rows == nil { - rows = []templates.GroupRow{} - } - component := templates.GroupList(rows) - component.Render(r.Context(), w) -} - -func (h *Handler) createGroupForm(w http.ResponseWriter, r *http.Request) { - component := templates.CreateGroupForm() - component.Render(r.Context(), w) -} - -func (h *Handler) createGroup(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - name := r.FormValue("name") - desc := r.FormValue("description") - if name == "" { - http.Error(w, "name required", http.StatusBadRequest) - return - } - // Collect checked apps - apps := r.Form["app"] - appsStr := "" - for i, a := range apps { - if i > 0 { - appsStr += "," - } - appsStr += a - } - if err := h.groupStore.Create(name, desc, appsStr); err != nil { - http.Error(w, err.Error(), http.StatusConflict) - return - } - h.groupList(w, r) -} - -func (h *Handler) deleteGroup(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if err := h.groupStore.Delete(name); err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - h.groupList(w, r) -} - -func (h *Handler) editGroupForm(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - g, err := h.groupStore.GetByName(name) - if err != nil || g == nil { - http.Error(w, "group not found", http.StatusNotFound) - return - } - component := templates.EditGroupForm(g.Name, g.Description) - component.Render(r.Context(), w) -} - -func (h *Handler) updateGroup(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - desc := r.FormValue("description") - _, err := h.groupStore.db.Exec(`UPDATE groups SET description = ? WHERE name = ?`, desc, name) - if err != nil { - http.Error(w, "update failed", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`
Group updated successfully
- `)) -} - -func (h *Handler) editUserForm(w http.ResponseWriter, r *http.Request) { - username := r.PathValue("username") - u, _ := h.store.GetByUsername(username) - if u == nil { - http.Error(w, "user not found", http.StatusNotFound) - return - } - grps, _ := h.groupStore.List() - groupNames := make([]string, 0, len(grps)) - for _, g := range grps { - groupNames = append(groupNames, g.Name) - } - row := UserToRow(*u) - component := templates.EditUserForm(row, groupNames) - component.Render(r.Context(), w) -} - -func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { - username := r.PathValue("username") - r.ParseForm() - role := r.FormValue("role") - groupVals := r.Form["group"] - groups := "" - for i, g := range groupVals { - if i > 0 { - groups += "," - } - groups += g - } - h.store.GetDB().Exec(`UPDATE users SET role = ?, groups = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?`, role, groups, username) - h.syncWriter.Sync() - // Return success message and refresh user list - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`
User updated successfully
- `)) - // Trigger the refresh of the user list via HTMX -} diff --git a/src/core/admin/users.go b/src/core/admin/users.go deleted file mode 100644 index 520dc27..0000000 --- a/src/core/admin/users.go +++ /dev/null @@ -1,308 +0,0 @@ -package admin - -import ( - "crypto/rand" - "database/sql" - "fmt" - "math/big" - "os/exec" - "strings" -) - -// User represents a managed user in the NextWks admin system. -type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - Role string `json:"role"` // "admin" or "user" - Groups string `json:"groups"` - PasswordHash string `json:"-"` - Disabled bool `json:"disabled"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// UserStore handles user CRUD operations against SQLite. -type UserStore struct { - db *sql.DB -} - -// NewUserStore creates a new UserStore with the given database. -func NewUserStore(db *sql.DB) *UserStore { - return &UserStore{db: db} -} - -// List returns all non-deleted users. -func (s *UserStore) List() ([]User, error) { - rows, err := s.db.Query(` - SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at - FROM users ORDER BY username ASC - `) - if err != nil { - return nil, fmt.Errorf("list users: %w", err) - } - defer rows.Close() - - var users []User - for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, - &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { - return nil, fmt.Errorf("scan user: %w", err) - } - users = append(users, u) - } - return users, rows.Err() -} - -// GetByUsername retrieves a single user by username. -func (s *UserStore) GetByUsername(username string) (*User, error) { - var u User - err := s.db.QueryRow(` - SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at - FROM users WHERE username = ? - `, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, - &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("get user %s: %w", username, err) - } - return &u, nil -} - -// CreateUserRequest represents a request to create one or more users. -type CreateUserRequest struct { - Users []CreateUserInput `json:"users"` -} - -// CreateUserInput represents a single user creation input. -type CreateUserInput struct { - Username string `json:"username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - Role string `json:"role"` // "admin" or "user" (default: "user") - Groups string `json:"groups"` -} - -// CreateUserResult holds the result of a user creation. -type CreateUserResult struct { - Username string `json:"username"` - GeneratedPassword string `json:"generated_password,omitempty"` - Error string `json:"error,omitempty"` -} - -// Create creates users and returns results with generated passwords. -func (s *UserStore) Create(req CreateUserRequest) []CreateUserResult { - results := make([]CreateUserResult, 0, len(req.Users)) - - for _, input := range req.Users { - result := CreateUserResult{Username: input.Username} - - // Validate username - if input.Username == "" { - result.Error = "username is required" - results = append(results, result) - continue - } - - // Check for existing user - existing, _ := s.GetByUsername(input.Username) - if existing != nil { - result.Error = "user already exists" - results = append(results, result) - continue - } - - // Generate random password - password, err := generatePassword(20) - if err != nil { - result.Error = fmt.Sprintf("password generation failed: %v", err) - results = append(results, result) - continue - } - - // Hash password using Authelia's own crypto tool - hash, err := hashWithAuthelia(password) - if err != nil { - result.Error = fmt.Sprintf("password hashing failed: %v", err) - results = append(results, result) - continue - } - - // Default role to "user" if not set - if input.Role == "" { - input.Role = "user" - } - - // Build effective groups: role-based + explicit - effectiveGroups := input.Groups - if input.Role == "admin" { - if effectiveGroups == "" { - effectiveGroups = "admins" - } else if !containsGroup(effectiveGroups, "admins") { - effectiveGroups = effectiveGroups + ",admins" - } - } - - _, err = s.db.Exec(` - INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) - `, input.Username, input.DisplayName, input.Email, input.Role, effectiveGroups, hash) - if err != nil { - result.Error = fmt.Sprintf("insert failed: %v", err) - results = append(results, result) - continue - } - - result.GeneratedPassword = password - results = append(results, result) - } - - return results -} - -// Delete removes a user by username. -func (s *UserStore) Delete(username string) error { - result, err := s.db.Exec("DELETE FROM users WHERE username = ?", username) - if err != nil { - return fmt.Errorf("delete user %s: %w", username, err) - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("user %s not found", username) - } - return nil -} - -// Count returns the total number of users. -func (s *UserStore) Count() (int, error) { - var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) - return count, err -} - -// containsGroup checks if a comma-separated groups string contains a specific group. -func containsGroup(groups, target string) bool { - for _, g := range splitAndTrim(groups, ",") { - if g == target { - return true - } - } - return false -} - -// generatePassword creates a cryptographically secure random password. -func generatePassword(length int) (string, error) { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - result := make([]byte, length) - for i := range result { - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) - if err != nil { - return "", err - } - result[i] = charset[n.Int64()] - } - return string(result), nil -} - -// hashWithAuthelia uses Authelia's own binary to hash a password. -func hashWithAuthelia(password string) (string, error) { - cmd := exec.Command("/opt/authelia/authelia", "crypto", "hash", "generate", "--password", password) - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("authelia hash: %w", err) - } - // Output format: "Digest: $argon2id$v=19$m=65536,t=3,p=4$salt$hash" - fields := strings.Fields(string(out)) - for _, f := range fields { - if strings.HasPrefix(f, "$argon2") { - return f, nil - } - } - return "", fmt.Errorf("could not find hash in authelia output: %s", string(out)) -} - -// GetDB returns the underlying database connection for sync operations. -func (s *UserStore) GetDB() *sql.DB { - return s.db -} - -// SyncUser is a snapshot of user data used for YAML export. -type SyncUser struct { - Username string - DisplayName string - Email string - Role string - Groups []string - Password string - Disabled bool -} - -// SyncSnapshot returns all users for YAML export. -func (s *UserStore) SyncSnapshot() ([]SyncUser, error) { - users, err := s.List() - if err != nil { - return nil, err - } - - syncUsers := make([]SyncUser, 0, len(users)) - for _, u := range users { - var groups []string - if u.Groups != "" { - // Split by comma, trim spaces - groups = splitAndTrim(u.Groups, ",") - } - syncUsers = append(syncUsers, SyncUser{ - Username: u.Username, - DisplayName: u.DisplayName, - Email: u.Email, - Role: u.Role, - Groups: groups, - Password: u.PasswordHash, - Disabled: u.Disabled, - }) - } - - return syncUsers, nil -} - -// splitAndTrim splits a string by delimiter and trims spaces. -func splitAndTrim(s, delim string) []string { - if s == "" { - return nil - } - - // Simple split without importing slices - result := make([]string, 0) - current := "" - for i := 0; i < len(s); i++ { - if i+len(delim) <= len(s) && s[i:i+len(delim)] == delim { - if current != "" { - result = append(result, trimSpace(current)) - current = "" - } - i += len(delim) - 1 - } else { - current += string(s[i]) - } - } - if current != "" { - result = append(result, trimSpace(current)) - } - return result -} - -// trimSpace removes leading and trailing whitespace. -func trimSpace(s string) string { - start, end := 0, len(s) - for start < end && (s[start] == ' ' || s[start] == '\t') { - start++ - } - for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { - end-- - } - return s[start:end] -} diff --git a/src/core/auth/middleware.go b/src/core/auth/middleware.go deleted file mode 100644 index 56e4f7b..0000000 --- a/src/core/auth/middleware.go +++ /dev/null @@ -1,49 +0,0 @@ -package auth - -import ( - "database/sql" - "net/http" -) - -// RoleChecker validates that the session user has the required role. -type RoleChecker struct { - db *sql.DB -} - -// NewRoleChecker creates a role checker backed by the database. -func NewRoleChecker(db *sql.DB) *RoleChecker { - return &RoleChecker{db: db} -} - -// RequireAdmin is middleware that allows only users with the "admin" role. -// Must run after SessionMiddleware has populated the context. -func (rc *RoleChecker) RequireAdmin(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - userID, ok := GetUserID(r) - if !ok { - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return - } - - isAdmin, err := rc.IsAdmin(userID) - if err != nil || !isAdmin { - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }) -} - -// IsAdmin checks if a user has the admin role. -func (rc *RoleChecker) IsAdmin(username string) (bool, error) { - var role string - err := rc.db.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role) - if err == sql.ErrNoRows { - return false, nil - } - if err != nil { - return false, err - } - return role == "admin", nil -} diff --git a/src/core/auth/oidc.go b/src/core/auth/oidc.go deleted file mode 100644 index cc2cd6a..0000000 --- a/src/core/auth/oidc.go +++ /dev/null @@ -1,260 +0,0 @@ -package auth - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -// OIDCConfig holds the configuration for the Authelia OIDC client. -type OIDCConfig struct { - IssuerURL string - ClientID string - ClientSecret string - RedirectURL string -} - -// OIDCHandler handles OIDC authentication flows with Authelia. -type OIDCHandler struct { - config OIDCConfig - store *SessionStore -} - -// NewOIDCHandler creates a new OIDC handler. -func NewOIDCHandler(config OIDCConfig, store *SessionStore) *OIDCHandler { - return &OIDCHandler{ - config: config, - store: store, - } -} - -// LoginRedirect redirects the user to Authelia's OIDC authorization endpoint. -func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) { - // Get target URL from current request path - targetURL := r.URL.Path - if targetURL == "/" || targetURL == "/access" { - targetURL = "/" - } - // Embed target in state: random_token:target_url - randPart := generateToken(16) - state := randPart + ":" + targetURL - nonce := generateToken(16) - - // PKCE: generate code verifier and challenge - verifier := generateToken(32) - challenge := pkceChallenge(verifier) - - // Store state + verifier in cookies (shared across subdomains) - http.SetCookie(w, &http.Cookie{ - Name: "oidc_state", - Value: randPart, - Path: "/", - MaxAge: 300, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - http.SetCookie(w, &http.Cookie{ - Name: "oidc_verifier", - Value: verifier, - Path: "/", - MaxAge: 300, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - - authURL := fmt.Sprintf( - "%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256", - h.config.IssuerURL, - url.QueryEscape(h.config.ClientID), - url.QueryEscape(h.config.RedirectURL), - url.QueryEscape(state), - nonce, - challenge, - ) - - http.Redirect(w, r, authURL, http.StatusFound) -} - -// Callback handles the OIDC authorization code callback from Authelia. -func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) { - stateCookie, err := r.Cookie("oidc_state") - if err != nil { - http.Error(w, "missing state cookie", http.StatusBadRequest) - return - } - - // Get state from URL query (GET) or form body (POST) - stateParam := r.URL.Query().Get("state") - if stateParam == "" { - r.ParseForm() - stateParam = r.Form.Get("state") - } - if stateParam == "" || stateParam != stateCookie.Value { - http.Error(w, "state mismatch", http.StatusForbidden) - return - } - - // Get PKCE verifier from cookie - verifierCookie, _ := r.Cookie("oidc_verifier") - verifier := "" - if verifierCookie != nil { - verifier = verifierCookie.Value - } - - // Clear state cookies - http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) - http.SetCookie(w, &http.Cookie{Name: "oidc_verifier", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) - - // Get code from URL query (GET) or form body (POST) - code := r.URL.Query().Get("code") - if code == "" { - code = r.Form.Get("code") - } - if code == "" { - http.Error(w, "missing authorization code", http.StatusBadRequest) - return - } - - // Exchange code for tokens (with PKCE verifier) - username, err := h.exchangeCode(code, verifier) - if err != nil { - http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError) - return - } - - token, err := h.store.CreateSession(username, 60) - if err != nil { - http.Error(w, "session creation failed", http.StatusInternalServerError) - return - } - - // Set session cookie - http.SetCookie(w, &http.Cookie{ - Name: "nextwks_session", - Value: token, - Path: "/", - MaxAge: 3600, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - - http.Redirect(w, r, "/", http.StatusFound) -} - -// exchangeCode exchanges an OIDC authorization code for an ID token. -func (h *OIDCHandler) exchangeCode(code, verifier string) (string, error) { - tokenURL := h.config.IssuerURL + "/api/oidc/token" - - data := url.Values{ - "grant_type": {"authorization_code"}, - "code": {code}, - "redirect_uri": {h.config.RedirectURL}, - "client_id": {h.config.ClientID}, - "code_verifier": {verifier}, - } - - resp, err := http.PostForm(tokenURL, data) - if err != nil { - return "", fmt.Errorf("token request failed: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body)) - } - - var tokenResp struct { - IDToken string `json:"id_token"` - } - if err := json.Unmarshal(body, &tokenResp); err != nil { - return "", fmt.Errorf("parse token response: %w", err) - } - - if tokenResp.IDToken == "" { - return "", fmt.Errorf("no id_token in response") - } - - username, err := decodeJWTSub(tokenResp.IDToken) - if err != nil { - return "", fmt.Errorf("decode id_token: %w", err) - } - - return username, nil -} - -// decodeJWTSub extracts the "sub" (subject/username) from a JWT without verifying the signature. -func decodeJWTSub(token string) (string, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return "", fmt.Errorf("invalid JWT format") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return "", fmt.Errorf("decode JWT payload: %w", err) - } - - var claims struct { - Sub string `json:"sub"` - PreferredUsername string `json:"preferred_username"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return "", fmt.Errorf("parse JWT claims: %w", err) - } - - // Use preferred_username (actual username), fall back to sub (UUID) - username := claims.PreferredUsername - if username == "" { - username = claims.Sub - } - if username == "" { - return "", fmt.Errorf("missing username in id_token") - } - - return username, nil -} - -// pkceChallenge creates a PKCE S256 challenge from a verifier. -func pkceChallenge(verifier string) string { - h := sha256.Sum256([]byte(verifier)) - return base64.RawURLEncoding.EncodeToString(h[:]) -} - -// AuthGateMiddleware protects routes behind OIDC authentication. -func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, ok := GetUserID(r) - if !ok { - // Check if force-login is requested (after logout) - if _, ferr := r.Cookie("force_login"); ferr == nil { - h.LoginRedirectWithPrompt(w, r, "login") - return - } - h.LoginRedirect(w, r) - return - } - next.ServeHTTP(w, r) - }) -} - -// LoginRedirectWithPrompt redirects with a specific prompt value. -func (h *OIDCHandler) LoginRedirectWithPrompt(w http.ResponseWriter, r *http.Request, prompt string) { - state := generateToken(16) - nonce := generateToken(16) - verifier := generateToken(32) - challenge := pkceChallenge(verifier) - - authURL := fmt.Sprintf( - "%s/api/oidc/authorize?prompt=%s&response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256", - h.config.IssuerURL, prompt, - url.QueryEscape(h.config.ClientID), url.QueryEscape(h.config.RedirectURL), - state, nonce, challenge, - ) - http.Redirect(w, r, authURL, http.StatusFound) -} diff --git a/src/core/auth/session.go b/src/core/auth/session.go deleted file mode 100644 index b8de9bb..0000000 --- a/src/core/auth/session.go +++ /dev/null @@ -1,154 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "database/sql" - "encoding/hex" - "fmt" - "net/http" - "time" -) - -// contextKey is used for storing values in request context. -type contextKey string - -const ( - ContextUserID contextKey = "user_id" - ContextRole contextKey = "role" -) - -// SessionStore manages user sessions backed by SQLite. -type SessionStore struct { - db *sql.DB - roleDB *sql.DB // Optional: same DB, used for role lookups -} - -// NewSessionStore creates a session store. -func NewSessionStore(db *sql.DB) *SessionStore { - return &SessionStore{db: db, roleDB: db} -} - -// Session represents an authenticated user session. -type Session struct { - ID string - UserID string - CreatedAt time.Time - ExpiresAt time.Time -} - -// CreateSession generates a new session for a user and returns the token. -func (s *SessionStore) CreateSession(userID string, expiryMinutes int) (string, error) { - token := generateToken(32) - tokenHash := hashToken(token) - - _, err := s.db.Exec( - `INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at) - VALUES (?, ?, ?, datetime('now'), datetime('now', '+' || ? || ' minutes'))`, - token[:16], userID, tokenHash, expiryMinutes, - ) - if err != nil { - return "", fmt.Errorf("create session: %w", err) - } - - return token, nil -} - -// ValidateSession checks if a session token is valid and returns the session. -func (s *SessionStore) ValidateSession(token string) (*Session, error) { - tokenHash := hashToken(token) - - var sess Session - var createdAt, expiresAt string - err := s.db.QueryRow( - `SELECT id, user_id, created_at, expires_at - FROM sessions - WHERE token_hash = ? AND expires_at > datetime('now')`, - tokenHash, - ).Scan(&sess.ID, &sess.UserID, &createdAt, &expiresAt) - - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("validate session: %w", err) - } - - sess.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - sess.ExpiresAt, _ = time.Parse("2006-01-02 15:04:05", expiresAt) - - return &sess, nil -} - -// DeleteSession removes a session (logout). -func (s *SessionStore) DeleteSession(token string) error { - tokenHash := hashToken(token) - _, err := s.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash) - return err -} - -// CleanExpired removes all expired sessions. -func (s *SessionStore) CleanExpired() error { - _, err := s.db.Exec("DELETE FROM sessions WHERE expires_at <= datetime('now')") - return err -} - -// SessionMiddleware returns an HTTP middleware that validates session cookies. -// If valid, the user_id is stored in the request context. -func (s *SessionStore) SessionMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("nextwks_session") - if err != nil { - // No cookie — pass through without session - next.ServeHTTP(w, r) - return - } - - session, err := s.ValidateSession(cookie.Value) - if err != nil || session == nil { - // Invalid or expired — clear cookie and continue - http.SetCookie(w, &http.Cookie{ - Name: "nextwks_session", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) - next.ServeHTTP(w, r) - return - } - - // Set user_id in context - // Set user_id and role in context - ctx := context.WithValue(r.Context(), ContextUserID, session.UserID) - // Look up role from database - var role string - s.roleDB.QueryRow("SELECT role FROM users WHERE username = ?", session.UserID).Scan(&role) - if role == "" { - role = "user" - } - ctx = context.WithValue(ctx, ContextRole, role) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// GetUserID retrieves the authenticated user ID from the request context. -func GetUserID(r *http.Request) (string, bool) { - uid, ok := r.Context().Value(ContextUserID).(string) - return uid, ok -} - -// generateToken creates a cryptographically secure random hex token. -func generateToken(length int) string { - b := make([]byte, length) - rand.Read(b) - return hex.EncodeToString(b) -} - -// hashToken creates a SHA-256 hash of a token for storage. -func hashToken(token string) string { - h := sha256.Sum256([]byte(token)) - return hex.EncodeToString(h[:]) -} diff --git a/src/core/config/config.go b/src/core/config/config.go deleted file mode 100644 index 6673231..0000000 --- a/src/core/config/config.go +++ /dev/null @@ -1,129 +0,0 @@ -package config - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// Config represents the full NextWks configuration. -type Config struct { - Server ServerConfig `yaml:"server"` - TLS TLSConfig `yaml:"tls"` - Admin AdminConfig `yaml:"admin"` - Database DatabaseConfig `yaml:"database"` - Authelia AutheliaConfig `yaml:"authelia"` - OIDC OIDCConfig `yaml:"oidc"` - SMTP SMTPConfig `yaml:"smtp"` - IMAP IMAPConfig `yaml:"imap"` - Locale LocaleConfig `yaml:"locale"` - Session SessionConfig `yaml:"session"` -} - -type ServerConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` -} - -// TLSConfig holds automatic HTTPS configuration via certmagic/Let's Encrypt, -// or file-based TLS using a self-signed or custom certificate. -type TLSConfig struct { - Enabled bool `yaml:"enabled"` - Domain string `yaml:"domain"` - Email string `yaml:"email"` - StoragePath string `yaml:"storage_path"` - Staging bool `yaml:"staging"` - CertFile string `yaml:"cert_file"` // File-based TLS cert (optional — overrides certmagic) - KeyFile string `yaml:"key_file"` // File-based TLS key (optional — overrides certmagic) -} - -type AdminConfig struct { - SecretToken string `yaml:"secret_token"` -} - -type DatabaseConfig struct { - Type string `yaml:"type"` // "sqlite" or "mariadb" - Path string `yaml:"path"` // SQLite file path - Host string `yaml:"host"` // MariaDB host - Port int `yaml:"port"` // MariaDB port - User string `yaml:"user"` // MariaDB user - Password string `yaml:"password"` // MariaDB password - Name string `yaml:"name"` // MariaDB database name -} - -type AutheliaConfig struct { - Host string `yaml:"host"` - ConfigPath string `yaml:"config_path"` - UsersDBPath string `yaml:"users_db_path"` -} - -// OIDCConfig holds the OIDC provider settings (Authelia). -type OIDCConfig struct { - IssuerURL string `yaml:"issuer_url"` // Public-facing Authelia URL (e.g., https://app.nextwks.eu/auth) - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - RedirectURL string `yaml:"redirect_url"` -} - -type SMTPConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - Username string `yaml:"username"` - Password string `yaml:"password"` - From string `yaml:"from"` -} - -type IMAPConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` -} - -type LocaleConfig struct { - Language string `yaml:"language"` - Timezone string `yaml:"timezone"` -} - -type SessionConfig struct { - Secret string `yaml:"secret"` - ExpiryMinutes int `yaml:"expiry_minutes"` -} - -// Load reads and parses the YAML configuration file. -func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read config file: %w", err) - } - - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parse config file: %w", err) - } - - return &cfg, nil -} - -// AutheliaSessionSecret extracts the session.secret from Authelia's configuration. -func AutheliaSessionSecret(cfgPath string) (string, error) { - data, err := os.ReadFile(cfgPath) - if err != nil { - return "", fmt.Errorf("read authelia config: %w", err) - } - - var autheliaCfg struct { - Session struct { - Secret string `yaml:"secret"` - } `yaml:"session"` - } - - if err := yaml.Unmarshal(data, &autheliaCfg); err != nil { - return "", fmt.Errorf("parse authelia config: %w", err) - } - - if autheliaCfg.Session.Secret == "" { - return "", fmt.Errorf("authelia session.secret not found in %s", cfgPath) - } - - return autheliaCfg.Session.Secret, nil -} diff --git a/src/core/config/config_test.go b/src/core/config/config_test.go deleted file mode 100644 index e5635fe..0000000 --- a/src/core/config/config_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -const testdataDir = "testdata" - -func testdataPath(name string) string { - return filepath.Join(testdataDir, name) -} - -// --- Config.Load tests --- - -func TestLoad_ValidConfig(t *testing.T) { - cfg, err := Load(testdataPath("valid-config.yaml")) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - if cfg.Server.Host != "0.0.0.0" { - t.Errorf("expected Server.Host '0.0.0.0', got %q", cfg.Server.Host) - } - if cfg.Server.Port != 8080 { - t.Errorf("expected Server.Port 8080, got %d", cfg.Server.Port) - } - if cfg.Admin.SecretToken != "test-admin-token-123" { - t.Errorf("expected Admin.SecretToken 'test-admin-token-123', got %q", cfg.Admin.SecretToken) - } - if cfg.Database.Type != "sqlite" { - t.Errorf("expected Database.Type 'sqlite', got %q", cfg.Database.Type) - } - if cfg.Database.Path != "/tmp/nextwks-test.db" { - t.Errorf("expected Database.Path '/tmp/nextwks-test.db', got %q", cfg.Database.Path) - } - if cfg.Authelia.Host != "http://127.0.0.1:9091" { - t.Errorf("expected Authelia.Host 'http://127.0.0.1:9091', got %q", cfg.Authelia.Host) - } - if cfg.Session.Secret != "test-session-secret" { - t.Errorf("expected Session.Secret 'test-session-secret', got %q", cfg.Session.Secret) - } - if cfg.Session.ExpiryMinutes != 60 { - t.Errorf("expected Session.ExpiryMinutes 60, got %d", cfg.Session.ExpiryMinutes) - } - if cfg.SMTP.Host != "mail.example.com" { - t.Errorf("expected SMTP.Host 'mail.example.com', got %q", cfg.SMTP.Host) - } - if cfg.SMTP.Port != 587 { - t.Errorf("expected SMTP.Port 587, got %d", cfg.SMTP.Port) - } - if cfg.SMTP.From != "noreply@example.com" { - t.Errorf("expected SMTP.From 'noreply@example.com', got %q", cfg.SMTP.From) - } -} - -func TestLoad_MissingFile(t *testing.T) { - _, err := Load(testdataPath("nonexistent-file.yaml")) - if err == nil { - t.Fatal("expected error for missing file, got nil") - } -} - -func TestLoad_InvalidYAML(t *testing.T) { - tmpFile := filepath.Join(t.TempDir(), "invalid.yaml") - if err := os.WriteFile(tmpFile, []byte("invalid: yaml: \n bad: ["), 0644); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - - _, err := Load(tmpFile) - if err == nil { - t.Fatal("expected error for invalid YAML, got nil") - } -} - -func TestLoad_EmptyFile(t *testing.T) { - tmpFile := filepath.Join(t.TempDir(), "empty.yaml") - if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - - cfg, err := Load(tmpFile) - if err != nil { - t.Fatalf("expected no error for empty file, got: %v", err) - } - - // Empty file should yield zero-value config - if cfg.Server.Port != 0 { - t.Errorf("expected zero-value Port, got %d", cfg.Server.Port) - } -} - -// --- Config.AutheliaSessionSecret tests --- - -func TestAutheliaSessionSecret_Valid(t *testing.T) { - secret, err := AutheliaSessionSecret(testdataPath("valid-authelia-config.yaml")) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - if secret != "authelia-test-session-secret" { - t.Errorf("expected secret 'authelia-test-session-secret', got %q", secret) - } -} - -func TestAutheliaSessionSecret_MissingFile(t *testing.T) { - _, err := AutheliaSessionSecret(testdataPath("nonexistent-authelia-config.yaml")) - if err == nil { - t.Fatal("expected error for missing file, got nil") - } -} - -func TestAutheliaSessionSecret_NoSecretField(t *testing.T) { - _, err := AutheliaSessionSecret(testdataPath("no-session-authelia-config.yaml")) - if err == nil { - t.Fatal("expected error when session.secret is missing, got nil") - } -} - -func TestAutheliaSessionSecret_EmptySecret(t *testing.T) { - tmpFile := filepath.Join(t.TempDir(), "authelia-empty-secret.yaml") - content := []byte("session:\n name: test\n secret: \"\"\n") - if err := os.WriteFile(tmpFile, content, 0644); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - - _, err := AutheliaSessionSecret(tmpFile) - if err == nil { - t.Fatal("expected error for empty session.secret, got nil") - } -} diff --git a/src/core/config/testdata/no-session-authelia-config.yaml b/src/core/config/testdata/no-session-authelia-config.yaml deleted file mode 100644 index dd3562b..0000000 --- a/src/core/config/testdata/no-session-authelia-config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -host: 0.0.0.0 -port: 9091 - -log: - level: debug - -jwt_secret: test-jwt-secret - -storage: - local: - path: /opt/authelia/data/db.sqlite - -authentication_backend: - file: - path: /opt/authelia/data/users_database.yml diff --git a/src/core/config/testdata/valid-authelia-config.yaml b/src/core/config/testdata/valid-authelia-config.yaml deleted file mode 100644 index 20d9f69..0000000 --- a/src/core/config/testdata/valid-authelia-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -host: 0.0.0.0 -port: 9091 - -log: - level: debug - -jwt_secret: test-jwt-secret - -session: - name: authelia_session - secret: authelia-test-session-secret - expiration: 1h - inactivity: 5m - -storage: - local: - path: /opt/authelia/data/db.sqlite - -authentication_backend: - file: - path: /opt/authelia/data/users_database.yml diff --git a/src/core/config/testdata/valid-config.yaml b/src/core/config/testdata/valid-config.yaml deleted file mode 100644 index 6e06e2f..0000000 --- a/src/core/config/testdata/valid-config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -server: - host: "0.0.0.0" - port: 8080 - -admin: - secret_token: "test-admin-token-123" - -database: - type: "sqlite" - path: "/tmp/nextwks-test.db" - -authelia: - host: "http://127.0.0.1:9091" - config_path: "/opt/authelia/config/configuration.yml" - users_db_path: "/opt/authelia/data/users_database.yml" - -smtp: - host: "mail.example.com" - port: 587 - username: "test@example.com" - password: "test-password" - from: "noreply@example.com" - -session: - secret: "test-session-secret" - expiry_minutes: 60 diff --git a/src/core/db/db.go b/src/core/db/db.go deleted file mode 100644 index 3c2d788..0000000 --- a/src/core/db/db.go +++ /dev/null @@ -1,136 +0,0 @@ -package db - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - - _ "modernc.org/sqlite" -) - -// Database wraps the SQLite connection and provides migration helpers. -type Database struct { - DB *sql.DB -} - -// Initialize opens (or creates) the SQLite database at the given path. -func Initialize(dbPath string) (*Database, error) { - // Ensure the data directory exists - dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, fmt.Errorf("create data directory: %w", err) - } - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, fmt.Errorf("open database: %w", err) - } - - // Enable WAL mode for better concurrency - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - return nil, fmt.Errorf("enable WAL mode: %w", err) - } - - // Enable foreign keys - if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { - return nil, fmt.Errorf("enable foreign keys: %w", err) - } - - return &Database{DB: db}, nil -} - -// Migrate runs automatic schema migrations on startup. -func (d *Database) Migrate() error { - migrations := []string{ - `groups`, - `users`, - `sessions`, - `audit_logs`, - } - - // Verify all required tables exist - for _, table := range migrations { - if err := d.ensureTable(table); err != nil { - return fmt.Errorf("ensure table %s: %w", table, err) - } - } - - return nil -} - -func (d *Database) ensureTable(name string) error { - switch name { - case "groups": - _, err := d.DB.Exec(` - CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - description TEXT NOT NULL DEFAULT '', - apps TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - return err - } - d.DB.Exec(`ALTER TABLE groups ADD COLUMN apps TEXT NOT NULL DEFAULT ''`) - return nil - - case "users": - // Create table if it doesn't exist - _, err := d.DB.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - email TEXT NOT NULL DEFAULT '', - role TEXT NOT NULL DEFAULT 'user', - groups TEXT NOT NULL DEFAULT '', - password_hash TEXT NOT NULL, - disabled INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - return err - } - - // Migrate: add role column if missing (for existing databases) - d.DB.Exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`) - return nil - - case "sessions": - _, err := d.DB.Exec(` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - token_hash TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL - ) - `) - return err - - case "audit_logs": - _, err := d.DB.Exec(` - CREATE TABLE IF NOT EXISTS audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - action TEXT NOT NULL, - actor TEXT NOT NULL, - target TEXT, - details TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - return err - } - - return fmt.Errorf("unknown table: %s", name) -} - -// Close cleanly shuts down the database connection. -func (d *Database) Close() error { - return d.DB.Close() -} diff --git a/src/core/db/db_test.go b/src/core/db/db_test.go deleted file mode 100644 index e88b417..0000000 --- a/src/core/db/db_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package db - -import ( - "database/sql" - "os" - "path/filepath" - "testing" -) - -func TestInitialize_CreatesDirectory(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "subdir", "test.db") - - db, err := Initialize(dbPath) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - defer db.Close() - - // Verify directory was created - if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) { - t.Fatal("expected directory to be created") - } - - // Verify database file was created - if _, err := os.Stat(dbPath); os.IsNotExist(err) { - t.Fatal("expected database file to be created") - } -} - -func TestInitialize_OpensConnection(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.db") - - db, err := Initialize(dbPath) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - defer db.Close() - - // Verify connection is alive - if err := db.DB.Ping(); err != nil { - t.Fatalf("expected ping to succeed, got: %v", err) - } -} - -func TestInitialize_ExistingFile(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "existing.db") - - // Create database once - db1, err := Initialize(dbPath) - if err != nil { - t.Fatalf("first init failed: %v", err) - } - db1.Close() - - // Re-open existing database - db2, err := Initialize(dbPath) - if err != nil { - t.Fatalf("second init failed: %v", err) - } - defer db2.Close() - - if err := db2.DB.Ping(); err != nil { - t.Fatalf("expected ping to succeed, got: %v", err) - } -} - -func TestMigrate_CreatesTables(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "migrate-test.db") - - database, err := Initialize(dbPath) - if err != nil { - t.Fatalf("init failed: %v", err) - } - defer database.Close() - - if err := database.Migrate(); err != nil { - t.Fatalf("migrate failed: %v", err) - } - - // Verify tables exist - expectedTables := []string{"users", "sessions", "audit_logs"} - for _, table := range expectedTables { - var count int - row := database.DB.QueryRow( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", - table, - ) - if err := row.Scan(&count); err != nil { - t.Fatalf("failed to check table %s: %v", table, err) - } - if count == 0 { - t.Errorf("expected table %s to exist", table) - } - } -} - -func TestMigrate_Idempotent(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "idempotent-test.db") - - database, err := Initialize(dbPath) - if err != nil { - t.Fatalf("init failed: %v", err) - } - defer database.Close() - - // Run migrations twice - if err := database.Migrate(); err != nil { - t.Fatalf("first migrate failed: %v", err) - } - if err := database.Migrate(); err != nil { - t.Fatalf("second migrate should succeed (idempotent), got: %v", err) - } -} - -func TestMigrate_TableSchemas(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "schema-test.db") - - database, err := Initialize(dbPath) - if err != nil { - t.Fatalf("init failed: %v", err) - } - defer database.Close() - database.Migrate() - - // Verify sessions table columns - rows, err := database.DB.Query("PRAGMA table_info(sessions)") - if err != nil { - t.Fatalf("failed to get sessions schema: %v", err) - } - defer rows.Close() - - columns := map[string]bool{} - for rows.Next() { - var cid int - var name, ctype string - var notnull, pk int - var dflt sql.NullString - if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { - t.Fatalf("failed to scan column: %v", err) - } - columns[name] = true - _ = ctype - } - - expectedCols := []string{"id", "user_id", "token_hash", "created_at", "expires_at"} - for _, col := range expectedCols { - if !columns[col] { - t.Errorf("expected column %q in sessions table", col) - } - } -} - -func TestMigrate_UsersTableSchema(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "users-schema-test.db") - - database, err := Initialize(dbPath) - if err != nil { - t.Fatalf("init failed: %v", err) - } - defer database.Close() - database.Migrate() - - // Verify users table columns - rows, err := database.DB.Query("PRAGMA table_info(users)") - if err != nil { - t.Fatalf("failed to get users schema: %v", err) - } - defer rows.Close() - - columns := map[string]string{} - for rows.Next() { - var cid int - var name, ctype string - var notnull, pk int - var dflt sql.NullString - if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { - t.Fatalf("failed to scan column: %v", err) - } - columns[name] = ctype - } - - expectedCols := []string{"id", "username", "display_name", "email", "groups", "password_hash", "disabled", "created_at", "updated_at"} - for _, col := range expectedCols { - if _, ok := columns[col]; !ok { - t.Errorf("expected column %q in users table", col) - } - } - - // Verify username has UNIQUE constraint (SQLite creates an index for UNIQUE columns) - var indexCount int - database.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'sqlite_autoindex_users%' AND sql IS NULL").Scan(&indexCount) - if indexCount == 0 { - t.Error("expected UNIQUE constraint on username column") - } - - // Spot-check specific types - if columns["username"] != "TEXT" { - t.Errorf("expected username type TEXT, got %s", columns["username"]) - } - if columns["disabled"] != "INTEGER" { - t.Errorf("expected disabled type INTEGER, got %s", columns["disabled"]) - } -} - -func TestClose(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "close-test.db") - - database, err := Initialize(dbPath) - if err != nil { - t.Fatalf("init failed: %v", err) - } - - if err := database.Close(); err != nil { - t.Fatalf("close failed: %v", err) - } - - // Ping should fail after close - if err := database.DB.Ping(); err == nil { - t.Fatal("expected ping to fail after close") - } -} diff --git a/src/core/email/email.go b/src/core/email/email.go deleted file mode 100644 index d48abed..0000000 --- a/src/core/email/email.go +++ /dev/null @@ -1,76 +0,0 @@ -package email - -import ( - "fmt" - "log/slog" - "net/smtp" - "strings" -) - -// Sender handles SMTP email delivery. -type Sender struct { - host string - port string - username string - password string - from string - logger *slog.Logger -} - -// NewSender creates an email sender from SMTP config. -func NewSender(host string, port int, username, password, from string, logger *slog.Logger) *Sender { - return &Sender{ - host: host, - port: fmt.Sprintf("%d", port), - username: username, - password: password, - from: from, - logger: logger, - } -} - -// SendWelcome sends a welcome email with the generated password. -func (s *Sender) SendWelcome(to, username, password, workspaceURL string) error { - subject := "Welcome to Next Workspace" - body := fmt.Sprintf(`Hello %s, - -Your Next Workspace account has been created. - - Workspace: %s - Username: %s - Password: %s - -Please log in and change your password. - -Best regards, -Next Workspace`, username, workspaceURL, username, password) - - return s.send(to, subject, body) -} - -// SendAlert sends a generic alert email. -func (s *Sender) SendAlert(to, subject, message string) error { - return s.send(to, subject, message) -} - -func (s *Sender) send(to, subject, body string) error { - if s.host == "" || s.username == "" { - s.logger.Warn("email not configured, skipping send", "to", to) - return nil - } - - msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", - s.from, to, subject, body) - - addr := fmt.Sprintf("%s:%s", s.host, s.port) - auth := smtp.PlainAuth("", s.username, s.password, s.host) - - err := smtp.SendMail(addr, auth, s.from, strings.Split(to, ","), []byte(msg)) - if err != nil { - s.logger.Error("send email failed", "to", to, "error", err) - return fmt.Errorf("send email: %w", err) - } - - s.logger.Info("email sent", "to", to, "subject", subject) - return nil -} diff --git a/src/core/i18n/i18n.go b/src/core/i18n/i18n.go deleted file mode 100644 index 4d62f45..0000000 --- a/src/core/i18n/i18n.go +++ /dev/null @@ -1,87 +0,0 @@ -package i18n - -// T holds all translation keys. -type T map[string]string - -var translations = map[string]T{ - "en": { - "greeting_morning": "Good morning", - "greeting_afternoon": "Good afternoon", - "greeting_evening": "Good evening", - "workspace_ready": "Your workspace is ready", - "install_app": "Install app", - "settings": "Settings", - "logout": "Logout", - "language": "Language", - "timezone": "Timezone", - "save": "Save", - "available": "Available", - "coming_soon": "Coming Soon", - "users_groups": "Users, groups & settings", - "storage_sharing": "Storage & sharing", - "email_client": "Email client", - "schedule_events": "Schedule & events", - "documents_sheets": "Documents & sheets", - "people_directory": "People & directory", - "project_management": "Project management", - "team_messaging": "Team messaging", - "pwa_desktop_title": "Desktop Chrome/Edge", - "pwa_desktop_step1": "Click the ⊕ icon in the address bar", - "pwa_desktop_step2": "Click Install", - "pwa_ios_title": "iOS Safari", - "pwa_ios_step1": "Tap Share ⎋", - "pwa_ios_step2": "Tap Add to Home Screen", - "pwa_android_title": "Android Chrome", - "pwa_android_step1": "Tap ⋮ menu", - "pwa_android_step2": "Tap Install app", - "pwa_got_it": "Got it", - "install_workspace": "Install Next Workspace", - }, - "de": { - "greeting_morning": "Guten Morgen", - "greeting_afternoon": "Guten Tag", - "greeting_evening": "Guten Abend", - "workspace_ready": "Ihr Arbeitsbereich ist bereit", - "install_app": "App installieren", - "settings": "Einstellungen", - "logout": "Abmelden", - "language": "Sprache", - "timezone": "Zeitzone", - "save": "Speichern", - "available": "Verfügbar", - "coming_soon": "Demnächst", - "users_groups": "Benutzer, Gruppen & Einstellungen", - "storage_sharing": "Speicher & Freigabe", - "email_client": "E-Mail-Client", - "schedule_events": "Termine & Ereignisse", - "documents_sheets": "Dokumente & Tabellen", - "people_directory": "Personen & Verzeichnis", - "project_management": "Projektmanagement", - "team_messaging": "Team-Chat", - "pwa_desktop_title": "Desktop Chrome/Edge", - "pwa_desktop_step1": "Klicken Sie auf das ⊕-Symbol in der Adressleiste", - "pwa_desktop_step2": "Klicken Sie auf Installieren", - "pwa_ios_title": "iOS Safari", - "pwa_ios_step1": "Tippen Sie auf Teilen ⎋", - "pwa_ios_step2": "Tippen Sie auf Zum Home-Bildschirm", - "pwa_android_title": "Android Chrome", - "pwa_android_step1": "Tippen Sie auf ⋮ Menü", - "pwa_android_step2": "Tippen Sie auf App installieren", - "pwa_got_it": "Verstanden", - "install_workspace": "Next Workspace installieren", - }, -} - -// Get returns the translation map for a language code. -func Get(lang string) T { - t, ok := translations[lang] - if !ok { - t = translations["en"] - } - return t -} - -// Supported returns the list of supported language codes. -func Supported() []string { - return []string{"en", "de"} -} diff --git a/src/core/ui/app-grid.templ b/src/core/ui/app-grid.templ deleted file mode 100644 index 5ab7132..0000000 --- a/src/core/ui/app-grid.templ +++ /dev/null @@ -1,49 +0,0 @@ -package ui - -type AppTile struct { - Name string - Description string - URL string - Icon string - Color string - Status string - AdminOnly bool -} - -func DefaultApps(role string) []AppTile { - all := []AppTile{ - {Name:"Admin Panel",Description:"Users, groups & settings",URL:"/admin",Color:"#9333ea",Icon:``,Status:"ready",AdminOnly:true}, - {Name:"Files",Description:"Storage & sharing",URL:"/files",Color:"#2563eb",Icon:``,Status:"coming-soon"}, - {Name:"Mail",Description:"Email client",URL:"/mail",Color:"#dc2626",Icon:``,Status:"coming-soon"}, - {Name:"Calendar",Description:"Schedule & events",URL:"/calendar",Color:"#059669",Icon:``,Status:"coming-soon"}, - {Name:"WorkSheets",Description:"Spreadsheets",URL:"/worksheets",Color:"#059669",Icon:``,Status:"coming-soon"}, - {Name:"TypeWriter",Description:"Documents",URL:"/typewriter",Color:"#2563eb",Icon:``,Status:"coming-soon"}, - {Name:"DeckCreator",Description:"Presentations",URL:"/deckcreator",Color:"#dc2626",Icon:``,Status:"coming-soon"}, - {Name:"NotesFiles",Description:"Notes & files",URL:"/notesfiles",Color:"#d97706",Icon:``,Status:"coming-soon"}, - {Name:"Contacts",Description:"People & directory",URL:"/contacts",Color:"#d97706",Icon:``,Status:"coming-soon"}, - {Name:"Tasks",Description:"Project management",URL:"/tasks",Color:"#7c3aed",Icon:``,Status:"coming-soon"}, - {Name:"Chat",Description:"Team messaging",URL:"/chat",Color:"#db2777",Icon:``,Status:"coming-soon"}, - } - filtered := make([]AppTile,0,len(all)) - for _,a := range all { - if a.AdminOnly && role != "admin" { continue } - filtered = append(filtered, a) - } - return filtered -} - -templ AppGrid(apps []AppTile) { -
- for _, a := range apps { - @appTile(a) - } -
-} - -templ appTile(a AppTile) { -
-
@templ.Raw(a.Icon)
-
{ a.Name }
-
Available
-
-} diff --git a/src/core/ui/app-grid.templ.bak b/src/core/ui/app-grid.templ.bak deleted file mode 100644 index da441bf..0000000 --- a/src/core/ui/app-grid.templ.bak +++ /dev/null @@ -1,121 +0,0 @@ -package ui - -// AppTile represents an app on the workspace launcher. -type AppTile struct { - Name string - Description string - URL string - Icon string // SVG inline - Color string // Background color - Status string // "ready" or "coming-soon" - AdminOnly bool // Only visible to admin role -} - -// DefaultApps returns apps filtered by user role. -func DefaultApps(role string) []AppTile { - all := []AppTile{ - { - Name: "Admin Panel", Description: "Users, groups & settings", - URL: "/admin", Color: "#9333ea", - Icon: ``, - Status: "ready", AdminOnly: true, - }, - { - Name: "Files", Description: "Storage & sharing", - URL: "#", Color: "#2563eb", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "Mail", Description: "Email client", - URL: "#", Color: "#dc2626", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "Calendar", Description: "Schedule & events", - URL: "#", Color: "#059669", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "WorkSheets", Description: "Spreadsheets", - URL: "/worksheets", Color: "#059669", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "TypeWriter", Description: "Documents", - URL: "/typewriter", Color: "#2563eb", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "DeckCreator", Description: "Presentations", - URL: "/deckcreator", Color: "#dc2626", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "NotesFiles", Description: "Notes & files", - URL: "/notesfiles", Color: "#d97706", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "Contacts", Description: "People & directory", - URL: "#", Color: "#d97706", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "Tasks", Description: "Project management", - URL: "#", Color: "#7c3aed", - Icon: ``, - Status: "coming-soon", - }, - { - Name: "Chat", Description: "Team messaging", - URL: "#", Color: "#db2777", - Icon: ``, - Status: "coming-soon", - }, - } - - // Filter by role - filtered := make([]AppTile, 0, len(all)) - for _, a := range all { - if a.AdminOnly && role != "admin" { - continue - } - filtered = append(filtered, a) - } - return filtered -} - -templ AppGrid(apps []AppTile) { -
- for _, a := range apps { - @appTile(a) - } -
-} - -templ appTile(a AppTile) { - if a.Status == "coming-soon" { -
-
@templ.Raw(a.Icon)
-
{ a.Name }
-
Coming Soon
-
- } else { -
-
@templ.Raw(a.Icon)
-
{ a.Name }
-
Available
-
- } -} diff --git a/src/core/ui/app-grid_templ.go b/src/core/ui/app-grid_templ.go deleted file mode 100644 index e04d6f7..0000000 --- a/src/core/ui/app-grid_templ.go +++ /dev/null @@ -1,160 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package ui - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -type AppTile struct { - Name string - Description string - URL string - Icon string - Color string - Status string - AdminOnly bool -} - -func DefaultApps(role string) []AppTile { - all := []AppTile{ - {Name: "Admin Panel", Description: "Users, groups & settings", URL: "/admin", Color: "#9333ea", Icon: ``, Status: "ready", AdminOnly: true}, - {Name: "Files", Description: "Storage & sharing", URL: "/files", Color: "#2563eb", Icon: ``, Status: "coming-soon"}, - {Name: "Mail", Description: "Email client", URL: "/mail", Color: "#dc2626", Icon: ``, Status: "coming-soon"}, - {Name: "Calendar", Description: "Schedule & events", URL: "/calendar", Color: "#059669", Icon: ``, Status: "coming-soon"}, - {Name: "WorkSheets", Description: "Spreadsheets", URL: "/worksheets", Color: "#059669", Icon: ``, Status: "coming-soon"}, - {Name: "TypeWriter", Description: "Documents", URL: "/typewriter", Color: "#2563eb", Icon: ``, Status: "coming-soon"}, - {Name: "DeckCreator", Description: "Presentations", URL: "/deckcreator", Color: "#dc2626", Icon: ``, Status: "coming-soon"}, - {Name: "NotesFiles", Description: "Notes & files", URL: "/notesfiles", Color: "#d97706", Icon: ``, Status: "coming-soon"}, - {Name: "Contacts", Description: "People & directory", URL: "/contacts", Color: "#d97706", Icon: ``, Status: "coming-soon"}, - {Name: "Tasks", Description: "Project management", URL: "/tasks", Color: "#7c3aed", Icon: ``, Status: "coming-soon"}, - {Name: "Chat", Description: "Team messaging", URL: "/chat", Color: "#db2777", Icon: ``, Status: "coming-soon"}, - } - filtered := make([]AppTile, 0, len(all)) - for _, a := range all { - if a.AdminOnly && role != "admin" { - continue - } - filtered = append(filtered, a) - } - return filtered -} - -func AppGrid(apps []AppTile) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, a := range apps { - templ_7745c5c3_Err = appTile(a).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func appTile(a AppTile) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var2 := templ.GetChildren(ctx) - if templ_7745c5c3_Var2 == nil { - templ_7745c5c3_Var2 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templ.Raw(a.Icon).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 46, Col: 32} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Available
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/ui/app-page.templ b/src/core/ui/app-page.templ deleted file mode 100644 index 84dc6c7..0000000 --- a/src/core/ui/app-page.templ +++ /dev/null @@ -1,32 +0,0 @@ -package ui - -templ AppPage(name, icon, description string) { - - - - - - { name } — Next Workspace - - - -
-
{ icon }
-

{ name }

-
Coming Soon
-

{ description }

- ← Back to Workspace -
- - -} diff --git a/src/core/ui/app-page_templ.go b/src/core/ui/app-page_templ.go deleted file mode 100644 index 2857b10..0000000 --- a/src/core/ui/app-page_templ.go +++ /dev/null @@ -1,92 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package ui - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func AppPage(name, icon, description string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-page.templ`, Line: 9, Col: 16} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Next Workspace
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(icon) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-page.templ`, Line: 24, Col: 28} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-page.templ`, Line: 25, Col: 14} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Coming Soon

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(description) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-page.templ`, Line: 27, Col: 20} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

← Back to Workspace
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/ui/handler.go b/src/core/ui/handler.go deleted file mode 100644 index 2ee2c78..0000000 --- a/src/core/ui/handler.go +++ /dev/null @@ -1,103 +0,0 @@ -package ui - -import ( - "net/http" - "path/filepath" - "strings" - - "git.lohmar.co.uk/lexton-it/NextWks/core/auth" - "git.lohmar.co.uk/lexton-it/NextWks/core/i18n" -) - -// PageCtx holds page-level data passed to templates. -type PageCtx struct { - UserID string - Role string - Locale i18n.T -} - -type Handler struct { - appDir string - defaultLang string -} - -func NewHandler(appDir string, defaultLang string) *Handler { - return &Handler{appDir: appDir, defaultLang: defaultLang} -} - -func (h *Handler) RegisterRoutes(mux *http.ServeMux, authGate func(http.Handler) http.Handler) { - staticDir := filepath.Join(h.appDir, "static") - mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir)))) - mux.Handle("GET /", authGate(http.HandlerFunc(h.launcherPage))) - // App routes - for _, a := range DefaultApps("") { - if a.URL != "" && a.URL != "#" && a.URL != "/" && a.URL != "/admin" { - mux.Handle("GET "+a.URL, authGate(http.HandlerFunc(h.appPage(a.Name, a.Icon, a.Description)))) - } - } -} - -func (h *Handler) appPage(name, icon, desc string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - component := AppPage(name, icon, desc) - component.Render(r.Context(), w) - } -} - -func (h *Handler) launcherPage(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - ctx := h.getContext(r) - apps := DefaultApps(ctx.Role) - component := LauncherPage(ctx, apps) - component.Render(r.Context(), w) -} - -func (h *Handler) getContext(r *http.Request) PageCtx { - userID, _ := auth.GetUserID(r) - role, _ := r.Context().Value(auth.ContextRole).(string) - if role == "" { - role = "user" - } - - // Detect language: cookie > header > config default - lang := h.defaultLang - if lang == "" { - lang = "en" - } - if cookie, err := r.Cookie("lang"); err == nil { - lang = cookie.Value - } else if al := r.Header.Get("Accept-Language"); al != "" { - for _, l := range i18n.Supported() { - if strings.HasPrefix(al, l) { - lang = l - break - } - } - } - - return PageCtx{ - UserID: userID, - Role: role, - Locale: i18n.Get(lang), - } -} - -// ServeHTTP serves the launcher dashboard for GET /. -// This replaces the mux-based registration for proxy integration. -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - h.launcherPage(w, r) -} - -// StaticHandler returns an http.Handler for static file serving. -func (h *Handler) StaticHandler() http.Handler { - staticDir := filepath.Join(h.appDir, "static") - return http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))) -} diff --git a/src/core/ui/launcher.templ b/src/core/ui/launcher.templ deleted file mode 100644 index d552dd4..0000000 --- a/src/core/ui/launcher.templ +++ /dev/null @@ -1,362 +0,0 @@ -package ui - -import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version" - -templ LauncherPage(c PageCtx, apps []AppTile) { - - - - - - Next Workspace - - - - - - @workspaceCSS() - - -
- @topBar(c) -
- @greetingSection(c) - @AppGrid(apps) -
-
- NextWks v{ appver.Version } -
-
- - - - - -
- - - @launcherJS() - - -} - -templ greetingSection(page PageCtx) { -
-
{ initials(page.UserID) }
-
-

{ page.Locale["greeting_morning"] }, { page.UserID }

-

{ page.Locale["workspace_ready"] }

-
-
-} - -templ topBar(page PageCtx) { -
-
-
N
- NextWks -
-
- -
-
{ initials(page.UserID) }
- -
-
-
-} - -templ workspaceCSS() { - -} - -// --- Helpers --- - -func version() string { - return appver.Version -} - -func initials(name string) string { - if name == "" { return "?" } - if len(name) == 1 { return name } - return string(name[0]) -} - -func timeOfDay() string { - // Simple: always show "morning" for now - return "morning" -} - -// --- PWA Instructions --- - -templ pwaInstructions() { -

Desktop Chrome/Edge

-
    -
  1. Click the icon in the address bar
  2. -
  3. Click Install
  4. -
-

iOS Safari

-
    -
  1. Tap Share
  2. -
  3. Tap Add to Home Screen
  4. -
-

Android Chrome

-
    -
  1. Tap menu
  2. -
  3. Tap Install app
  4. -
- -} - -// --- JavaScript --- - -templ launcherJS() { - -} diff --git a/src/core/ui/launcher_templ.go b/src/core/ui/launcher_templ.go deleted file mode 100644 index 8693012..0000000 --- a/src/core/ui/launcher_templ.go +++ /dev/null @@ -1,436 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package ui - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version" - -func LauncherPage(c PageCtx, apps []AppTile) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Next Workspace") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = workspaceCSS().Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = topBar(c).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = greetingSection(c).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = AppGrid(apps).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
NextWks v") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(appver.Version) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 27, Col: 36} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Install Next Workspace

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = pwaInstructions().Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = launcherJS().Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func greetingSection(page PageCtx) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(initials(page.UserID)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 80, Col: 45} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(page.Locale["greeting_morning"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 82, Col: 40} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ", ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(page.UserID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 82, Col: 57} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(page.Locale["workspace_ready"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 83, Col: 38} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func topBar(page PageCtx) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
N
NextWks
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(initials(page.UserID)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 99, Col: 50} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(page.UserID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 101, Col: 47} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(page.Locale["settings"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 104, Col: 31} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(page.Locale["logout"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 108, Col: 29} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func workspaceCSS() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// --- Helpers --- - -func version() string { - return appver.Version -} - -func initials(name string) string { - if name == "" { - return "?" - } - if len(name) == 1 { - return name - } - return string(name[0]) -} - -func timeOfDay() string { - // Simple: always show "morning" for now - return "morning" -} - -// --- PWA Instructions --- -func pwaInstructions() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var19 := templ.GetChildren(ctx) - if templ_7745c5c3_Var19 == nil { - templ_7745c5c3_Var19 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

Desktop Chrome/Edge

  1. Click the icon in the address bar
  2. Click Install

iOS Safari

  1. Tap Share
  2. Tap Add to Home Screen

Android Chrome

  1. Tap menu
  2. Tap Install app
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// --- JavaScript --- -func launcherJS() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/ui/logout.templ b/src/core/ui/logout.templ deleted file mode 100644 index c7c182d..0000000 --- a/src/core/ui/logout.templ +++ /dev/null @@ -1,44 +0,0 @@ -package ui - -templ LogoutPage(authLogoutURL, workspaceURL string) { - - - - - - Logged Out — Next Workspace - - - -
-

Logged Out

-
✓ Session cleared
-

You have been logged out of Next Workspace. Your identity provider session will also be cleared.

- Re-login - if authLogoutURL != "" { - Sign out from Authelia - } -
- - - -} diff --git a/src/core/ui/logout_templ.go b/src/core/ui/logout_templ.go deleted file mode 100644 index a90e8f7..0000000 --- a/src/core/ui/logout_templ.go +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package ui - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func LogoutPage(authLogoutURL, workspaceURL string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Logged Out — Next Workspace

Logged Out

✓ Session cleared

You have been logged out of Next Workspace. Your identity provider session will also be cleared.

Re-login ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if authLogoutURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Sign out from Authelia") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/ui/pwa-guide.templ b/src/core/ui/pwa-guide.templ deleted file mode 100644 index d478cd1..0000000 --- a/src/core/ui/pwa-guide.templ +++ /dev/null @@ -1,46 +0,0 @@ -package ui - -templ PWAInstallPrompt() { -
-

🚀 Install Next Workspace

-

Install as an app for quick access and offline support.

- -
-} - -templ PWAGuideModal() { - -} diff --git a/src/core/ui/pwa-guide_templ.go b/src/core/ui/pwa-guide_templ.go deleted file mode 100644 index 7230973..0000000 --- a/src/core/ui/pwa-guide_templ.go +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package ui - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func PWAInstallPrompt() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

🚀 Install Next Workspace

Install as an app for quick access and offline support.

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func PWAGuideModal() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var2 := templ.GetChildren(ctx) - if templ_7745c5c3_Var2 == nil { - templ_7745c5c3_Var2 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Install Next Workspace

Your browser didn't show an automatic install prompt. Use the instructions below for your device.

🖥️ Desktop Chrome/Edge

  1. Click the install icon in the address bar (right side)
  2. Click Install in the popup
  3. The app will open in its own window

📱 iOS Safari

  1. Tap the Share button 📤 at the bottom of the screen
  2. Scroll down and tap Add to Home Screen
  3. Tap Add in the top-right corner
  4. The app icon will appear on your home screen

🤖 Android Chrome

  1. Tap the menu icon (three dots)
  2. Tap Install app or Add to Home screen
  3. Tap Install
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/src/core/version/version.go b/src/core/version/version.go deleted file mode 100644 index 5cc19f3..0000000 --- a/src/core/version/version.go +++ /dev/null @@ -1,21 +0,0 @@ -package version - -var ( - // Version is set at build time via ldflags: -X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=2026.6.0001 - Version = "dev" - - // BuildTime is set at build time via ldflags. - BuildTime = "unknown" - - // CommitSHA is set at build time via ldflags. - CommitSHA = "unknown" -) - -// Info returns a formatted version info response. -func Info() map[string]string { - return map[string]string{ - "version": Version, - "build_time": BuildTime, - "commit": CommitSHA, - } -} diff --git a/src/go.mod b/src/go.mod deleted file mode 100644 index ad4bdf2..0000000 --- a/src/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -module git.lohmar.co.uk/lexton-it/NextWks - -go 1.25.0 - -require ( - github.com/a-h/templ v0.3.1020 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/chi/v5 v5.3.0 // indirect - github.com/go-chi/cors v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/sys v0.46.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.72.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.52.0 // indirect -) diff --git a/src/go.sum b/src/go.sum deleted file mode 100644 index a970c08..0000000 --- a/src/go.sum +++ /dev/null @@ -1,34 +0,0 @@ -github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= -github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= -github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= -modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/src/main.go b/src/main.go deleted file mode 100644 index 1230dc7..0000000 --- a/src/main.go +++ /dev/null @@ -1,351 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "os/signal" - "path/filepath" - "syscall" - - "git.lohmar.co.uk/lexton-it/NextWks/core/admin" - "git.lohmar.co.uk/lexton-it/NextWks/core/auth" - "git.lohmar.co.uk/lexton-it/NextWks/core/config" - "git.lohmar.co.uk/lexton-it/NextWks/core/proxy" - "git.lohmar.co.uk/lexton-it/NextWks/core/db" - "git.lohmar.co.uk/lexton-it/NextWks/core/email" - "git.lohmar.co.uk/lexton-it/NextWks/core/ui" - "git.lohmar.co.uk/lexton-it/NextWks/core/version" - - "github.com/caddyserver/certmagic" -) - -func main() { - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - - // Config path: default to ./config.yaml for dev, override with -config for production - configPath := flag.String("config", "./config.yaml", "path to configuration file") - flag.Parse() - - logger.Info("starting Next Workspace (NextWks)", "version", version.Version, "config", *configPath) - - // Load configuration - cfg, err := config.Load(*configPath) - if err != nil { - logger.Error("failed to load config", "error", err) - os.Exit(1) - } - - // Initialize database - database, err := db.Initialize(cfg.Database.Path) - if err != nil { - logger.Error("failed to initialize database", "error", err) - os.Exit(1) - } - defer database.Close() - - // Run schema migrations - if err := database.Migrate(); err != nil { - logger.Error("failed to run migrations", "error", err) - os.Exit(1) - } - logger.Info("database initialized and migrated", "path", cfg.Database.Path) - - // Initialize admin components - userStore := admin.NewUserStore(database.DB) - syncWriter := admin.NewSyncWriter(cfg.Authelia.UsersDBPath, userStore) - - // Bootstrap: import existing Authelia users if this is a fresh start - imported, err := syncWriter.Bootstrap() - if err != nil { - logger.Warn("bootstrap authelia users", "error", err) - } else if imported > 0 { - logger.Info("bootstrapped authelia users", "count", imported) - } - - // Fix existing user roles based on groups - if fixed, err := syncWriter.FixRoles(); err != nil { - logger.Warn("fix roles", "error", err) - } else if fixed > 0 { - logger.Info("fixed user roles", "count", fixed) - } - - // Create email sender - emailSender := email.NewSender(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.From, logger) - - // Create admin handler - adminHandler := admin.NewHandler(userStore, admin.NewGroupStore(database.DB), syncWriter, emailSender, logger, *configPath) - - // Initialize session store and OIDC auth - sessionStore := auth.NewSessionStore(database.DB) - roleChecker := auth.NewRoleChecker(database.DB) - - // OIDC issuer: public-facing URL (via Zoraxy) for browser redirects - // Falls back to authelia.host if not configured - issuerURL := cfg.OIDC.IssuerURL - if issuerURL == "" { - issuerURL = cfg.Authelia.Host - } - oidcCfg := auth.OIDCConfig{ - IssuerURL: issuerURL, - ClientID: cfg.OIDC.ClientID, - ClientSecret: cfg.OIDC.ClientSecret, - RedirectURL: cfg.OIDC.RedirectURL, - } - oidcHandler := auth.NewOIDCHandler(oidcCfg, sessionStore) - - // Initialize launcher UI handler - // appDir is the directory containing config.yaml (and static/ subdir) - appDir := filepath.Dir(*configPath) - if appDir == "." { - appDir = "./" - } - uiHandler := ui.NewHandler(appDir, cfg.Locale.Language) - - // Setup HTTP router - mux := http.NewServeMux() - - // Initialize reverse proxy - prx, err := proxy.New([]proxy.Route{ - {Path: "/auth/", Target: fmt.Sprintf("http://127.0.0.1:%d", 9091), StripPrefix: false}, - }) - if err != nil { - logger.Error("failed to initialize proxy", "error", err) - os.Exit(1) - } - - // Friendly greeting for unmatched proxy routes - prx.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("\n\n\n \n \n NextWks\n \n\n\n
\n

Next Workspace

\n

This application isn't available yet. It may still be provisioning or the route hasn't been configured.

\n Return to Dashboard\n
\n\n")) - }) - - // --- Auth middleware --- - // combinedAuth is preserved for when Authelia is deployed. - // Currently unused — root handler serves greeting directly for testability. - combinedAuth := func(next http.Handler) http.Handler { - return sessionStore.SessionMiddleware(oidcHandler.AuthGateMiddleware(next)) - } - _ = combinedAuth // suppress unused while Authelia is not deployed - bearerAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken) - adminAuth := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if token := r.Header.Get("Authorization"); token != "" { - bearerAuth(next).ServeHTTP(w, r) - return - } - cookie, err := r.Cookie("nextwks_session") - if err != nil || cookie == nil { - oidcHandler.LoginRedirect(w, r) - return - } - session, err := sessionStore.ValidateSession(cookie.Value) - if err != nil || session == nil { - oidcHandler.LoginRedirect(w, r) - return - } - isAdmin, _ := roleChecker.IsAdmin(session.UserID) - if isAdmin { - ctx := context.WithValue(r.Context(), auth.ContextUserID, session.UserID) - ctx = context.WithValue(ctx, auth.ContextRole, "admin") - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - http.Error(w, "{\"error\":\"admin access required\"}", http.StatusForbidden) - }) - } - - // --- Public endpoints (on mux, before catch-all) --- - mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) - }) - mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(version.Info()) - }) - - // --- OIDC auth routes (explicit mux patterns — take precedence over catch-all) --- - mux.HandleFunc("GET /auth/login", oidcHandler.LoginRedirect) - mux.HandleFunc("GET /access", oidcHandler.Callback) - mux.HandleFunc("POST /access", oidcHandler.Callback) - mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) { - // Check if this is the return from Authelia logout (no NextWks cookie) - if _, err := r.Cookie("nextwks_session"); err != nil { - // Second visit: show logout confirmation page - component := ui.LogoutPage("", cfg.OIDC.RedirectURL) - component.Render(r.Context(), w) - return - } - // First visit: clear cookie and redirect to Authelia logout - http.SetCookie(w, &http.Cookie{ - Name: "nextwks_session", Value: "", Path: "/", - MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, - }) - logoutURL := fmt.Sprintf("%s/logout?rd=%s/auth/logout", cfg.OIDC.IssuerURL, cfg.OIDC.RedirectURL) - http.Redirect(w, r, logoutURL, http.StatusFound) - }) - mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"provider":"Authelia","issuer":"%s","status":"configured"}`, cfg.Authelia.Host) - }) - mux.HandleFunc("GET /pwa-guide", func(w http.ResponseWriter, r *http.Request) { - component := ui.PWAGuideModal() - component.Render(r.Context(), w) - }) - - // --- Proxy routes (all traffic through proxy as catch-all) --- - // Root handler: serves greeting page for all paths when Authelia is not yet deployed. - // Once Authelia is running, enable auth by passing through combinedAuth. - launcherHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // For now: serve the greeting/boilerplate page for all paths. - // This proves networking works before Authelia is deployed. - // When Authelia is ready, replace this with: - // combinedAuth(uiHandler).ServeHTTP(w, r) — for exact "/" - // prx.NotFoundHandler.ServeHTTP(w, r) — for everything else - prx.NotFoundHandler.ServeHTTP(w, r) - }) - - // Admin routes use a sub-mux with no-op auth (auth is applied at proxy route level) - adminMux := http.NewServeMux() - adminHandler.RegisterRoutes(adminMux, func(next http.Handler) http.Handler { return next }) - adminHandler.RegisterUIRoutes(adminMux, func(next http.Handler) http.Handler { return next }) - adminHandler.RegisterHTMXRoutes(adminMux, func(next http.Handler) http.Handler { return next }) - - // Add routes to proxy (longest prefix wins — order matters) - prx.AddRoute(proxy.Route{Path: "/auth/", Target: fmt.Sprintf("http://127.0.0.1:%d", 9091)}) - prx.AddRoute(proxy.Route{Path: "/static/", Handler: uiHandler.StaticHandler()}) - prx.AddRoute(proxy.Route{Path: "/admin/", Handler: adminAuth(adminMux)}) - prx.AddRoute(proxy.Route{Path: "/", Handler: launcherHandler}) - - // Catch-all: everything else goes through proxy - mux.Handle("/", prx.Handler()) - - // CORS middleware - handler := corsMiddleware(mux) - - // Start server - if cfg.TLS.Enabled && cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { - // --- File-based TLS (self-signed or custom cert) --- - // TLS listener on :443 - tlsAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, 443) - tlsServer := &http.Server{ - Addr: tlsAddr, - Handler: handler, - } - - // HTTP listener on configured port (for redirect or mixed mode) - httpAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - httpServer := &http.Server{ - Addr: httpAddr, - Handler: handler, - } - - // Graceful shutdown - go func() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - logger.Info("shutting down server...") - tlsServer.Close() - httpServer.Close() - }() - - go func() { - logger.Info("server listening (TLS)", "address", tlsAddr) - logger.Info("workspace launcher", "url", "https://"+cfg.Server.Host+":443/") - logger.Info("admin panel", "url", "https://"+cfg.Server.Host+":443/admin") - if err := tlsServer.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile); err != http.ErrServerClosed { - logger.Error("tls server error", "error", err) - os.Exit(1) - } - }() - - logger.Info("server listening (HTTP)", "address", httpAddr) - if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { - logger.Error("http server error", "error", err) - os.Exit(1) - } - } else if cfg.TLS.Enabled { - // --- TLS mode: certmagic on :443, HTTP→HTTPS redirect on :80 --- - certmagic.DefaultACME.Agreed = true - certmagic.DefaultACME.Email = cfg.TLS.Email - certmagic.Default.Storage = &certmagic.FileStorage{Path: cfg.TLS.StoragePath} - if cfg.TLS.Staging { - certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA - } - - // certmagic.HTTPS handles ACME challenges, TLS, and HTTP→HTTPS redirect - go func() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - logger.Info("shutting down server...") - os.Exit(0) - }() - - logger.Info("server listening (TLS)", "domain", cfg.TLS.Domain) - logger.Info("workspace launcher", "url", "https://"+cfg.TLS.Domain+"/") - logger.Info("admin panel", "url", "https://"+cfg.TLS.Domain+"/admin") - logger.Info("auth status", "url", "https://"+cfg.TLS.Domain+"/auth/status") - if err := certmagic.HTTPS([]string{cfg.TLS.Domain}, handler); err != nil { - logger.Error("certmagic server error", "error", err) - os.Exit(1) - } - } else { - // --- Plain HTTP mode --- - addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - server := &http.Server{ - Addr: addr, - Handler: handler, - } - - // Graceful shutdown - go func() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - logger.Info("shutting down server...") - server.Close() - }() - - logger.Info("server listening", "address", addr) - logger.Info("workspace launcher", "url", fmt.Sprintf("http://%s/", addr)) - logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr)) - logger.Info("auth status", "url", fmt.Sprintf("http://%s/auth/status", addr)) - if err := server.ListenAndServe(); err != http.ErrServerClosed { - logger.Error("server error", "error", err) - os.Exit(1) - } - } -} - -// corsMiddleware adds CORS headers for frontend access. -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - next.ServeHTTP(w, r) - }) -} - -func domainFromURL(rawURL string) string { - u, err := url.Parse(rawURL) - if err != nil { - return "" - } - return u.Hostname() -} diff --git a/testdata/authelia/configuration.yml b/testdata/authelia/configuration.yml deleted file mode 100644 index c337990..0000000 --- a/testdata/authelia/configuration.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Authelia Configuration (Development Mock) -# Path: used for testing config parsing - -host: 0.0.0.0 -port: 9091 - -log: - level: debug - -jwt_secret: dev-jwt-secret-change-in-production - -session: - name: authelia_session - secret: dev-authelia-session-secret-please-change - expiration: 1h - inactivity: 5m - -storage: - local: - path: /opt/authelia/data/db.sqlite - -access_control: - default_policy: deny - -authentication_backend: - file: - path: /opt/authelia/data/users_database.yml diff --git a/update.sh b/update.sh deleted file mode 100755 index db61153..0000000 --- a/update.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -# ============================================================ -# NextWks — Production Update Script -# Pulls latest code, rebuilds, restarts NextWks only. -# Users & auth are managed by Authelia (untouched). -# -# Run: cd /opt/nextworkspace && sudo bash update.sh -# cd /opt/nextworkspace && sudo bash update.sh --wipe-db -# ============================================================ -set -euo pipefail - -GREEN='\033[0;32m'; BLUE='\033[0;34m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' -info() { echo -e "${BLUE}$1${NC}"; } -ok() { echo -e "${GREEN}$1${NC}"; } -warn() { echo -e "${YELLOW}$1${NC}"; } -error() { echo -e "${RED}$1${NC}"; } - -REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git" -REPO_DIR="/tmp/nextwks-update" -INSTALL_DIR="/opt/nextworkspace" -BIN_DIR="${INSTALL_DIR}/bin" -DATA_DIR="${INSTALL_DIR}/data" -WIPE_DB=false -[ "${1:-}" = "--wipe-db" ] && WIPE_DB=true - -info "Updating NextWks..." -[ "$WIPE_DB" = true ] && warn "Database wipe requested — users will be restored from Authelia" - -# Clone fresh copy -rm -rf "$REPO_DIR" 2>/dev/null -git clone --depth 1 "$REPO_URL" "$REPO_DIR" --quiet - -VERSION=$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo "dev") -COMMIT_SHA=$(cd "$REPO_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") - -# Build -cd "$REPO_DIR/src" -go build -ldflags="-s -w \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=${VERSION} \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ - -X git.lohmar.co.uk/lexton-it/NextWks/core/version.CommitSHA=$(git rev-parse --short HEAD)" \ - -o /tmp/nextwks-core . - -# Stop, optionally wipe, replace, restart -systemctl stop nextwks - -if [ "$WIPE_DB" = true ]; then - find "$DATA_DIR" -name "*.db*" -delete 2>/dev/null || true - ok "Database wiped" -fi - -cp /tmp/nextwks-core "$BIN_DIR/core" -chmod 755 "$BIN_DIR/core" -chown nextwks:nextwks "$BIN_DIR/core" -chown -R nextwks:nextwks "$DATA_DIR" 2>/dev/null || true -systemctl start nextwks -sleep 2 - -# Verify -if curl -s --max-time 3 http://localhost:8080/api/health | grep -q ok; then - ok "NextWks updated to v${VERSION} (${COMMIT_SHA})" - [ "$WIPE_DB" = true ] && info "Users bootstrapped from Authelia" -else - error "Health check failed — check: sudo journalctl -u nextwks -n 20" -fi - -# Cleanup -rm -rf "$REPO_DIR" /tmp/nextwks-core