feat: hello world pipeline proof
This commit is contained in:
parent
937250fa28
commit
2d3832df0b
63 changed files with 113 additions and 8644 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
|
nextworkspace
|
||||||
app/core
|
app/core
|
||||||
app/core.exe
|
app/core.exe
|
||||||
app/data/*.db
|
app/data/*.db
|
||||||
|
|
|
||||||
180
README.md
180
README.md
|
|
@ -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 <admin.secret_token>` header.
|
|
||||||
|
|
||||||
### List Users
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/admin/api/users
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create User
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/admin/api/users \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"users":[{"username":"jdoe","display_name":"John Doe","email":"john@example.com","groups":"users"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete User
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:8080/admin/api/users/jdoe \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/admin/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authelia Integration
|
|
||||||
|
|
||||||
NextWks acts as a management layer for Authelia. When users are created or deleted:
|
|
||||||
|
|
||||||
1. The user is stored in NextWks' SQLite database (source of truth)
|
|
||||||
2. The user is automatically synchronized to `/opt/authelia/users_database.yml`
|
|
||||||
3. Authelia detects the file change (watch mode) and reloads
|
|
||||||
|
|
||||||
On first boot, NextWks bootstraps existing Authelia users into its database.
|
|
||||||
|
|
||||||
**OIDC**: Authelia must be configured with the `nextwks` client. See the [Authelia configuration guide](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
1
VERSION
1
VERSION
|
|
@ -1 +0,0 @@
|
||||||
2026.6.0007
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
|
||||||
<rect width="192" height="192" rx="32" fill="#1e293b"/>
|
|
||||||
<rect x="32" y="32" width="128" height="128" rx="24" fill="#3b82f6"/>
|
|
||||||
<path d="M72 72 L120 72 M72 96 L104 96 M72 120 L88 120" stroke="#ffffff" stroke-width="8" stroke-linecap="round" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 354 B |
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
||||||
<rect width="512" height="512" rx="64" fill="#1e293b"/>
|
|
||||||
<rect x="96" y="96" width="320" height="320" rx="48" fill="#3b82f6"/>
|
|
||||||
<path d="M176 240 L336 240 M176 304 L288 304 M176 368 L224 368" stroke="#ffffff" stroke-width="16" stroke-linecap="round" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 363 B |
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Next Workspace",
|
|
||||||
"short_name": "NextWks",
|
|
||||||
"description": "Your self-hosted workspace platform",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#0f172a",
|
|
||||||
"theme_color": "#3b82f6",
|
|
||||||
"orientation": "any",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/static/icons/icon-192.svg",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/static/icons/icon-512.svg",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"categories": ["productivity", "utilities"],
|
|
||||||
"lang": "en",
|
|
||||||
"dir": "ltr"
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// Next Workspace - Service Worker
|
|
||||||
// Cache name includes timestamp to force update on deploy
|
|
||||||
const CACHE_NAME = 'nextwks-v1';
|
|
||||||
const STATIC_ASSETS = [
|
|
||||||
'/',
|
|
||||||
'/static/manifest.json',
|
|
||||||
'/static/icons/icon-192.svg',
|
|
||||||
'/static/icons/icon-512.svg',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Install: cache static assets
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
return cache.addAll(STATIC_ASSETS);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate: clean old caches
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) => {
|
|
||||||
return Promise.all(
|
|
||||||
keys
|
|
||||||
.filter((key) => key !== CACHE_NAME)
|
|
||||||
.map((key) => caches.delete(key))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch: serve from cache first, fall back to network
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
// Only handle GET requests
|
|
||||||
if (event.request.method !== 'GET') return;
|
|
||||||
|
|
||||||
// For navigation requests, always go to network
|
|
||||||
if (event.request.mode === 'navigate') {
|
|
||||||
event.respondWith(fetch(event.request).catch(() => caches.match('/')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For static assets, try cache first
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((cached) => {
|
|
||||||
return cached || fetch(event.request).then((response) => {
|
|
||||||
// Cache successful responses for static assets
|
|
||||||
if (response.status === 200 && event.request.url.includes('/static/')) {
|
|
||||||
const clone = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
266
deploy.sh
266
deploy.sh
|
|
@ -1,231 +1,75 @@
|
||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# ============================================================
|
|
||||||
# NextWks — Production Deploy Script
|
|
||||||
# Idempotent: safe for first-time setup and subsequent updates.
|
|
||||||
# Run: sudo bash deploy.sh
|
|
||||||
# ============================================================
|
|
||||||
set -euo pipefail
|
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'
|
# NextWorkspace Deploy — clean-slate deployment
|
||||||
info() { echo -e "${BLUE}[+]${NC} $1"; }
|
HEALTH_CHECK_RETRIES=10
|
||||||
ok() { echo -e "${GREEN}[✓]${NC} $1"; }
|
HEALTH_CHECK_INTERVAL=2
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
|
||||||
die() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
|
|
||||||
header(){ echo -e "\n${BOLD}${CYAN}── $1 ──${NC}"; }
|
|
||||||
|
|
||||||
# ============================================================
|
REPO_DIR="/opt/NextWks"
|
||||||
# CONFIG
|
TARGET_DIR="/opt/nextworkspace"
|
||||||
# ============================================================
|
SERVICE_NAME="nextworkspace"
|
||||||
INSTALL_DIR="/opt/nextworkspace"
|
BINARY_NAME="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"
|
|
||||||
|
|
||||||
# Must be root
|
echo "=== NextWorkspace Deploy ==="
|
||||||
[ "${EUID:-$(id -u)}" -ne 0 ] && die "Run as root: sudo bash deploy.sh"
|
|
||||||
|
|
||||||
# ============================================================
|
# 1. Navigate to repo and pull latest
|
||||||
# PHASE 1: DIRECTORY SCAFFOLD + SERVICE USER
|
cd "$REPO_DIR"
|
||||||
# ============================================================
|
echo "[1/8] Pulling latest code..."
|
||||||
header "Phase 1: Directory Structure"
|
git pull
|
||||||
|
|
||||||
if ! id "$SVC_USER" &>/dev/null; then
|
# 2. Build
|
||||||
useradd -r -s /usr/sbin/nologin -d /nonexistent "$SVC_USER"
|
echo "[2/8] Building binary..."
|
||||||
ok "Created service user: $SVC_USER"
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
else
|
go build -o "$BINARY_NAME" .
|
||||||
info "Service user exists: $SVC_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$SRC_DIR" "$CONFIG_DIR/apps.d" "$DATA_DIR" "$CERTS_DIR" "$LOGS_DIR" "$STATIC_DIR"
|
# 3. Remove old deployment
|
||||||
chown -R "$SVC_USER:$SVC_USER" "$DATA_DIR" "$CERTS_DIR" "$LOGS_DIR" "$STATIC_DIR"
|
echo "[3/8] Removing old deployment..."
|
||||||
ok "Directory tree created at $INSTALL_DIR/"
|
rm -rf "$TARGET_DIR"
|
||||||
|
|
||||||
# ============================================================
|
# 4. Create target directories
|
||||||
# PHASE 2: FETCH + BUILD
|
echo "[4/8] Creating target directories..."
|
||||||
# ============================================================
|
mkdir -p "$TARGET_DIR/app/data"
|
||||||
header "Phase 2: Build"
|
mkdir -p "$TARGET_DIR/app/static"
|
||||||
|
|
||||||
if [ -d "$SRC_DIR/.git" ]; then
|
# 5. Copy binary
|
||||||
info "Pulling latest from origin..."
|
echo "[5/8] Copying binary..."
|
||||||
cd "$SRC_DIR"
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
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
|
|
||||||
|
|
||||||
VERSION=$(cat "$SRC_DIR/VERSION" 2>/dev/null || echo "dev")
|
# 6. Write systemd service
|
||||||
COMMIT_SHA=$(cd "$SRC_DIR" && git rev-parse --short HEAD)
|
echo "[6/8] Writing systemd service..."
|
||||||
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
|
||||||
|
|
||||||
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
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Next Workspace (NextWks) Core
|
Description=NextWorkspace
|
||||||
Documentation=https://git.lohmar.co.uk/lexton-it/NextWks
|
After=network.target
|
||||||
After=network-online.target authelia.service
|
|
||||||
Wants=network-online.target authelia.service
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Environment=PORT=80
|
||||||
User=${SVC_USER}
|
ExecStart=$TARGET_DIR/$BINARY_NAME
|
||||||
Group=${SVC_USER}
|
WorkingDirectory=$TARGET_DIR
|
||||||
WorkingDirectory=${INSTALL_DIR}
|
Restart=always
|
||||||
ExecStart=${BIN_PATH} -config ${CONFIG_PATH}
|
User=root
|
||||||
Restart=on-failure
|
Group=root
|
||||||
RestartSec=5
|
|
||||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=yes
|
|
||||||
ReadWritePaths=${DATA_DIR} ${LOGS_DIR} ${CERTS_DIR}
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
SERVICEEOF
|
UNIT
|
||||||
|
|
||||||
|
# 7. Reload systemd and restart
|
||||||
|
echo "[7/8] Reloading systemd and restarting service..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable nextwks 2>/dev/null || true
|
systemctl enable $SERVICE_NAME
|
||||||
systemctl restart nextwks
|
systemctl restart $SERVICE_NAME
|
||||||
ok "Systemd unit installed and service restarted"
|
|
||||||
|
|
||||||
# ============================================================
|
# 8. Health check
|
||||||
# PHASE 5: HEALTH CHECK
|
echo "[8/8] Running health check..."
|
||||||
# ============================================================
|
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||||
header "Phase 5: Health Check"
|
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
|
echo "[FAIL] Health check failed — service did not respond on port 80"
|
||||||
if curl -s --max-time 5 http://localhost:8080/api/health 2>/dev/null | grep -q '"status":"ok"'; then
|
exit 1
|
||||||
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 ""
|
|
||||||
|
|
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module nextworkspace
|
||||||
|
|
||||||
|
go 1.22
|
||||||
543
install.sh
543
install.sh
|
|
@ -1,523 +1,44 @@
|
||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# ============================================================
|
|
||||||
# Next Workspace (NextWks) — Installer
|
|
||||||
# ============================================================
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Colors
|
# NextWorkspace Installer — bootstraps a bare Linux VM
|
||||||
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'
|
# Idempotent: safe to run multiple times.
|
||||||
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}"; }
|
|
||||||
|
|
||||||
# ============================================================
|
echo "=== NextWorkspace Installer ==="
|
||||||
# SERVICE USER
|
|
||||||
# ============================================================
|
|
||||||
SVC_USER="nextwks"
|
|
||||||
|
|
||||||
# Create service user if needed, and set sudo wrapper
|
# ---- Go ----
|
||||||
if ! id "$SVC_USER" &>/dev/null; then
|
if command -v go &>/dev/null; then
|
||||||
useradd -r -s /usr/sbin/nologin -d /nonexistent "$SVC_USER" 2>/dev/null || true
|
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
|
fi
|
||||||
[ "$EUID" -eq 0 ] && SUDO="" || SUDO="sudo"
|
|
||||||
|
|
||||||
# ============================================================
|
# ---- System deps ----
|
||||||
# PATHS
|
echo "[INSTALL] git, build-essential..."
|
||||||
# ============================================================
|
apt-get update -qq
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
apt-get install -y -qq git build-essential curl
|
||||||
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"
|
|
||||||
|
|
||||||
# ============================================================
|
# ---- Clone / pull repo ----
|
||||||
# DEFAULTS
|
REPO_DIR="/opt/NextWks"
|
||||||
# ============================================================
|
|
||||||
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
|
|
||||||
# ============================================================
|
|
||||||
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
|
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
|
||||||
|
|
||||||
# If piped from curl (no script file), save and exit
|
if [ -d "$REPO_DIR/.git" ]; then
|
||||||
if [ ! -t 0 ] && [ ! -f "${BASH_SOURCE[0]}" ]; then
|
echo "[UPDATE] Repository exists — pulling latest..."
|
||||||
SCRIPT_FILE="/tmp/nextwks-install.sh"
|
cd "$REPO_DIR"
|
||||||
cat > "$SCRIPT_FILE"
|
git pull
|
||||||
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"
|
|
||||||
else
|
else
|
||||||
gather_inputs
|
echo "[CLONE] Cloning repository..."
|
||||||
|
git clone "$REPO_URL" "$REPO_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Derive domains from URLs (needed even when --from-env)
|
echo "[DONE] Bootstrapping complete. Running first deploy..."
|
||||||
NEXTWKS_DOMAIN=$(echo "$NEXTWKS_URL" | sed 's|https\?://||;s|/.*||')
|
"$REPO_DIR/deploy.sh"
|
||||||
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 ""
|
|
||||||
|
|
|
||||||
22
main.go
Normal file
22
main.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# install-authelia.sh
|
|
||||||
# Installs and configures Authelia IDP for NextWks
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 1. CONFIGURATION / VARIABLES
|
|
||||||
# ==========================================
|
|
||||||
DOMAIN="sechpoint.app"
|
|
||||||
AUTH_SUBDOMAIN="auth.${DOMAIN}"
|
|
||||||
SMTP_HOST="smtp.openxchange.eu"
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER="post@sechpoint.app"
|
|
||||||
SMTP_PASS="0@pYAY14mB"
|
|
||||||
|
|
||||||
# Initial Admin Setup
|
|
||||||
ADMIN_USER="admin"
|
|
||||||
ADMIN_EMAIL="cl@${DOMAIN}"
|
|
||||||
ADMIN_PASSWORD="ueM8tLARi5v3orIzvd56w6u6!" # This will be hashed automatically
|
|
||||||
|
|
||||||
# Bulk Onboarding List (Format: "username:DisplayName:email")
|
|
||||||
USER_LIST=(
|
|
||||||
"clohmar:Claus Lohmar:cl@${DOMAIN}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
AUTHELIA_DIR="/opt/authelia"
|
|
||||||
AUTHELIA_VERSION="v4.38.0"
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 2. INSTALLATION & PREPARATION
|
|
||||||
# ==========================================
|
|
||||||
echo "Installing prerequisites and downloading Authelia..."
|
|
||||||
apt-get update && apt-get install -y wget curl tar openssl jq
|
|
||||||
|
|
||||||
mkdir -p "$AUTHELIA_DIR"
|
|
||||||
wget -q "https://github.com/authelia/authelia/releases/download/${AUTHELIA_VERSION}/authelia-${AUTHELIA_VERSION}-linux-amd64.tar.gz" -O /tmp/authelia.tar.gz
|
|
||||||
tar -xzf /tmp/authelia.tar.gz -C "$AUTHELIA_DIR"
|
|
||||||
mv "$AUTHELIA_DIR/authelia-linux-amd64" "$AUTHELIA_DIR/authelia"
|
|
||||||
chmod +x "$AUTHELIA_DIR/authelia"
|
|
||||||
|
|
||||||
# Generate Secrets
|
|
||||||
JWT_SECRET=$(openssl rand -base64 32)
|
|
||||||
SESSION_SECRET=$(openssl rand -base64 32)
|
|
||||||
STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
|
||||||
|
|
||||||
# Generate Hash for the Initial Admin
|
|
||||||
ADMIN_HASH=$("$AUTHELIA_DIR/authelia" crypto hash generate --password "$ADMIN_PASSWORD" | awk '{print $NF}')
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 3. GENERATE USER DATABASE (BULK ONBOARDING)
|
|
||||||
# ==========================================
|
|
||||||
echo "Generating user database..."
|
|
||||||
cat <<EOF > "${AUTHELIA_DIR}/users_database.yml"
|
|
||||||
users:
|
|
||||||
${ADMIN_USER}:
|
|
||||||
displayname: "System Administrator"
|
|
||||||
password: "${ADMIN_HASH}"
|
|
||||||
email: "${ADMIN_EMAIL}"
|
|
||||||
groups: [admins]
|
|
||||||
EOF
|
|
||||||
|
|
||||||
for entry in "${USER_LIST[@]}"; do
|
|
||||||
IFS=":" read -r uname dname uemail <<< "$entry"
|
|
||||||
cat <<EOF >> "${AUTHELIA_DIR}/users_database.yml"
|
|
||||||
${uname}:
|
|
||||||
displayname: "${dname}"
|
|
||||||
password: "${ADMIN_HASH}" # Everyone starts with the same temp password
|
|
||||||
email: "${uemail}"
|
|
||||||
groups: [users]
|
|
||||||
EOF
|
|
||||||
done
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 4. GENERATE MAIN CONFIGURATION
|
|
||||||
# ==========================================
|
|
||||||
echo "Generating Authelia configuration..."
|
|
||||||
cat <<EOF > "${AUTHELIA_DIR}/configuration.yml"
|
|
||||||
theme: light
|
|
||||||
jwt_secret: "${JWT_SECRET}"
|
|
||||||
default_redirection_url: "https://${DOMAIN}"
|
|
||||||
|
|
||||||
server:
|
|
||||||
host: 0.0.0.0
|
|
||||||
port: 9091
|
|
||||||
|
|
||||||
authentication_backend:
|
|
||||||
password_reset:
|
|
||||||
disable: false
|
|
||||||
file:
|
|
||||||
path: "${AUTHELIA_DIR}/users_database.yml"
|
|
||||||
watch: true
|
|
||||||
|
|
||||||
session:
|
|
||||||
name: authelia_session
|
|
||||||
secret: "${SESSION_SECRET}"
|
|
||||||
domain: "${DOMAIN}"
|
|
||||||
expiration: 1h
|
|
||||||
inactivity: 5m
|
|
||||||
|
|
||||||
notifier:
|
|
||||||
smtp:
|
|
||||||
host: "${SMTP_HOST}"
|
|
||||||
port: ${SMTP_PORT}
|
|
||||||
username: "${SMTP_USER}"
|
|
||||||
password: "${SMTP_PASS}"
|
|
||||||
sender: "Authelia <${SMTP_USER}>"
|
|
||||||
|
|
||||||
storage:
|
|
||||||
encryption_key: "${STORAGE_ENCRYPTION_KEY}"
|
|
||||||
local:
|
|
||||||
path: "${AUTHELIA_DIR}/db.sqlite3"
|
|
||||||
|
|
||||||
access_control:
|
|
||||||
default_policy: deny
|
|
||||||
rules:
|
|
||||||
- domain: "${AUTH_SUBDOMAIN}"
|
|
||||||
policy: bypass
|
|
||||||
- domain: "*.${DOMAIN}"
|
|
||||||
policy: two_factor
|
|
||||||
|
|
||||||
totp:
|
|
||||||
issuer: authelia.com
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 5. SYSTEMD & PERMISSIONS
|
|
||||||
# ==========================================
|
|
||||||
chown -R root:root "$AUTHELIA_DIR"
|
|
||||||
chmod 600 "${AUTHELIA_DIR}/configuration.yml"
|
|
||||||
chmod 600 "${AUTHELIA_DIR}/users_database.yml"
|
|
||||||
|
|
||||||
cat <<EOF > /etc/systemd/system/authelia.service
|
|
||||||
[Unit]
|
|
||||||
Description=Authelia Identity Provider
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=${AUTHELIA_DIR}
|
|
||||||
ExecStart=${AUTHELIA_DIR}/authelia --config ${AUTHELIA_DIR}/configuration.yml
|
|
||||||
Restart=always
|
|
||||||
User=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now authelia
|
|
||||||
|
|
||||||
echo "-------------------------------------------------------"
|
|
||||||
echo "Authelia Installation Complete!"
|
|
||||||
echo "Authelia is running on port 9091"
|
|
||||||
echo "Config: ${AUTHELIA_DIR}/configuration.yml"
|
|
||||||
echo "Users: ${AUTHELIA_DIR}/users_database.yml"
|
|
||||||
echo "Next step: Configure your Reverse Proxy for ${AUTH_SUBDOMAIN}"
|
|
||||||
echo "-------------------------------------------------------"
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
checks := []struct {
|
|
||||||
path string
|
|
||||||
purpose string
|
|
||||||
mustExist bool
|
|
||||||
}{
|
|
||||||
{"/opt/nextwks/config.yaml", "NextWks configuration", true},
|
|
||||||
{"/opt/nextwks/bin", "Binary output directory", true},
|
|
||||||
{"/opt/nextwks/data", "Data directory", true},
|
|
||||||
{"/opt/authelia/config/configuration.yml", "Authelia mock configuration", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
allPassed := true
|
|
||||||
for _, c := range checks {
|
|
||||||
_, err := os.Stat(c.path)
|
|
||||||
if c.mustExist && os.IsNotExist(err) {
|
|
||||||
fmt.Printf("❌ MISSING: %s (%s)\n", c.path, c.purpose)
|
|
||||||
allPassed = false
|
|
||||||
} else if c.mustExist && err != nil {
|
|
||||||
fmt.Printf("❌ ERROR: %s - %v\n", c.path, err)
|
|
||||||
allPassed = false
|
|
||||||
} else {
|
|
||||||
fmt.Printf("✅ OK: %s (%s)\n", c.path, c.purpose)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allPassed {
|
|
||||||
fmt.Println("\n✅ All system paths verified!")
|
|
||||||
} else {
|
|
||||||
fmt.Println("\n❌ Some paths are missing or have errors")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,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
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/subtle"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TokenAuthMiddleware protects admin routes with a static bearer token.
|
|
||||||
// All /admin/* routes require the Authorization: Bearer <token> header
|
|
||||||
// matching the configured admin.secret_token.
|
|
||||||
func TokenAuthMiddleware(secretToken string) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := r.Header.Get("Authorization")
|
|
||||||
|
|
||||||
// Expect "Bearer <token>" format
|
|
||||||
const bearerPrefix = "Bearer "
|
|
||||||
if len(token) < len(bearerPrefix) {
|
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token = token[len(bearerPrefix):]
|
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
|
||||||
if subtle.ConstantTimeCompare([]byte(token), []byte(secretToken)) != 1 {
|
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,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
|
|
||||||
}
|
|
||||||
|
|
@ -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") != ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AutheliaUserDB represents the full structure of Authelia's users_database.yml.
|
|
||||||
type AutheliaUserDB struct {
|
|
||||||
Users map[string]AutheliaUserEntry `yaml:"users"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutheliaUserEntry represents a single user entry in Authelia's YAML.
|
|
||||||
type AutheliaUserEntry struct {
|
|
||||||
DisplayName string `yaml:"displayname,omitempty"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
Email string `yaml:"email,omitempty"`
|
|
||||||
Groups []string `yaml:"groups,omitempty"`
|
|
||||||
Disabled bool `yaml:"disabled,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncWriter handles writing the user database to Authelia's YAML format.
|
|
||||||
type SyncWriter struct {
|
|
||||||
usersDBPath string
|
|
||||||
store *UserStore
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSyncWriter creates a new SyncWriter.
|
|
||||||
func NewSyncWriter(usersDBPath string, store *UserStore) *SyncWriter {
|
|
||||||
return &SyncWriter{
|
|
||||||
usersDBPath: usersDBPath,
|
|
||||||
store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync writes the current user store to Authelia's users_database.yml.
|
|
||||||
func (sw *SyncWriter) Sync() error {
|
|
||||||
syncUsers, err := sw.store.SyncSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get sync snapshot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db := AutheliaUserDB{
|
|
||||||
Users: make(map[string]AutheliaUserEntry, len(syncUsers)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, u := range syncUsers {
|
|
||||||
db.Users[u.Username] = AutheliaUserEntry{
|
|
||||||
DisplayName: u.DisplayName,
|
|
||||||
Password: u.Password,
|
|
||||||
Email: u.Email,
|
|
||||||
Groups: u.Groups,
|
|
||||||
Disabled: u.Disabled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the target directory exists
|
|
||||||
dir := filepath.Dir(sw.usersDBPath)
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("create authelia data directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := yaml.Marshal(&db)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal users database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(sw.usersDBPath, data, 0644); err != nil {
|
|
||||||
return fmt.Errorf("write users database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap imports existing Authelia users into the SQLite store.
|
|
||||||
// This runs on first initialization to adopt existing users.
|
|
||||||
func (sw *SyncWriter) Bootstrap() (int, error) {
|
|
||||||
data, err := os.ReadFile(sw.usersDBPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return 0, nil // No existing file, nothing to bootstrap
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("read authelia users database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var db AutheliaUserDB
|
|
||||||
if err := yaml.Unmarshal(data, &db); err != nil {
|
|
||||||
return 0, fmt.Errorf("parse authelia users database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
imported := 0
|
|
||||||
for username, entry := range db.Users {
|
|
||||||
existing, _ := sw.store.GetByUsername(username)
|
|
||||||
if existing != nil {
|
|
||||||
continue // Already exists, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build groups string
|
|
||||||
groups := ""
|
|
||||||
for i, g := range entry.Groups {
|
|
||||||
if i > 0 {
|
|
||||||
groups += ","
|
|
||||||
}
|
|
||||||
groups += g
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine role from groups
|
|
||||||
role := "user"
|
|
||||||
if containsGroup(groups, "admins") {
|
|
||||||
role = "admin"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := sw.store.GetDB().Exec(`
|
|
||||||
INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
||||||
`, username, entry.DisplayName, entry.Email, role, groups, entry.Password, entry.Disabled)
|
|
||||||
if err != nil {
|
|
||||||
return imported, fmt.Errorf("import user %s: %w", username, err)
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
|
|
||||||
return imported, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FixRoles updates existing users' roles based on their groups.
|
|
||||||
func (sw *SyncWriter) FixRoles() (int, error) {
|
|
||||||
users, err := sw.store.List()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed := 0
|
|
||||||
for _, u := range users {
|
|
||||||
expectedRole := "user"
|
|
||||||
if containsGroup(u.Groups, "admins") {
|
|
||||||
expectedRole = "admin"
|
|
||||||
}
|
|
||||||
if u.Role != expectedRole {
|
|
||||||
_, err := sw.store.GetDB().Exec("UPDATE users SET role = ? WHERE username = ?", expectedRole, u.Username)
|
|
||||||
if err != nil {
|
|
||||||
return fixed, err
|
|
||||||
}
|
|
||||||
fixed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fixed, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package templates
|
|
||||||
|
|
||||||
templ Dashboard(userCount int) {
|
|
||||||
@Layout("dashboard") {
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{ userCount }</div>
|
|
||||||
<div class="stat-label">Users</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">—</div>
|
|
||||||
<div class="stat-label">Modules</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">OK</div>
|
|
||||||
<div class="stat-label">Status</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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, "<div class=\"stats-grid\"><div class=\"stat-card\"><div class=\"stat-value\">")
|
|
||||||
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, "</div><div class=\"stat-label\">Users</div></div><div class=\"stat-card\"><div class=\"stat-value\">—</div><div class=\"stat-label\">Modules</div></div><div class=\"stat-card\"><div class=\"stat-value\">OK</div><div class=\"stat-label\">Status</div></div></div>")
|
|
||||||
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
|
|
||||||
|
|
@ -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") {
|
|
||||||
<h2 style="font-size:1.15rem;font-weight:600;margin-bottom:20px">Global Settings</h2>
|
|
||||||
if msg != "" {
|
|
||||||
<div class="alert success">{ msg }</div>
|
|
||||||
}
|
|
||||||
<form hx-post="/admin/global" hx-target="body" hx-swap="outerHTML">
|
|
||||||
<div class="form-card">
|
|
||||||
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">Database</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Type</label>
|
|
||||||
<select name="db_type" class="input" onchange="toggleDBFields()" id="db_type">
|
|
||||||
<option value="sqlite" if dbType == "sqlite" { selected }>SQLite (built-in)</option>
|
|
||||||
<option value="mariadb" if dbType == "mariadb" { selected }>MariaDB (external)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="sqlite-fields" style={ styleDB("sqlite", dbType) }>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Data Path</label>
|
|
||||||
<input type="text" name="db_path" class="input" value={ dbPath } placeholder="/opt/nextwks/data/nextwks.db"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="mariadb-fields" style={ styleDB("mariadb", dbType) }>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Host</label>
|
|
||||||
<input type="text" name="db_host" class="input" value={ dbHost }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Port</label>
|
|
||||||
<input type="text" name="db_port" class="input" value={ dbPort }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" name="db_user" class="input" value={ dbUser }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Password</label>
|
|
||||||
<input type="password" name="db_pass" class="input" value={ dbPass }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Database Name</label>
|
|
||||||
<input type="text" name="db_name" class="input" value={ dbName }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
function toggleDBFields() {
|
|
||||||
var v = document.getElementById('db_type').value;
|
|
||||||
document.getElementById('sqlite-fields').style.display = v === 'sqlite' ? 'block' : 'none';
|
|
||||||
document.getElementById('mariadb-fields').style.display = v === 'mariadb' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<div class="form-card">
|
|
||||||
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">Email</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>SMTP Host</label>
|
|
||||||
<input type="text" name="smtp_host" class="input" value={ smtpHost }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>SMTP Port</label>
|
|
||||||
<input type="text" name="smtp_port" class="input" value={ smtpPort }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>SMTP Username</label>
|
|
||||||
<input type="text" name="smtp_user" class="input" value={ smtpUser }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>SMTP Password</label>
|
|
||||||
<input type="password" name="smtp_pass" class="input" value={ smtpPass }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>IMAP Host</label>
|
|
||||||
<input type="text" name="imap_host" class="input" value={ imapHost }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>IMAP Port</label>
|
|
||||||
<input type="text" name="imap_port" class="input" value={ imapPort }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-card">
|
|
||||||
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">Workspace URL</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>NextWks URL</label>
|
|
||||||
<input type="text" name="nextwks_url" class="input" value={ nextwksURL }/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-card">
|
|
||||||
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">Authentication (OIDC)</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Auth Server URL</label>
|
|
||||||
<input type="text" name="auth_url" class="input" value={ authURL }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Client ID</label>
|
|
||||||
<input type="text" class="input" value={ clientID } readonly style="opacity:0.7"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Scopes</label>
|
|
||||||
<input type="text" class="input" value="openid profile email" readonly style="opacity:0.7"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Redirect URL — copy this to your IDM</label>
|
|
||||||
<div style="display:flex;gap:8px">
|
|
||||||
<input type="text" id="callback-url" class="input" value={ callbackURL } readonly style="opacity:0.7;font-family:monospace;font-size:0.8rem"/>
|
|
||||||
<button type="button" class="btn-sm" onclick="copyCallback()">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
function copyCallback() {
|
|
||||||
var el = document.getElementById('callback-url');
|
|
||||||
el.select(); document.execCommand('copy');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-card">
|
|
||||||
<h3 style="font-size:0.95rem;font-weight:600;margin-bottom:12px">Localization</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Default Language</label>
|
|
||||||
<select name="lang" class="input">
|
|
||||||
<option value="en" if lang == "en" { selected }>English</option>
|
|
||||||
<option value="de" if lang == "de" { selected }>Deutsch</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Default Timezone</label>
|
|
||||||
<select name="tz" class="input">
|
|
||||||
<option value="UTC" if tz == "UTC" { selected }>UTC</option>
|
|
||||||
<option value="Europe/London" if tz == "Europe/London" { selected }>London</option>
|
|
||||||
<option value="Europe/Berlin" if tz == "Europe/Berlin" { selected }>Berlin</option>
|
|
||||||
<option value="Europe/Paris" if tz == "Europe/Paris" { selected }>Paris</option>
|
|
||||||
<option value="America/New_York" if tz == "America/New_York" { selected }>New York</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn" style="width:100%">Save Settings</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func styleDB(target, current string) string {
|
|
||||||
if target == current {
|
|
||||||
return "display:block"
|
|
||||||
}
|
|
||||||
return "display:none"
|
|
||||||
}
|
|
||||||
|
|
@ -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, "<h2 style=\"font-size:1.15rem;font-weight:600;margin-bottom:20px\">Global Settings</h2>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if msg != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"alert success\">")
|
|
||||||
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, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " <form hx-post=\"/admin/global\" hx-target=\"body\" hx-swap=\"outerHTML\"><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">Database</h3><div class=\"form-group\"><label>Type</label> <select name=\"db_type\" class=\"input\" onchange=\"toggleDBFields()\" id=\"db_type\"><option value=\"sqlite\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if dbType == "sqlite" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ">SQLite (built-in)</option> <option value=\"mariadb\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if dbType == "mariadb" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ">MariaDB (external)</option></select></div><div id=\"sqlite-fields\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(styleDB("sqlite", dbType))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 19, Col: 61}
|
|
||||||
}
|
|
||||||
_, 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, 9, "\"><div class=\"form-group\"><label>Data Path</label> <input type=\"text\" name=\"db_path\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(dbPath)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 22, Col: 68}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" placeholder=\"/opt/nextwks/data/nextwks.db\"></div></div><div id=\"mariadb-fields\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(styleDB("mariadb", dbType))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 25, Col: 63}
|
|
||||||
}
|
|
||||||
_, 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, 11, "\"><div class=\"form-row\"><div class=\"form-group\"><label>Host</label> <input type=\"text\" name=\"db_host\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(dbHost)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 29, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div><div class=\"form-group\"><label>Port</label> <input type=\"text\" name=\"db_port\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(dbPort)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 33, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>Username</label> <input type=\"text\" name=\"db_user\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(dbUser)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 39, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></div><div class=\"form-group\"><label>Password</label> <input type=\"password\" name=\"db_pass\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(dbPass)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 43, Col: 73}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"></div></div><div class=\"form-group\"><label>Database Name</label> <input type=\"text\" name=\"db_name\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var11 string
|
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(dbName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 48, Col: 68}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"></div></div></div><script type=\"text/javascript\">\n\t\t\t\tfunction toggleDBFields() {\n\t\t\t\t\tvar v = document.getElementById('db_type').value;\n\t\t\t\t\tdocument.getElementById('sqlite-fields').style.display = v === 'sqlite' ? 'block' : 'none';\n\t\t\t\t\tdocument.getElementById('mariadb-fields').style.display = v === 'mariadb' ? 'block' : 'none';\n\t\t\t\t}\n\t\t\t</script><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">Email</h3><div class=\"form-row\"><div class=\"form-group\"><label>SMTP Host</label> <input type=\"text\" name=\"smtp_host\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var12 string
|
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpHost)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 65, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"></div><div class=\"form-group\"><label>SMTP Port</label> <input type=\"text\" name=\"smtp_port\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpPort)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 69, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>SMTP Username</label> <input type=\"text\" name=\"smtp_user\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpUser)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 75, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div><div class=\"form-group\"><label>SMTP Password</label> <input type=\"password\" name=\"smtp_pass\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(smtpPass)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 79, Col: 76}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"></div></div><div class=\"form-row\"><div class=\"form-group\"><label>IMAP Host</label> <input type=\"text\" name=\"imap_host\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var16 string
|
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(imapHost)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 85, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"></div><div class=\"form-group\"><label>IMAP Port</label> <input type=\"text\" name=\"imap_port\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var17 string
|
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(imapPort)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 89, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"></div></div></div><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">Workspace URL</h3><div class=\"form-group\"><label>NextWks URL</label> <input type=\"text\" name=\"nextwks_url\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(nextwksURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 98, Col: 75}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"></div></div><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">Authentication (OIDC)</h3><div class=\"form-group\"><label>Auth Server URL</label> <input type=\"text\" name=\"auth_url\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var19 string
|
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(authURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 106, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"></div><div class=\"form-row\"><div class=\"form-group\"><label>Client ID</label> <input type=\"text\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var20 string
|
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(clientID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 111, Col: 55}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" readonly style=\"opacity:0.7\"></div><div class=\"form-group\"><label>Scopes</label> <input type=\"text\" class=\"input\" value=\"openid profile email\" readonly style=\"opacity:0.7\"></div></div><div class=\"form-group\"><label>Redirect URL — copy this to your IDM</label><div style=\"display:flex;gap:8px\"><input type=\"text\" id=\"callback-url\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(callbackURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/global.templ`, Line: 121, Col: 76}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" readonly style=\"opacity:0.7;font-family:monospace;font-size:0.8rem\"> <button type=\"button\" class=\"btn-sm\" onclick=\"copyCallback()\">Copy</button></div></div><script type=\"text/javascript\">\n\t\t\t\t\tfunction copyCallback() {\n\t\t\t\t\t\tvar el = document.getElementById('callback-url');\n\t\t\t\t\t\tel.select(); document.execCommand('copy');\n\t\t\t\t\t}\n\t\t\t\t</script></div><div class=\"form-card\"><h3 style=\"font-size:0.95rem;font-weight:600;margin-bottom:12px\">Localization</h3><div class=\"form-row\"><div class=\"form-group\"><label>Default Language</label> <select name=\"lang\" class=\"input\"><option value=\"en\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if lang == "en" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">English</option> <option value=\"de\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if lang == "de" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">Deutsch</option></select></div><div class=\"form-group\"><label>Default Timezone</label> <select name=\"tz\" class=\"input\"><option value=\"UTC\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if tz == "UTC" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">UTC</option> <option value=\"Europe/London\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if tz == "Europe/London" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">London</option> <option value=\"Europe/Berlin\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if tz == "Europe/Berlin" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">Berlin</option> <option value=\"Europe/Paris\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if tz == "Europe/Paris" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">Paris</option> <option value=\"America/New_York\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if tz == "America/New_York" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">New York</option></select></div></div></div><button type=\"submit\" class=\"btn\" style=\"width:100%\">Save Settings</button></form>")
|
|
||||||
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
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
package templates
|
|
||||||
|
|
||||||
templ GroupDashboard() {
|
|
||||||
@Layout("groups") {
|
|
||||||
<div class="split-pane">
|
|
||||||
<div class="pane-top">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Groups</h2>
|
|
||||||
<button class="btn" hx-get="/admin/groups/create-form" hx-target="#group-detail" hx-swap="innerHTML">+ Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="group-list">
|
|
||||||
<div class="table-toolbar">
|
|
||||||
<span class="count">Groups</span>
|
|
||||||
<button class="btn-sm" hx-get="/admin/groups/list" hx-target="#group-list-body" hx-swap="innerHTML">Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-body" id="group-list-body" hx-get="/admin/groups/list" hx-trigger="load" hx-swap="innerHTML">
|
|
||||||
<div class="loading">Loading groups...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pane-bottom" id="group-detail">
|
|
||||||
<div class="empty-state">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
|
||||||
<p>Select a group or click + Add</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ GroupList(groups []GroupRow) {
|
|
||||||
if len(groups) == 0 {
|
|
||||||
<div class="empty-row">No groups yet</div>
|
|
||||||
} else {
|
|
||||||
for _, g := range groups {
|
|
||||||
<div class="group-row">
|
|
||||||
<div class="group-row-left">
|
|
||||||
<div class="group-icon" style={ "background:" + g.Color }>{ g.Initial }</div>
|
|
||||||
<div>
|
|
||||||
<strong>{ g.Name }</strong>
|
|
||||||
<div class="user-sub">{ g.UserCount } members</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="group-row-right">
|
|
||||||
<button class="btn-sm" hx-get={ "/admin/groups/edit-form/" + g.Name } hx-target="#group-detail" hx-swap="innerHTML">Edit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ CreateGroupForm() {
|
|
||||||
<div class="detail-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>New Group</h3>
|
|
||||||
<button class="btn-sm" hx-get="/admin/groups/cancel-form" hx-target="#group-detail" hx-swap="innerHTML">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<form hx-post="/admin/groups" hx-target="#group-list-body" hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="htmx.trigger('#group-detail', 'click')">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Group Name *</label>
|
|
||||||
<input type="text" name="name" class="input" required placeholder="e.g. engineering"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Description</label>
|
|
||||||
<input type="text" name="description" class="input" placeholder="Team description"/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn" style="width:100%;margin-top:8px">Create Group</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ EditGroupForm(name, description string) {
|
|
||||||
<div class="detail-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>Edit Group</h3>
|
|
||||||
<button class="btn-sm" hx-get="/admin/groups/cancel-form" hx-target="#group-detail" hx-swap="innerHTML">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<form hx-put={ "/admin/groups/" + name } hx-target="#group-list-body" hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="htmx.trigger('#group-detail', 'click')">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Group Name</label>
|
|
||||||
<input type="text" class="input" value={ name } readonly style="opacity:0.6"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Description</label>
|
|
||||||
<input type="text" name="description" class="input" value={ description }/>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:8px;margin-top:12px">
|
|
||||||
<button type="submit" class="btn" style="flex:1">Save</button>
|
|
||||||
<button type="button" class="btn danger"
|
|
||||||
hx-delete={ "/admin/groups/" + name }
|
|
||||||
hx-confirm={ "Delete group " + name + "?" }
|
|
||||||
hx-target="#group-list-body" hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="htmx.trigger('#group-detail', 'click')">Delete</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
type GroupRow struct {
|
|
||||||
Name string
|
|
||||||
UserCount int
|
|
||||||
Initial string
|
|
||||||
Color string
|
|
||||||
}
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package templates
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
func 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, "<div class=\"split-pane\"><div class=\"pane-top\"><div class=\"section-header\"><h2>Groups</h2><button class=\"btn\" hx-get=\"/admin/groups/create-form\" hx-target=\"#group-detail\" hx-swap=\"innerHTML\">+ Add</button></div><div class=\"group-list\"><div class=\"table-toolbar\"><span class=\"count\">Groups</span> <button class=\"btn-sm\" hx-get=\"/admin/groups/list\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\">Refresh</button></div><div class=\"list-body\" id=\"group-list-body\" hx-get=\"/admin/groups/list\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><div class=\"loading\">Loading groups...</div></div></div></div><div class=\"pane-bottom\" id=\"group-detail\"><div class=\"empty-state\"><svg width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2\"></path><circle cx=\"9\" cy=\"7\" r=\"4\"></circle><path d=\"M23 21v-2a4 4 0 00-3-3.87\"></path><path d=\"M16 3.13a4 4 0 010 7.75\"></path></svg><p>Select a group or click + Add</p></div></div></div>")
|
|
||||||
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, "<div class=\"empty-row\">No groups yet</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, g := range groups {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"group-row\"><div class=\"group-row-left\"><div class=\"group-icon\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + g.Color)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 38, Col: 60}
|
|
||||||
}
|
|
||||||
_, 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(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, "</div><div><strong>")
|
|
||||||
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, "</strong><div class=\"user-sub\">")
|
|
||||||
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</div></div></div><div class=\"group-row-right\"><button class=\"btn-sm\" hx-get=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/groups/edit-form/" + g.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 45, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-target=\"#group-detail\" hx-swap=\"innerHTML\">Edit</button></div></div>")
|
|
||||||
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, "<div class=\"detail-card\"><div class=\"section-header\"><h3>New Group</h3><button class=\"btn-sm\" hx-get=\"/admin/groups/cancel-form\" hx-target=\"#group-detail\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-post=\"/admin/groups\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\" hx-on::after-request=\"htmx.trigger('#group-detail', 'click')\"><div class=\"form-group\"><label>Group Name *</label> <input type=\"text\" name=\"name\" class=\"input\" required placeholder=\"e.g. engineering\"></div><div class=\"form-group\"><label>Description</label> <input type=\"text\" name=\"description\" class=\"input\" placeholder=\"Team description\"></div><button type=\"submit\" class=\"btn\" style=\"width:100%;margin-top:8px\">Create Group</button></form></div>")
|
|
||||||
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, "<div class=\"detail-card\"><div class=\"section-header\"><h3>Edit Group</h3><button class=\"btn-sm\" hx-get=\"/admin/groups/cancel-form\" hx-target=\"#group-detail\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-put=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var11 string
|
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/groups/" + name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 79, Col: 40}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\" hx-on::after-request=\"htmx.trigger('#group-detail', 'click')\"><div class=\"form-group\"><label>Group Name</label> <input type=\"text\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var12 string
|
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 83, Col: 49}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" readonly style=\"opacity:0.6\"></div><div class=\"form-group\"><label>Description</label> <input type=\"text\" name=\"description\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(description)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 87, Col: 75}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div style=\"display:flex;gap:8px;margin-top:12px\"><button type=\"submit\" class=\"btn\" style=\"flex:1\">Save</button> <button type=\"button\" class=\"btn danger\" hx-delete=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/groups/" + name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 92, Col: 40}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-confirm=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete group " + name + "?")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/groups.templ`, Line: 93, Col: 46}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-target=\"#group-list-body\" hx-swap=\"innerHTML\" hx-on::after-request=\"htmx.trigger('#group-detail', 'click')\">Delete</button></div></form></div>")
|
|
||||||
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
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
package templates
|
|
||||||
|
|
||||||
templ Layout(page string) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
|
||||||
<title>Admin — Next Workspace</title>
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
||||||
@adminStyles()
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="layout">
|
|
||||||
<header class="topbar">
|
|
||||||
<div class="topbar-left">
|
|
||||||
<a href="/" class="back-btn" title="Back to Workspace">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
|
||||||
</a>
|
|
||||||
<span class="page-title">Admin</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<nav class="sidebar">
|
|
||||||
<div class="sidebar-nav">
|
|
||||||
<a href="/admin" class={ "nav-link" + activeClass(page, "dashboard") }>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
|
||||||
Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/admin/global" class={ "nav-link" + activeClass(page, "global") }>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
|
||||||
Global
|
|
||||||
</a>
|
|
||||||
<a href="/admin/users" class={ "nav-link" + activeClass(page, "users") }>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
||||||
Users
|
|
||||||
</a>
|
|
||||||
<a href="/admin/groups" class={ "nav-link" + activeClass(page, "groups") }>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
|
||||||
Groups
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<main class="content">
|
|
||||||
{ children... }
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
|
|
||||||
func activeClass(current, target string) string {
|
|
||||||
if current == target {
|
|
||||||
return " active"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
templ adminStyles() {
|
|
||||||
<style type="text/css">
|
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
:root{
|
|
||||||
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;--border:#30363d;
|
|
||||||
--text:#e6edf3;--text2:#8b949e;--blue:#58a6ff;--purple:#a371f7;--green:#3fb950;
|
|
||||||
--red:#f85149;--orange:#d2991d
|
|
||||||
}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
||||||
.layout{display:flex;min-height:100vh}
|
|
||||||
.topbar{display:flex;align-items:center;padding:0 16px;height:52px;background:var(--surface);border-bottom:1px solid var(--border);position:fixed;top:0;left:0;right:0;z-index:100}
|
|
||||||
.topbar-left{display:flex;align-items:center;gap:10px}
|
|
||||||
.back-btn{color:var(--text2);text-decoration:none;display:flex;align-items:center;padding:4px;border-radius:8px}
|
|
||||||
.back-btn:hover{background:var(--surface2);color:var(--text)}
|
|
||||||
.page-title{font-weight:600;font-size:1.05rem}
|
|
||||||
.sidebar{width:200px;background:var(--surface);border-right:1px solid var(--border);padding:12px 8px;position:fixed;top:52px;left:0;bottom:0;overflow-y:auto}
|
|
||||||
.sidebar-nav{display:flex;flex-direction:column;gap:2px}
|
|
||||||
.nav-link{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:8px;text-decoration:none;color:var(--text2);font-size:0.9rem;font-weight:500;transition:all 0.1s}
|
|
||||||
.nav-link:hover{background:var(--surface2);color:var(--text)}
|
|
||||||
.nav-link.active{background:var(--surface2);color:var(--blue)}
|
|
||||||
.nav-link svg{flex-shrink:0}
|
|
||||||
.content{flex:1;margin-left:200px;margin-top:52px;padding:24px;max-width:900px}
|
|
||||||
|
|
||||||
/* Dashboard */
|
|
||||||
.stats-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:24px}
|
|
||||||
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;text-align:center}
|
|
||||||
.stat-value{font-size:1.5rem;font-weight:700;color:var(--blue)}
|
|
||||||
.stat-label{font-size:0.75rem;color:var(--text2);margin-top:4px}
|
|
||||||
|
|
||||||
/* User Management */
|
|
||||||
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
|
|
||||||
.section-header h2{font-size:1.15rem;font-weight:600}
|
|
||||||
.section-header h3{font-size:1rem;font-weight:600}
|
|
||||||
.form-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:16px}
|
|
||||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
|
||||||
.form-group{margin-bottom:12px}
|
|
||||||
.form-group label{display:block;font-size:0.75rem;font-weight:500;color:var(--text2);margin-bottom:4px;text-transform:uppercase}
|
|
||||||
.input{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:0.9rem;outline:none}
|
|
||||||
.input:focus{border-color:var(--blue)}
|
|
||||||
.btn{display:inline-flex;align-items:center;padding:8px 16px;border-radius:8px;border:none;background:var(--blue);color:#fff;font-size:0.85rem;font-weight:500;cursor:pointer;text-decoration:none}
|
|
||||||
.btn:hover{opacity:0.9}
|
|
||||||
.btn-sm{display:inline-flex;align-items:center;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text);font-size:0.78rem;cursor:pointer}
|
|
||||||
.btn-sm:hover{background:var(--surface2)}
|
|
||||||
.btn-sm.danger{color:var(--red);border-color:var(--red)}
|
|
||||||
.btn-sm.danger:hover{background:rgba(248,81,73,0.1)}
|
|
||||||
.btn.danger{background:var(--red)}
|
|
||||||
.btn.danger:hover{opacity:0.9}
|
|
||||||
.table-toolbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
|
|
||||||
.table-title{font-weight:600;font-size:0.9rem}
|
|
||||||
.user-table-wrap{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px}
|
|
||||||
table{width:100%;border-collapse:collapse}
|
|
||||||
th,td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--border);font-size:0.85rem}
|
|
||||||
th{color:var(--text2);font-weight:600;font-size:0.75rem;text-transform:uppercase}
|
|
||||||
.loading-row{text-align:center;color:var(--text2);padding:2rem}
|
|
||||||
.role-tag{display:inline-block;padding:2px 8px;border-radius:99px;font-size:0.7rem;font-weight:600;background:rgba(88,166,255,0.15);color:var(--blue)}
|
|
||||||
.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;vertical-align:middle}
|
|
||||||
.status-dot.on{background:var(--green)}
|
|
||||||
.status-dot.off{background:var(--red)}
|
|
||||||
.alert{padding:12px;border-radius:8px;margin-bottom:8px;font-size:0.85rem}
|
|
||||||
.alert.success{background:rgba(63,185,80,0.1);border:1px solid rgba(63,185,80,0.3);color:var(--green)}
|
|
||||||
.alert.error{background:rgba(248,81,73,0.1);border:1px solid rgba(248,81,73,0.3);color:var(--red)}
|
|
||||||
.password-box{font-family:monospace;background:var(--bg);padding:10px;border-radius:6px;margin:8px 0;font-size:0.85rem;user-select:all;word-break:break-all}
|
|
||||||
.password-hint{font-size:0.75rem;color:var(--orange)}
|
|
||||||
@media(max-width:640px){.sidebar{display:none}.content{margin-left:0}.stats-grid{grid-template-columns:1fr 1fr}.form-row{grid-template-columns:1fr}}
|
|
||||||
|
|
||||||
/* Split pane */
|
|
||||||
.split-pane{display:flex;flex-direction:column;gap:16px;height:calc(100vh - 100px)}
|
|
||||||
.pane-top{flex:1;overflow-y:auto;min-height:200px}
|
|
||||||
.pane-bottom{flex:1;overflow-y:auto;border-top:1px solid var(--border);padding-top:16px}
|
|
||||||
.user-list{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
|
||||||
.list-body{max-height:300px;overflow-y:auto}
|
|
||||||
.user-row{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--border);cursor:pointer;transition:background 0.1s}
|
|
||||||
.user-row:hover{background:var(--surface2)}
|
|
||||||
.user-row:last-child{border-bottom:none}
|
|
||||||
.user-row-left{display:flex;align-items:center;gap:10px}
|
|
||||||
.user-avatar{width:32px;height:32px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:0.8rem;font-weight:600;color:var(--text2)}
|
|
||||||
.user-sub{font-size:0.75rem;color:var(--text2);margin-top:1px}
|
|
||||||
.user-row-right{display:flex;align-items:center;gap:8px}
|
|
||||||
.empty-row{padding:20px;text-align:center;color:var(--text2)}
|
|
||||||
.loading{padding:20px;text-align:center;color:var(--text2)}
|
|
||||||
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--text2);gap:10px}
|
|
||||||
.empty-state p{font-size:0.85rem}
|
|
||||||
.detail-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px}
|
|
||||||
.group-list{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
|
||||||
.group-row{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--border);transition:background 0.1s}
|
|
||||||
.group-row:hover{background:var(--surface2)}
|
|
||||||
.group-row:last-child{border-bottom:none}
|
|
||||||
.group-row-left{display:flex;align-items:center;gap:10px}
|
|
||||||
.group-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:0.8rem;font-weight:700;color:#fff}
|
|
||||||
.group-row-right{display:flex;align-items:center;gap:8px}
|
|
||||||
.checkbox-list{display:grid;grid-template-columns:1fr 1fr;gap:4px;max-height:120px;overflow-y:auto}
|
|
||||||
.checkbox-item{display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:0.85rem}
|
|
||||||
.checkbox-item:hover{background:var(--surface2)}
|
|
||||||
.checkbox-item input[type=checkbox]{accent-color:var(--blue)}
|
|
||||||
/* Tags */
|
|
||||||
.tag-selector{border:1px solid var(--border);border-radius:8px;padding:8px;min-height:40px}
|
|
||||||
.tag-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;min-height:24px}
|
|
||||||
.tag-pool{display:flex;flex-wrap:wrap;gap:6px}
|
|
||||||
.tag-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:99px;font-size:0.78rem;cursor:pointer;background:var(--surface2);color:var(--text2);transition:all 0.1s;user-select:none}
|
|
||||||
.tag-chip:hover{background:var(--surface3);color:var(--text)}
|
|
||||||
.tag-chip.selected{background:var(--blue);color:#fff}.tag-chip.selected .tag-x{display:inline}
|
|
||||||
.tag-x{display:none;font-weight:700;margin-left:2px;line-height:1}
|
|
||||||
</style>
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,245 +0,0 @@
|
||||||
package templates
|
|
||||||
|
|
||||||
templ UserDashboard() {
|
|
||||||
@Layout("users") {
|
|
||||||
<div class="split-pane">
|
|
||||||
<div class="pane-top">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Users</h2>
|
|
||||||
<button class="btn" hx-get="/admin/users/create-form" hx-target="#user-detail" hx-swap="innerHTML">+ Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="user-list">
|
|
||||||
<div class="table-toolbar">
|
|
||||||
<span class="count">Users</span>
|
|
||||||
<button class="btn-sm" hx-get="/admin/users/list" hx-target="#user-list-body" hx-swap="innerHTML">Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-body" id="user-list-body" hx-get="/admin/users/list" hx-trigger="load" hx-swap="innerHTML">
|
|
||||||
<div class="loading">Loading users...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pane-bottom" id="user-detail">
|
|
||||||
<div class="empty-state">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
||||||
<p>Click Edit on a user or + Add</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ UserList(users []UserRow) {
|
|
||||||
if len(users) == 0 {
|
|
||||||
<div class="empty-row">No users found</div>
|
|
||||||
} else {
|
|
||||||
for _, u := range users {
|
|
||||||
<div class="user-row">
|
|
||||||
<div class="user-row-left">
|
|
||||||
<div class="user-avatar">{ initials(u.DisplayName) }</div>
|
|
||||||
<div>
|
|
||||||
<strong>{ u.Username }</strong>
|
|
||||||
<div class="user-sub">{ u.Email }</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="user-row-right">
|
|
||||||
<span class="role-tag">{ u.Role }</span>
|
|
||||||
if u.Disabled {
|
|
||||||
<span class="status-dot off"></span>
|
|
||||||
} else {
|
|
||||||
<span class="status-dot on"></span>
|
|
||||||
}
|
|
||||||
<button class="btn-sm" hx-get={ "/admin/users/edit-form/" + u.Username } hx-target="#user-detail" hx-swap="innerHTML">Edit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ CreateUserForm(groups []string) {
|
|
||||||
<div class="detail-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>New User</h3>
|
|
||||||
<button class="btn-sm" hx-get="/admin/users/cancel-form" hx-target="#user-detail" hx-swap="innerHTML">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<form hx-post="/admin/users/create" hx-target="#user-detail" hx-swap="innerHTML">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username *</label>
|
|
||||||
<input type="text" name="username" class="input" required placeholder="jdoe"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Display Name</label>
|
|
||||||
<input type="text" name="display_name" class="input" placeholder="John Doe"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Email</label>
|
|
||||||
<input type="email" name="email" class="input" placeholder="john@example.com"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Role</label>
|
|
||||||
<select name="role" class="input">
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
if len(groups) > 0 {
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Groups</label>
|
|
||||||
<div class="tag-selector">
|
|
||||||
<div class="tag-pool">
|
|
||||||
for _, g := range groups {
|
|
||||||
<div class="tag-chip" onclick="toggleTag(this)" data-value={ g }>{ g }</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="group" id="group-input-create"/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button type="submit" class="btn" style="width:100%;margin-top:8px">Create User</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ CreateUserSuccess(results []CreateUserResultRow) {
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Error != "" {
|
|
||||||
<div class="alert error">{ r.Error }</div>
|
|
||||||
} else {
|
|
||||||
<div class="alert success">
|
|
||||||
<strong>{ r.Username }</strong> created
|
|
||||||
<div class="password-box">{ r.GeneratedPassword }</div>
|
|
||||||
<div class="password-hint">Save this password — it won't be shown again</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<script type="text/javascript">
|
|
||||||
setTimeout(function() { htmx.trigger('#user-list-body', 'click'); }, 500);
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
<div class="detail-card">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>Edit User</h3>
|
|
||||||
<button class="btn-sm" hx-get="/admin/users/cancel-form" hx-target="#user-detail" hx-swap="innerHTML">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<form hx-put={ "/admin/users/" + u.Username } hx-target="#user-detail" hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="htmx.trigger('#user-list-body', 'click')">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" class="input" value={ u.Username } readonly style="opacity:0.6"/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Display Name</label>
|
|
||||||
<input type="text" name="display_name" class="input" value={ u.DisplayName }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Email</label>
|
|
||||||
<input type="email" name="email" class="input" value={ u.Email }/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Role</label>
|
|
||||||
<select name="role" class="input">
|
|
||||||
<option value="user" if u.Role == "user" { selected }>User</option>
|
|
||||||
<option value="admin" if u.Role == "admin" { selected }>Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
if len(groups) > 0 {
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Groups</label>
|
|
||||||
<div class="tag-selector" id="group-tags">
|
|
||||||
<div class="tag-chips" id="selected-groups">
|
|
||||||
for _, g := range groups {
|
|
||||||
if hasGroup(u.Groups, g) {
|
|
||||||
<div class="tag-chip selected" onclick="toggleTag(this)" data-value={ g }>{ g }<span class="tag-x">×</span></div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="tag-pool">
|
|
||||||
for _, g := range groups {
|
|
||||||
if !hasGroup(u.Groups, g) {
|
|
||||||
<div class="tag-chip" onclick="toggleTag(this)" data-value={ g }>{ g }</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="group" id="group-input"/>
|
|
||||||
</div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
function toggleTag(el) {
|
|
||||||
el.classList.toggle('selected');
|
|
||||||
if (el.classList.contains('selected')) {
|
|
||||||
document.getElementById('selected-groups').appendChild(el);
|
|
||||||
} else {
|
|
||||||
document.getElementById('selected-groups').appendChild(el);
|
|
||||||
el.classList.add('selected');
|
|
||||||
}
|
|
||||||
updateGroupInput();
|
|
||||||
}
|
|
||||||
function updateGroupInput() {
|
|
||||||
var vals = [];
|
|
||||||
document.querySelectorAll('#selected-groups .tag-chip.selected').forEach(function(c){
|
|
||||||
vals.push(c.getAttribute('data-value'));
|
|
||||||
});
|
|
||||||
document.getElementById('group-input').value = vals.join(',');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
<div style="display:flex;gap:8px;margin-top:12px">
|
|
||||||
<button type="submit" class="btn" style="flex:1">Save</button>
|
|
||||||
<button type="button" class="btn danger"
|
|
||||||
hx-delete={ "/admin/api/users/" + u.Username }
|
|
||||||
hx-target="#user-list-body" hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="htmx.trigger('#user-detail', 'click')">Delete</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
@ -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, "<div class=\"split-pane\"><div class=\"pane-top\"><div class=\"section-header\"><h2>Users</h2><button class=\"btn\" hx-get=\"/admin/users/create-form\" hx-target=\"#user-detail\" hx-swap=\"innerHTML\">+ Add</button></div><div class=\"user-list\"><div class=\"table-toolbar\"><span class=\"count\">Users</span> <button class=\"btn-sm\" hx-get=\"/admin/users/list\" hx-target=\"#user-list-body\" hx-swap=\"innerHTML\">Refresh</button></div><div class=\"list-body\" id=\"user-list-body\" hx-get=\"/admin/users/list\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><div class=\"loading\">Loading users...</div></div></div></div><div class=\"pane-bottom\" id=\"user-detail\"><div class=\"empty-state\"><svg width=\"40\" height=\"40\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2\"></path><circle cx=\"12\" cy=\"7\" r=\"4\"></circle></svg><p>Click Edit on a user or + Add</p></div></div></div>")
|
|
||||||
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, "<div class=\"empty-row\">No users found</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, u := range users {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"user-row\"><div class=\"user-row-left\"><div class=\"user-avatar\">")
|
|
||||||
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, "</div><div><strong>")
|
|
||||||
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, "</strong><div class=\"user-sub\">")
|
|
||||||
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, "</div></div></div><div class=\"user-row-right\"><span class=\"role-tag\">")
|
|
||||||
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, "</span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if u.Disabled {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"status-dot off\"></span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"status-dot on\"></span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<button class=\"btn-sm\" hx-get=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/edit-form/" + u.Username)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 51, Col: 75}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" hx-target=\"#user-detail\" hx-swap=\"innerHTML\">Edit</button></div></div>")
|
|
||||||
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, "<div class=\"detail-card\"><div class=\"section-header\"><h3>New User</h3><button class=\"btn-sm\" hx-get=\"/admin/users/cancel-form\" hx-target=\"#user-detail\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-post=\"/admin/users/create\" hx-target=\"#user-detail\" hx-swap=\"innerHTML\"><div class=\"form-group\"><label>Username *</label> <input type=\"text\" name=\"username\" class=\"input\" required placeholder=\"jdoe\"></div><div class=\"form-group\"><label>Display Name</label> <input type=\"text\" name=\"display_name\" class=\"input\" placeholder=\"John Doe\"></div><div class=\"form-group\"><label>Email</label> <input type=\"email\" name=\"email\" class=\"input\" placeholder=\"john@example.com\"></div><div class=\"form-group\"><label>Role</label> <select name=\"role\" class=\"input\"><option value=\"user\">User</option> <option value=\"admin\">Admin</option></select></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if len(groups) > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"form-group\"><label>Groups</label><div class=\"tag-selector\"><div class=\"tag-pool\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, g := range groups {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"tag-chip\" onclick=\"toggleTag(this)\" data-value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(g)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 90, Col: 70}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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(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, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div><input type=\"hidden\" name=\"group\" id=\"group-input-create\"></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"submit\" class=\"btn\" style=\"width:100%;margin-top:8px\">Create User</button></form></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateUserSuccess(results []CreateUserResultRow) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_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, "<div class=\"alert error\">")
|
|
||||||
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, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"alert success\"><strong>")
|
|
||||||
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, "</strong> created<div class=\"password-box\">")
|
|
||||||
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, "</div><div class=\"password-hint\">Save this password — it won't be shown again</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<script type=\"text/javascript\">\n\t\tsetTimeout(function() { htmx.trigger('#user-list-body', 'click'); }, 500);\n\t</script>")
|
|
||||||
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, "<div class=\"detail-card\"><div class=\"section-header\"><h3>Edit User</h3><button class=\"btn-sm\" hx-get=\"/admin/users/cancel-form\" hx-target=\"#user-detail\" hx-swap=\"innerHTML\">Cancel</button></div><form hx-put=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var17 string
|
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + u.Username)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 174, Col: 45}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-target=\"#user-detail\" hx-swap=\"innerHTML\" hx-on::after-request=\"htmx.trigger('#user-list-body', 'click')\"><div class=\"form-group\"><label>Username</label> <input type=\"text\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(u.Username)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 178, Col: 55}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" readonly style=\"opacity:0.6\"></div><div class=\"form-group\"><label>Display Name</label> <input type=\"text\" name=\"display_name\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var19 string
|
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(u.DisplayName)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 182, Col: 78}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"></div><div class=\"form-group\"><label>Email</label> <input type=\"email\" name=\"email\" class=\"input\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var20 string
|
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(u.Email)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 186, Col: 66}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"></div><div class=\"form-group\"><label>Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if u.Role == "user" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">User</option> <option value=\"admin\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if u.Role == "admin" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Admin</option></select></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if len(groups) > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"form-group\"><label>Groups</label><div class=\"tag-selector\" id=\"group-tags\"><div class=\"tag-chips\" id=\"selected-groups\">")
|
|
||||||
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, "<div class=\"tag-chip selected\" onclick=\"toggleTag(this)\" data-value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(g)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 202, Col: 80}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
|
||||||
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, "<span class=\"tag-x\">×</span></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"tag-pool\">")
|
|
||||||
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, "<div class=\"tag-chip\" onclick=\"toggleTag(this)\" data-value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var23 string
|
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(g)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 209, Col: 71}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
|
|
||||||
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, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div></div><input type=\"hidden\" name=\"group\" id=\"group-input\"></div><script type=\"text/javascript\">\n\t\t\t\t\tfunction toggleTag(el) {\n\t\t\t\t\t\tel.classList.toggle('selected');\n\t\t\t\t\t\tif (el.classList.contains('selected')) {\n\t\t\t\t\t\t\tdocument.getElementById('selected-groups').appendChild(el);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tdocument.getElementById('selected-groups').appendChild(el);\n\t\t\t\t\t\t\tel.classList.add('selected');\n\t\t\t\t\t\t}\n\t\t\t\t\t\tupdateGroupInput();\n\t\t\t\t\t}\n\t\t\t\t\tfunction updateGroupInput() {\n\t\t\t\t\t\tvar vals = [];\n\t\t\t\t\t\tdocument.querySelectorAll('#selected-groups .tag-chip.selected').forEach(function(c){\n\t\t\t\t\t\t\tvals.push(c.getAttribute('data-value'));\n\t\t\t\t\t\t});\n\t\t\t\t\t\tdocument.getElementById('group-input').value = vals.join(',');\n\t\t\t\t\t}\n\t\t\t\t</script>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div style=\"display:flex;gap:8px;margin-top:12px\"><button type=\"submit\" class=\"btn\" style=\"flex:1\">Save</button> <button type=\"button\" class=\"btn danger\" hx-delete=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/api/users/" + u.Username)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/admin/templates/user-dashboard.templ`, Line: 239, Col: 49}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-target=\"#user-list-body\" hx-swap=\"innerHTML\" hx-on::after-request=\"htmx.trigger('#user-detail', 'click')\">Delete</button></div></form></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -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(`<div class="alert success">Group updated successfully</div>
|
|
||||||
<script>setTimeout(function(){htmx.trigger('#group-detail','click')},800)</script>`))
|
|
||||||
}
|
|
||||||
|
|
||||||
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(`<div class="alert success">User updated successfully</div>
|
|
||||||
<script>setTimeout(function(){htmx.trigger('#user-detail','click')},800)</script>`))
|
|
||||||
// Trigger the refresh of the user list via HTMX
|
|
||||||
}
|
|
||||||
|
|
@ -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]
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RoleChecker validates that the session user has the required role.
|
|
||||||
type RoleChecker struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRoleChecker creates a role checker backed by the database.
|
|
||||||
func NewRoleChecker(db *sql.DB) *RoleChecker {
|
|
||||||
return &RoleChecker{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireAdmin is middleware that allows only users with the "admin" role.
|
|
||||||
// Must run after SessionMiddleware has populated the context.
|
|
||||||
func (rc *RoleChecker) RequireAdmin(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := GetUserID(r)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isAdmin, err := rc.IsAdmin(userID)
|
|
||||||
if err != nil || !isAdmin {
|
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAdmin checks if a user has the admin role.
|
|
||||||
func (rc *RoleChecker) IsAdmin(username string) (bool, error) {
|
|
||||||
var role string
|
|
||||||
err := rc.db.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return role == "admin", nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,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)
|
|
||||||
}
|
|
||||||
|
|
@ -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[:])
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testdataDir = "testdata"
|
|
||||||
|
|
||||||
func testdataPath(name string) string {
|
|
||||||
return filepath.Join(testdataDir, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config.Load tests ---
|
|
||||||
|
|
||||||
func TestLoad_ValidConfig(t *testing.T) {
|
|
||||||
cfg, err := Load(testdataPath("valid-config.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Server.Host != "0.0.0.0" {
|
|
||||||
t.Errorf("expected Server.Host '0.0.0.0', got %q", cfg.Server.Host)
|
|
||||||
}
|
|
||||||
if cfg.Server.Port != 8080 {
|
|
||||||
t.Errorf("expected Server.Port 8080, got %d", cfg.Server.Port)
|
|
||||||
}
|
|
||||||
if cfg.Admin.SecretToken != "test-admin-token-123" {
|
|
||||||
t.Errorf("expected Admin.SecretToken 'test-admin-token-123', got %q", cfg.Admin.SecretToken)
|
|
||||||
}
|
|
||||||
if cfg.Database.Type != "sqlite" {
|
|
||||||
t.Errorf("expected Database.Type 'sqlite', got %q", cfg.Database.Type)
|
|
||||||
}
|
|
||||||
if cfg.Database.Path != "/tmp/nextwks-test.db" {
|
|
||||||
t.Errorf("expected Database.Path '/tmp/nextwks-test.db', got %q", cfg.Database.Path)
|
|
||||||
}
|
|
||||||
if cfg.Authelia.Host != "http://127.0.0.1:9091" {
|
|
||||||
t.Errorf("expected Authelia.Host 'http://127.0.0.1:9091', got %q", cfg.Authelia.Host)
|
|
||||||
}
|
|
||||||
if cfg.Session.Secret != "test-session-secret" {
|
|
||||||
t.Errorf("expected Session.Secret 'test-session-secret', got %q", cfg.Session.Secret)
|
|
||||||
}
|
|
||||||
if cfg.Session.ExpiryMinutes != 60 {
|
|
||||||
t.Errorf("expected Session.ExpiryMinutes 60, got %d", cfg.Session.ExpiryMinutes)
|
|
||||||
}
|
|
||||||
if cfg.SMTP.Host != "mail.example.com" {
|
|
||||||
t.Errorf("expected SMTP.Host 'mail.example.com', got %q", cfg.SMTP.Host)
|
|
||||||
}
|
|
||||||
if cfg.SMTP.Port != 587 {
|
|
||||||
t.Errorf("expected SMTP.Port 587, got %d", cfg.SMTP.Port)
|
|
||||||
}
|
|
||||||
if cfg.SMTP.From != "noreply@example.com" {
|
|
||||||
t.Errorf("expected SMTP.From 'noreply@example.com', got %q", cfg.SMTP.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_MissingFile(t *testing.T) {
|
|
||||||
_, err := Load(testdataPath("nonexistent-file.yaml"))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for missing file, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_InvalidYAML(t *testing.T) {
|
|
||||||
tmpFile := filepath.Join(t.TempDir(), "invalid.yaml")
|
|
||||||
if err := os.WriteFile(tmpFile, []byte("invalid: yaml: \n bad: ["), 0644); err != nil {
|
|
||||||
t.Fatalf("failed to write temp file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := Load(tmpFile)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid YAML, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_EmptyFile(t *testing.T) {
|
|
||||||
tmpFile := filepath.Join(t.TempDir(), "empty.yaml")
|
|
||||||
if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil {
|
|
||||||
t.Fatalf("failed to write temp file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := Load(tmpFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error for empty file, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty file should yield zero-value config
|
|
||||||
if cfg.Server.Port != 0 {
|
|
||||||
t.Errorf("expected zero-value Port, got %d", cfg.Server.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config.AutheliaSessionSecret tests ---
|
|
||||||
|
|
||||||
func TestAutheliaSessionSecret_Valid(t *testing.T) {
|
|
||||||
secret, err := AutheliaSessionSecret(testdataPath("valid-authelia-config.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if secret != "authelia-test-session-secret" {
|
|
||||||
t.Errorf("expected secret 'authelia-test-session-secret', got %q", secret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAutheliaSessionSecret_MissingFile(t *testing.T) {
|
|
||||||
_, err := AutheliaSessionSecret(testdataPath("nonexistent-authelia-config.yaml"))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for missing file, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAutheliaSessionSecret_NoSecretField(t *testing.T) {
|
|
||||||
_, err := AutheliaSessionSecret(testdataPath("no-session-authelia-config.yaml"))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when session.secret is missing, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAutheliaSessionSecret_EmptySecret(t *testing.T) {
|
|
||||||
tmpFile := filepath.Join(t.TempDir(), "authelia-empty-secret.yaml")
|
|
||||||
content := []byte("session:\n name: test\n secret: \"\"\n")
|
|
||||||
if err := os.WriteFile(tmpFile, content, 0644); err != nil {
|
|
||||||
t.Fatalf("failed to write temp file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := AutheliaSessionSecret(tmpFile)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty session.secret, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
host: 0.0.0.0
|
|
||||||
port: 9091
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: debug
|
|
||||||
|
|
||||||
jwt_secret: test-jwt-secret
|
|
||||||
|
|
||||||
storage:
|
|
||||||
local:
|
|
||||||
path: /opt/authelia/data/db.sqlite
|
|
||||||
|
|
||||||
authentication_backend:
|
|
||||||
file:
|
|
||||||
path: /opt/authelia/data/users_database.yml
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
host: 0.0.0.0
|
|
||||||
port: 9091
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: debug
|
|
||||||
|
|
||||||
jwt_secret: test-jwt-secret
|
|
||||||
|
|
||||||
session:
|
|
||||||
name: authelia_session
|
|
||||||
secret: authelia-test-session-secret
|
|
||||||
expiration: 1h
|
|
||||||
inactivity: 5m
|
|
||||||
|
|
||||||
storage:
|
|
||||||
local:
|
|
||||||
path: /opt/authelia/data/db.sqlite
|
|
||||||
|
|
||||||
authentication_backend:
|
|
||||||
file:
|
|
||||||
path: /opt/authelia/data/users_database.yml
|
|
||||||
26
src/core/config/testdata/valid-config.yaml
vendored
26
src/core/config/testdata/valid-config.yaml
vendored
|
|
@ -1,26 +0,0 @@
|
||||||
server:
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8080
|
|
||||||
|
|
||||||
admin:
|
|
||||||
secret_token: "test-admin-token-123"
|
|
||||||
|
|
||||||
database:
|
|
||||||
type: "sqlite"
|
|
||||||
path: "/tmp/nextwks-test.db"
|
|
||||||
|
|
||||||
authelia:
|
|
||||||
host: "http://127.0.0.1:9091"
|
|
||||||
config_path: "/opt/authelia/config/configuration.yml"
|
|
||||||
users_db_path: "/opt/authelia/data/users_database.yml"
|
|
||||||
|
|
||||||
smtp:
|
|
||||||
host: "mail.example.com"
|
|
||||||
port: 587
|
|
||||||
username: "test@example.com"
|
|
||||||
password: "test-password"
|
|
||||||
from: "noreply@example.com"
|
|
||||||
|
|
||||||
session:
|
|
||||||
secret: "test-session-secret"
|
|
||||||
expiry_minutes: 60
|
|
||||||
|
|
@ -1,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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestInitialize_CreatesDirectory(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "subdir", "test.db")
|
|
||||||
|
|
||||||
db, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// Verify directory was created
|
|
||||||
if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
|
|
||||||
t.Fatal("expected directory to be created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify database file was created
|
|
||||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
||||||
t.Fatal("expected database file to be created")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitialize_OpensConnection(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "test.db")
|
|
||||||
|
|
||||||
db, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// Verify connection is alive
|
|
||||||
if err := db.DB.Ping(); err != nil {
|
|
||||||
t.Fatalf("expected ping to succeed, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitialize_ExistingFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "existing.db")
|
|
||||||
|
|
||||||
// Create database once
|
|
||||||
db1, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first init failed: %v", err)
|
|
||||||
}
|
|
||||||
db1.Close()
|
|
||||||
|
|
||||||
// Re-open existing database
|
|
||||||
db2, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second init failed: %v", err)
|
|
||||||
}
|
|
||||||
defer db2.Close()
|
|
||||||
|
|
||||||
if err := db2.DB.Ping(); err != nil {
|
|
||||||
t.Fatalf("expected ping to succeed, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrate_CreatesTables(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "migrate-test.db")
|
|
||||||
|
|
||||||
database, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init failed: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
if err := database.Migrate(); err != nil {
|
|
||||||
t.Fatalf("migrate failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify tables exist
|
|
||||||
expectedTables := []string{"users", "sessions", "audit_logs"}
|
|
||||||
for _, table := range expectedTables {
|
|
||||||
var count int
|
|
||||||
row := database.DB.QueryRow(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
|
||||||
table,
|
|
||||||
)
|
|
||||||
if err := row.Scan(&count); err != nil {
|
|
||||||
t.Fatalf("failed to check table %s: %v", table, err)
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
t.Errorf("expected table %s to exist", table)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrate_Idempotent(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "idempotent-test.db")
|
|
||||||
|
|
||||||
database, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init failed: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
// Run migrations twice
|
|
||||||
if err := database.Migrate(); err != nil {
|
|
||||||
t.Fatalf("first migrate failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := database.Migrate(); err != nil {
|
|
||||||
t.Fatalf("second migrate should succeed (idempotent), got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrate_TableSchemas(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "schema-test.db")
|
|
||||||
|
|
||||||
database, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init failed: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
database.Migrate()
|
|
||||||
|
|
||||||
// Verify sessions table columns
|
|
||||||
rows, err := database.DB.Query("PRAGMA table_info(sessions)")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get sessions schema: %v", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
columns := map[string]bool{}
|
|
||||||
for rows.Next() {
|
|
||||||
var cid int
|
|
||||||
var name, ctype string
|
|
||||||
var notnull, pk int
|
|
||||||
var dflt sql.NullString
|
|
||||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
|
||||||
t.Fatalf("failed to scan column: %v", err)
|
|
||||||
}
|
|
||||||
columns[name] = true
|
|
||||||
_ = ctype
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedCols := []string{"id", "user_id", "token_hash", "created_at", "expires_at"}
|
|
||||||
for _, col := range expectedCols {
|
|
||||||
if !columns[col] {
|
|
||||||
t.Errorf("expected column %q in sessions table", col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrate_UsersTableSchema(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "users-schema-test.db")
|
|
||||||
|
|
||||||
database, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init failed: %v", err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
database.Migrate()
|
|
||||||
|
|
||||||
// Verify users table columns
|
|
||||||
rows, err := database.DB.Query("PRAGMA table_info(users)")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get users schema: %v", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
columns := map[string]string{}
|
|
||||||
for rows.Next() {
|
|
||||||
var cid int
|
|
||||||
var name, ctype string
|
|
||||||
var notnull, pk int
|
|
||||||
var dflt sql.NullString
|
|
||||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
|
||||||
t.Fatalf("failed to scan column: %v", err)
|
|
||||||
}
|
|
||||||
columns[name] = ctype
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedCols := []string{"id", "username", "display_name", "email", "groups", "password_hash", "disabled", "created_at", "updated_at"}
|
|
||||||
for _, col := range expectedCols {
|
|
||||||
if _, ok := columns[col]; !ok {
|
|
||||||
t.Errorf("expected column %q in users table", col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify username has UNIQUE constraint (SQLite creates an index for UNIQUE columns)
|
|
||||||
var indexCount int
|
|
||||||
database.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'sqlite_autoindex_users%' AND sql IS NULL").Scan(&indexCount)
|
|
||||||
if indexCount == 0 {
|
|
||||||
t.Error("expected UNIQUE constraint on username column")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spot-check specific types
|
|
||||||
if columns["username"] != "TEXT" {
|
|
||||||
t.Errorf("expected username type TEXT, got %s", columns["username"])
|
|
||||||
}
|
|
||||||
if columns["disabled"] != "INTEGER" {
|
|
||||||
t.Errorf("expected disabled type INTEGER, got %s", columns["disabled"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClose(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "close-test.db")
|
|
||||||
|
|
||||||
database, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("init failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.Close(); err != nil {
|
|
||||||
t.Fatalf("close failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ping should fail after close
|
|
||||||
if err := database.DB.Ping(); err == nil {
|
|
||||||
t.Fatal("expected ping to fail after close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,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
|
|
||||||
}
|
|
||||||
|
|
@ -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"}
|
|
||||||
}
|
|
||||||
|
|
@ -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:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l-.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,Status:"ready",AdminOnly:true},
|
|
||||||
{Name:"Files",Description:"Storage & sharing",URL:"/files",Color:"#2563eb",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"Mail",Description:"Email client",URL:"/mail",Color:"#dc2626",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"Calendar",Description:"Schedule & events",URL:"/calendar",Color:"#059669",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"WorkSheets",Description:"Spreadsheets",URL:"/worksheets",Color:"#059669",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"TypeWriter",Description:"Documents",URL:"/typewriter",Color:"#2563eb",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"DeckCreator",Description:"Presentations",URL:"/deckcreator",Color:"#dc2626",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"NotesFiles",Description:"Notes & files",URL:"/notesfiles",Color:"#d97706",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"Contacts",Description:"People & directory",URL:"/contacts",Color:"#d97706",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"Tasks",Description:"Project management",URL:"/tasks",Color:"#7c3aed",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`,Status:"coming-soon"},
|
|
||||||
{Name:"Chat",Description:"Team messaging",URL:"/chat",Color:"#db2777",Icon:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,Status:"coming-soon"},
|
|
||||||
}
|
|
||||||
filtered := make([]AppTile,0,len(all))
|
|
||||||
for _,a := range all {
|
|
||||||
if a.AdminOnly && role != "admin" { continue }
|
|
||||||
filtered = append(filtered, a)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
templ AppGrid(apps []AppTile) {
|
|
||||||
<div class="app-grid">
|
|
||||||
for _, a := range apps {
|
|
||||||
@appTile(a)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ appTile(a AppTile) {
|
|
||||||
<div class="app-card" data-url={ a.URL } onclick="window.open(this.getAttribute('data-url'),'_blank')" oncontextmenu="return false">
|
|
||||||
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
|
|
||||||
<div class="app-name">{ a.Name }</div>
|
|
||||||
<div class="app-badge">Available</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
@ -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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
|
|
||||||
Status: "ready", AdminOnly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Files", Description: "Storage & sharing",
|
|
||||||
URL: "#", Color: "#2563eb",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Mail", Description: "Email client",
|
|
||||||
URL: "#", Color: "#dc2626",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Calendar", Description: "Schedule & events",
|
|
||||||
URL: "#", Color: "#059669",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "WorkSheets", Description: "Spreadsheets",
|
|
||||||
URL: "/worksheets", Color: "#059669",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "TypeWriter", Description: "Documents",
|
|
||||||
URL: "/typewriter", Color: "#2563eb",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "DeckCreator", Description: "Presentations",
|
|
||||||
URL: "/deckcreator", Color: "#dc2626",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "NotesFiles", Description: "Notes & files",
|
|
||||||
URL: "/notesfiles", Color: "#d97706",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Contacts", Description: "People & directory",
|
|
||||||
URL: "#", Color: "#d97706",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Tasks", Description: "Project management",
|
|
||||||
URL: "#", Color: "#7c3aed",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Chat", Description: "Team messaging",
|
|
||||||
URL: "#", Color: "#db2777",
|
|
||||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
|
|
||||||
Status: "coming-soon",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by role
|
|
||||||
filtered := make([]AppTile, 0, len(all))
|
|
||||||
for _, a := range all {
|
|
||||||
if a.AdminOnly && role != "admin" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, a)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
templ AppGrid(apps []AppTile) {
|
|
||||||
<div class="app-grid">
|
|
||||||
for _, a := range apps {
|
|
||||||
@appTile(a)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ appTile(a AppTile) {
|
|
||||||
if a.Status == "coming-soon" {
|
|
||||||
<div class="app-card disabled"
|
|
||||||
oncontextmenu="return false"
|
|
||||||
ontouchstart="startLongPress(event)" ontouchend="cancelLongPress()"
|
|
||||||
onmousedown="startLongPress(event)" onmouseup="cancelLongPress()">
|
|
||||||
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
|
|
||||||
<div class="app-name">{ a.Name }</div>
|
|
||||||
<div class="app-badge">Coming Soon</div>
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="app-card" data-url={ a.URL } onclick="window.location.href=this.getAttribute('data-url')" oncontextmenu="return false">
|
|
||||||
<div class="app-icon" style={ "background:" + a.Color }>@templ.Raw(a.Icon)</div>
|
|
||||||
<div class="app-name">{ a.Name }</div>
|
|
||||||
<div class="app-badge">Available</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l-.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`, Status: "ready", AdminOnly: true},
|
|
||||||
{Name: "Files", Description: "Storage & sharing", URL: "/files", Color: "#2563eb", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "Mail", Description: "Email client", URL: "/mail", Color: "#dc2626", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "Calendar", Description: "Schedule & events", URL: "/calendar", Color: "#059669", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "WorkSheets", Description: "Spreadsheets", URL: "/worksheets", Color: "#059669", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "TypeWriter", Description: "Documents", URL: "/typewriter", Color: "#2563eb", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "DeckCreator", Description: "Presentations", URL: "/deckcreator", Color: "#dc2626", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "NotesFiles", Description: "Notes & files", URL: "/notesfiles", Color: "#d97706", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "Contacts", Description: "People & directory", URL: "/contacts", Color: "#d97706", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "Tasks", Description: "Project management", URL: "/tasks", Color: "#7c3aed", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>`, Status: "coming-soon"},
|
|
||||||
{Name: "Chat", Description: "Team messaging", URL: "/chat", Color: "#db2777", Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`, Status: "coming-soon"},
|
|
||||||
}
|
|
||||||
filtered := make([]AppTile, 0, len(all))
|
|
||||||
for _, a := range all {
|
|
||||||
if a.AdminOnly && role != "admin" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, a)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func AppGrid(apps []AppTile) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"app-grid\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, a := range apps {
|
|
||||||
templ_7745c5c3_Err = appTile(a).Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func appTile(a AppTile) templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var2 == nil {
|
|
||||||
templ_7745c5c3_Var2 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-card\" data-url=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.URL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 44, Col: 39}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" onclick=\"window.open(this.getAttribute('data-url'),'_blank')\" oncontextmenu=\"return false\"><div class=\"app-icon\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var4 string
|
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + a.Color)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 45, 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, 5, "\">")
|
|
||||||
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, "</div><div class=\"app-name\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(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, "</div><div class=\"app-badge\">Available</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
templ AppPage(name, icon, description string) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
|
||||||
<title>{ name } — Next Workspace</title>
|
|
||||||
<style type="text/css">
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;display:flex;align-items:center;justify-content:center;min-height:100vh;min-height:100dvh}
|
|
||||||
.card{background:#161b22;border:1px solid #30363d;border-radius:16px;padding:48px;text-align:center;max-width:420px;width:90%}
|
|
||||||
.icon{font-size:3rem;margin-bottom:16px}
|
|
||||||
h1{font-size:1.4rem;margin-bottom:8px}
|
|
||||||
.badge{display:inline-block;padding:4px 12px;border-radius:99px;background:rgba(210,153,29,0.15);color:#d2991d;font-size:0.75rem;font-weight:600;margin-bottom:20px}
|
|
||||||
p{color:#8b949e;font-size:0.9rem;margin-bottom:24px;line-height:1.5}
|
|
||||||
.back{color:#58a6ff;text-decoration:none;font-size:0.85rem}
|
|
||||||
.back:hover{text-decoration:underline}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="icon">{ icon }</div>
|
|
||||||
<h1>{ name }</h1>
|
|
||||||
<div class="badge">Coming Soon</div>
|
|
||||||
<p>{ description }</p>
|
|
||||||
<a href="/" class="back">← Back to Workspace</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
|
|
@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\"><title>")
|
|
||||||
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</title><style type=\"text/css\">\n\t\t\t\t*{margin:0;padding:0;box-sizing:border-box}\n\t\t\t\tbody{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;display:flex;align-items:center;justify-content:center;min-height:100vh;min-height:100dvh}\n\t\t\t\t.card{background:#161b22;border:1px solid #30363d;border-radius:16px;padding:48px;text-align:center;max-width:420px;width:90%}\n\t\t\t\t.icon{font-size:3rem;margin-bottom:16px}\n\t\t\t\th1{font-size:1.4rem;margin-bottom:8px}\n\t\t\t\t.badge{display:inline-block;padding:4px 12px;border-radius:99px;background:rgba(210,153,29,0.15);color:#d2991d;font-size:0.75rem;font-weight:600;margin-bottom:20px}\n\t\t\t\tp{color:#8b949e;font-size:0.9rem;margin-bottom:24px;line-height:1.5}\n\t\t\t\t.back{color:#58a6ff;text-decoration:none;font-size:0.85rem}\n\t\t\t\t.back:hover{text-decoration:underline}\n\t\t\t</style></head><body><div class=\"card\"><div class=\"icon\">")
|
|
||||||
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, "</div><h1>")
|
|
||||||
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, "</h1><div class=\"badge\">Coming Soon</div><p>")
|
|
||||||
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, "</p><a href=\"/\" class=\"back\">← Back to Workspace</a></div></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -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)))
|
|
||||||
}
|
|
||||||
|
|
@ -1,362 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version"
|
|
||||||
|
|
||||||
templ LauncherPage(c PageCtx, apps []AppTile) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
|
||||||
<title>Next Workspace</title>
|
|
||||||
<link rel="manifest" href="/static/manifest.json"/>
|
|
||||||
<meta name="theme-color" content="#1a1a2e"/>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
||||||
@workspaceCSS()
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
@topBar(c)
|
|
||||||
<main class="content">
|
|
||||||
@greetingSection(c)
|
|
||||||
@AppGrid(apps)
|
|
||||||
</main>
|
|
||||||
<footer class="footer">
|
|
||||||
<span>NextWks v{ appver.Version }</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PWA Modal -->
|
|
||||||
<div id="pwa-overlay" class="overlay" style="display:none" onclick="closePWA()">
|
|
||||||
<div class="modal" onclick="event.stopPropagation()">
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Install Next Workspace</h2>
|
|
||||||
<button class="close-btn" onclick="closePWA()">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="pwa-content">
|
|
||||||
@pwaInstructions()
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Slide-out Settings Drawer -->
|
|
||||||
<div class="drawer-overlay" id="settings-overlay" onclick="closeSettings()"></div>
|
|
||||||
<aside class="drawer" id="settings-drawer">
|
|
||||||
<div class="drawer-head">
|
|
||||||
<h2>{ c.Locale["settings"] }</h2>
|
|
||||||
<button class="close-btn" onclick="closeSettings()">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-body">
|
|
||||||
<div class="setting-group">
|
|
||||||
<label>{ c.Locale["language"] }</label>
|
|
||||||
<select id="setting-lang" class="form-select">
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="de">Deutsch</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-group">
|
|
||||||
<label>{ c.Locale["timezone"] }</label>
|
|
||||||
<select id="setting-tz" class="form-select">
|
|
||||||
<option value="UTC">UTC</option>
|
|
||||||
<option value="Europe/London">London</option>
|
|
||||||
<option value="Europe/Berlin">Berlin</option>
|
|
||||||
<option value="Europe/Paris">Paris</option>
|
|
||||||
<option value="America/New_York">New York</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn" onclick="saveSettings()">{ c.Locale["save"] }</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
@launcherJS()
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ greetingSection(page PageCtx) {
|
|
||||||
<div class="greeting">
|
|
||||||
<div class="avatar">{ initials(page.UserID) }</div>
|
|
||||||
<div class="greeting-text">
|
|
||||||
<h1>{ page.Locale["greeting_morning"] }, { page.UserID }</h1>
|
|
||||||
<p>{ page.Locale["workspace_ready"] }</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ topBar(page PageCtx) {
|
|
||||||
<header class="topbar">
|
|
||||||
<div class="topbar-left">
|
|
||||||
<div class="logo-icon">N</div>
|
|
||||||
<span class="logo-text">NextWks</span>
|
|
||||||
</div>
|
|
||||||
<div class="topbar-right">
|
|
||||||
<button class="icon-btn" onclick="showPWA()" title={ page.Locale["install_app"] }>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="user-menu" onclick="toggleUserMenu(event)">
|
|
||||||
<div class="avatar sm">{ initials(page.UserID) }</div>
|
|
||||||
<div class="dropdown" id="userDropdown">
|
|
||||||
<div class="dropdown-header">{ page.UserID }</div>
|
|
||||||
<div class="dropdown-item" onclick="window.showSettings()">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l-.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
|
||||||
{ page.Locale["settings"] }
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item" onclick="window.location.href='/auth/logout'">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
||||||
{ page.Locale["logout"] }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ workspaceCSS() {
|
|
||||||
<style type="text/css">
|
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
:root{
|
|
||||||
--bg:#0d1117; --surface:#161b22; --surface2:#21262d; --surface3:#30363d;
|
|
||||||
--border:#30363d; --text:#e6edf3; --text2:#8b949e; --text3:#6e7681;
|
|
||||||
--blue:#58a6ff; --green:#3fb950; --orange:#d2991d; --red:#f85149;
|
|
||||||
--purple:#a371f7; --pink:#db61a2; --teal:#39d353;
|
|
||||||
--radius:16px; --radius-sm:10px
|
|
||||||
}
|
|
||||||
html{font-size:15px}
|
|
||||||
body{
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;
|
|
||||||
background:var(--bg); color:var(--text); min-height:100vh; min-height:100dvh;
|
|
||||||
-webkit-tap-highlight-color:transparent; -webkit-user-select:none; user-select:none
|
|
||||||
}
|
|
||||||
.shell{display:flex;flex-direction:column;min-height:100vh;min-height:100dvh}
|
|
||||||
.topbar{
|
|
||||||
display:flex; justify-content:space-between; align-items:center;
|
|
||||||
padding:12px 16px; background:var(--surface); border-bottom:1px solid var(--border);
|
|
||||||
position:sticky; top:0; z-index:100
|
|
||||||
}
|
|
||||||
.topbar-left{display:flex;align-items:center;gap:10px}
|
|
||||||
.logo-icon{
|
|
||||||
width:32px;height:32px;border-radius:8px;background:var(--blue);
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
font-weight:700;font-size:16px;color:#fff
|
|
||||||
}
|
|
||||||
.logo-text{font-weight:600;font-size:18px;color:var(--text)}
|
|
||||||
.topbar-right{display:flex;align-items:center;gap:8px}
|
|
||||||
.icon-btn{
|
|
||||||
width:36px;height:36px;border-radius:50%;border:none;background:transparent;
|
|
||||||
color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;
|
|
||||||
transition:all 0.15s
|
|
||||||
}
|
|
||||||
.icon-btn:hover{background:var(--surface2);color:var(--text)}
|
|
||||||
.avatar{
|
|
||||||
width:40px;height:40px;border-radius:50%;background:var(--purple);
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
font-weight:600;font-size:15px;color:#fff;flex-shrink:0
|
|
||||||
}
|
|
||||||
.avatar.sm{width:32px;height:32px;font-size:13px}
|
|
||||||
|
|
||||||
/* User Dropdown */
|
|
||||||
.user-menu{position:relative;cursor:pointer}
|
|
||||||
.dropdown{
|
|
||||||
display:none;position:absolute;right:0;top:44px;
|
|
||||||
background:var(--surface);border:1px solid var(--border);border-radius:12px;
|
|
||||||
min-width:180px;padding:6px;z-index:200;box-shadow:0 8px 24px rgba(0,0,0,0.4)
|
|
||||||
}
|
|
||||||
.dropdown.show{display:block}
|
|
||||||
.dropdown-header{padding:10px 12px;font-size:0.85rem;font-weight:500;border-bottom:1px solid var(--border);margin-bottom:4px}
|
|
||||||
.dropdown-item{
|
|
||||||
display:flex;align-items:center;gap:10px;padding:10px 12px;
|
|
||||||
border-radius:8px;font-size:0.85rem;color:var(--text);cursor:pointer;transition:background 0.1s
|
|
||||||
}
|
|
||||||
.dropdown-item:hover{background:var(--surface2)}
|
|
||||||
.dropdown-item svg{flex-shrink:0}
|
|
||||||
.content{flex:1;padding:24px 16px;max-width:900px;margin:0 auto;width:100%}
|
|
||||||
.greeting{display:flex;align-items:center;gap:12px;margin-bottom:24px}
|
|
||||||
@media(min-width:600px){.greeting{margin-bottom:32px}}
|
|
||||||
.greeting-text h1{font-size:1.25rem;font-weight:600;color:var(--text);line-height:1.3}
|
|
||||||
.greeting-text p{color:var(--text2);font-size:0.85rem;margin-top:2px}
|
|
||||||
|
|
||||||
/* App Grid — RADICAL REDESIGN */
|
|
||||||
.app-grid{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:repeat(3,1fr);
|
|
||||||
gap:0px; padding:0;
|
|
||||||
}
|
|
||||||
@media(min-width:600px){.app-grid{grid-template-columns:repeat(4,1fr)}}
|
|
||||||
@media(min-width:900px){.app-grid{grid-template-columns:repeat(6,1fr)}}
|
|
||||||
|
|
||||||
.app-card{
|
|
||||||
display:flex; flex-direction:column; align-items:center; gap:4px;
|
|
||||||
cursor:pointer; text-decoration:none; color:inherit; padding:12px 6px;
|
|
||||||
border-radius:16px; transition:all 0.15s; border:none; background:none
|
|
||||||
}
|
|
||||||
.app-card:active{transform:scale(0.9)}
|
|
||||||
.app-card .app-icon{
|
|
||||||
width:56px;height:56px;border-radius:14px;
|
|
||||||
display:flex;align-items:center;justify-content:center;flex-shrink:0
|
|
||||||
}
|
|
||||||
.app-card .app-icon svg{width:28px;height:28px}
|
|
||||||
.app-card .app-name{font-size:0.72rem;font-weight:400;color:var(--text);text-align:center;line-height:1.2;max-width:68px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
||||||
.app-card .app-badge{display:none}
|
|
||||||
.app-card.disabled{opacity:0.3;cursor:default}
|
|
||||||
.app-card.disabled:active{transform:none}
|
|
||||||
|
|
||||||
/* RADICAL: hot pink border on every card to verify CSS updates */
|
|
||||||
.app-card .app-icon{outline:2px solid #ff1493}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.footer{
|
|
||||||
text-align:center;padding:16px;color:var(--text3);font-size:0.7rem;
|
|
||||||
border-top:1px solid var(--border);margin-top:auto
|
|
||||||
}
|
|
||||||
/* Settings */
|
|
||||||
.setting-group{margin-bottom:14px}
|
|
||||||
.setting-group label{display:block;font-size:0.8rem;color:var(--text2);margin-bottom:4px;font-weight:500}
|
|
||||||
.form-select{
|
|
||||||
width:100%;padding:10px 12px;background:var(--surface2);border:1px solid var(--border);
|
|
||||||
border-radius:8px;color:var(--text);font-size:0.9rem;outline:none;appearance:none
|
|
||||||
}
|
|
||||||
.form-select:focus{border-color:var(--blue)}
|
|
||||||
|
|
||||||
/* Drawer */
|
|
||||||
.drawer-overlay{
|
|
||||||
position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:500;
|
|
||||||
opacity:0;pointer-events:none;transition:opacity 0.25s
|
|
||||||
}
|
|
||||||
.drawer-overlay.open{opacity:1;pointer-events:auto}
|
|
||||||
.drawer{
|
|
||||||
position:fixed;top:0;right:0;bottom:0;width:280px;max-width:85vw;
|
|
||||||
background:var(--surface);z-index:501;transform:translateX(100%);
|
|
||||||
transition:transform 0.25s ease;display:flex;flex-direction:column
|
|
||||||
}
|
|
||||||
.drawer.open{transform:translateX(0)}
|
|
||||||
.drawer-head{
|
|
||||||
display:flex;justify-content:space-between;align-items:center;
|
|
||||||
padding:18px 20px 12px;border-bottom:1px solid var(--border)
|
|
||||||
}
|
|
||||||
.drawer-head h2{font-size:1.1rem;font-weight:600}
|
|
||||||
.drawer-body{padding:20px;flex:1;overflow-y:auto}
|
|
||||||
</style>
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
func version() string {
|
|
||||||
return appver.Version
|
|
||||||
}
|
|
||||||
|
|
||||||
func initials(name string) string {
|
|
||||||
if name == "" { return "?" }
|
|
||||||
if len(name) == 1 { return name }
|
|
||||||
return string(name[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeOfDay() string {
|
|
||||||
// Simple: always show "morning" for now
|
|
||||||
return "morning"
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PWA Instructions ---
|
|
||||||
|
|
||||||
templ pwaInstructions() {
|
|
||||||
<h3>Desktop Chrome/Edge</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Click the <code>⊕</code> icon in the address bar</li>
|
|
||||||
<li>Click <strong>Install</strong></li>
|
|
||||||
</ol>
|
|
||||||
<h3>iOS Safari</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Tap <strong>Share</strong> <code>⎋</code></li>
|
|
||||||
<li>Tap <strong>Add to Home Screen</strong></li>
|
|
||||||
</ol>
|
|
||||||
<h3>Android Chrome</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Tap <strong>⋮</strong> menu</li>
|
|
||||||
<li>Tap <strong>Install app</strong></li>
|
|
||||||
</ol>
|
|
||||||
<button class="btn" onclick="closePWA()">Got it</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- JavaScript ---
|
|
||||||
|
|
||||||
templ launcherJS() {
|
|
||||||
<script type="text/javascript">
|
|
||||||
let longPressTimer = null;
|
|
||||||
let currentUrl = null;
|
|
||||||
|
|
||||||
function openApp() {
|
|
||||||
var url = this.getAttribute('data-url');
|
|
||||||
if (url) window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLongPress(e, url) {
|
|
||||||
currentUrl = url || null;
|
|
||||||
longPressTimer = setTimeout(function() {
|
|
||||||
showPWA();
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelLongPress() {
|
|
||||||
if (longPressTimer) {
|
|
||||||
clearTimeout(longPressTimer);
|
|
||||||
longPressTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPWA() {
|
|
||||||
document.getElementById('pwa-overlay').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePWA() {
|
|
||||||
document.getElementById('pwa-overlay').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleUserMenu(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
document.getElementById('userDropdown').classList.toggle('show');
|
|
||||||
}
|
|
||||||
document.addEventListener('click', function() {
|
|
||||||
var d = document.getElementById('userDropdown');
|
|
||||||
if (d) d.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
window.showSettings = function() {
|
|
||||||
document.getElementById('settings-overlay').classList.add('open');
|
|
||||||
document.getElementById('settings-drawer').classList.add('open');
|
|
||||||
document.getElementById('userDropdown').classList.remove('show');
|
|
||||||
document.getElementById('setting-lang').value = localStorage.getItem('lang')||'en';
|
|
||||||
document.getElementById('setting-tz').value = localStorage.getItem('tz')||'UTC';
|
|
||||||
};
|
|
||||||
function closeSettings() {
|
|
||||||
document.getElementById('settings-overlay').classList.remove('open');
|
|
||||||
document.getElementById('settings-drawer').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettings() {
|
|
||||||
localStorage.setItem('lang', document.getElementById('setting-lang').value);
|
|
||||||
localStorage.setItem('tz', document.getElementById('setting-tz').value);
|
|
||||||
closeSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// PWA install handler
|
|
||||||
let deferredPrompt = null;
|
|
||||||
window.addEventListener('beforeinstallprompt', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
deferredPrompt = e;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Override showPWA to try native prompt first
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var origShowPWA = showPWA;
|
|
||||||
showPWA = function() {
|
|
||||||
if (deferredPrompt) {
|
|
||||||
deferredPrompt.prompt();
|
|
||||||
deferredPrompt.userChoice.then(function() { deferredPrompt = null; });
|
|
||||||
} else {
|
|
||||||
origShowPWA();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,44 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
templ LogoutPage(authLogoutURL, workspaceURL string) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>Logged Out — Next Workspace</title>
|
|
||||||
<style type="text/css">
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
|
||||||
.card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:40px;text-align:center;max-width:380px;width:90%}
|
|
||||||
h1{font-size:1.4rem;margin-bottom:8px}
|
|
||||||
p{color:#8b949e;font-size:0.9rem;margin-bottom:24px;line-height:1.5}
|
|
||||||
.btn{display:block;width:100%;padding:12px;border-radius:8px;border:none;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;margin-bottom:10px;text-align:center}
|
|
||||||
.btn-primary{background:#58a6ff;color:#fff}
|
|
||||||
.btn-primary:hover{opacity:0.9}
|
|
||||||
.btn-secondary{background:transparent;border:1px solid #30363d;color:#8b949e}
|
|
||||||
.btn-secondary:hover{background:#21262d}
|
|
||||||
.status{color:#3fb950;font-size:0.85rem;margin-bottom:16px}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1>Logged Out</h1>
|
|
||||||
<div class="status">✓ Session cleared</div>
|
|
||||||
<p>You have been logged out of Next Workspace. Your identity provider session will also be cleared.</p>
|
|
||||||
<a href={ workspaceURL } class="btn btn-primary">Re-login</a>
|
|
||||||
if authLogoutURL != "" {
|
|
||||||
<a href={ authLogoutURL } class="btn btn-secondary">Sign out from Authelia</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
// Only auto-redirect on first visit (before Authelia logout returns)
|
|
||||||
if (document.querySelector('.btn-secondary')) {
|
|
||||||
setTimeout(function() {
|
|
||||||
window.location.href = document.querySelector('.btn-secondary').href;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
|
|
@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Logged Out — Next Workspace</title><style type=\"text/css\">\n\t\t\t\t*{margin:0;padding:0;box-sizing:border-box}\n\t\t\t\tbody{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;display:flex;align-items:center;justify-content:center;min-height:100vh}\n\t\t\t\t.card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:40px;text-align:center;max-width:380px;width:90%}\n\t\t\t\th1{font-size:1.4rem;margin-bottom:8px}\n\t\t\t\tp{color:#8b949e;font-size:0.9rem;margin-bottom:24px;line-height:1.5}\n\t\t\t\t.btn{display:block;width:100%;padding:12px;border-radius:8px;border:none;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;margin-bottom:10px;text-align:center}\n\t\t\t\t.btn-primary{background:#58a6ff;color:#fff}\n\t\t\t\t.btn-primary:hover{opacity:0.9}\n\t\t\t\t.btn-secondary{background:transparent;border:1px solid #30363d;color:#8b949e}\n\t\t\t\t.btn-secondary:hover{background:#21262d}\n\t\t\t\t.status{color:#3fb950;font-size:0.85rem;margin-bottom:16px}\n\t\t\t</style></head><body><div class=\"card\"><h1>Logged Out</h1><div class=\"status\">✓ Session cleared</div><p>You have been logged out of Next Workspace. Your identity provider session will also be cleared.</p><a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var2 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(workspaceURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/logout.templ`, Line: 29, Col: 25}
|
|
||||||
}
|
|
||||||
_, 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, "\" class=\"btn btn-primary\">Re-login</a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if authLogoutURL != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(authLogoutURL)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/logout.templ`, Line: 31, Col: 27}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"btn btn-secondary\">Sign out from Authelia</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><script type=\"text/javascript\">\n\t\t\t\t// Only auto-redirect on first visit (before Authelia logout returns)\n\t\t\t\tif (document.querySelector('.btn-secondary')) {\n\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\twindow.location.href = document.querySelector('.btn-secondary').href;\n\t\t\t\t\t}, 1500);\n\t\t\t\t}\n\t\t\t</script></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
templ PWAInstallPrompt() {
|
|
||||||
<div class="pwa-prompt" id="pwa-prompt">
|
|
||||||
<h3>🚀 Install Next Workspace</h3>
|
|
||||||
<p>Install as an app for quick access and offline support.</p>
|
|
||||||
<button class="btn btn-primary" onclick="installPWA()">
|
|
||||||
Install to Desktop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ PWAGuideModal() {
|
|
||||||
<div class="modal" onclick="event.stopPropagation()">
|
|
||||||
<button class="modal-close" onclick="closePWAModal()">×</button>
|
|
||||||
<h2>Install Next Workspace</h2>
|
|
||||||
|
|
||||||
<p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p>
|
|
||||||
|
|
||||||
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;">🖥️ Desktop Chrome/Edge</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li>
|
|
||||||
<li>Click <strong>Install</strong> in the popup</li>
|
|
||||||
<li>The app will open in its own window</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">📱 iOS Safari</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li>
|
|
||||||
<li>Scroll down and tap <strong>Add to Home Screen</strong></li>
|
|
||||||
<li>Tap <strong>Add</strong> in the top-right corner</li>
|
|
||||||
<li>The app icon will appear on your home screen</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">🤖 Android Chrome</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li>
|
|
||||||
<li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li>
|
|
||||||
<li>Tap <strong>Install</strong></li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<button class="btn btn-primary" style="margin-top:1rem;width:100%;" onclick="closePWAModal()">
|
|
||||||
Got it
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package ui
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
func PWAInstallPrompt() templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"pwa-prompt\" id=\"pwa-prompt\"><h3>🚀 Install Next Workspace</h3><p>Install as an app for quick access and offline support.</p><button class=\"btn btn-primary\" onclick=\"installPWA()\">Install to Desktop</button></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func PWAGuideModal() templ.Component {
|
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var2 == nil {
|
|
||||||
templ_7745c5c3_Var2 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"modal\" onclick=\"event.stopPropagation()\"><button class=\"modal-close\" onclick=\"closePWAModal()\">×</button><h2>Install Next Workspace</h2><p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;\">🖥️ Desktop Chrome/Edge</h3><ol><li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li><li>Click <strong>Install</strong> in the popup</li><li>The app will open in its own window</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">📱 iOS Safari</h3><ol><li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li><li>Scroll down and tap <strong>Add to Home Screen</strong></li><li>Tap <strong>Add</strong> in the top-right corner</li><li>The app icon will appear on your home screen</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">🤖 Android Chrome</h3><ol><li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li><li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li><li>Tap <strong>Install</strong></li></ol><button class=\"btn btn-primary\" style=\"margin-top:1rem;width:100%;\" onclick=\"closePWAModal()\">Got it</button></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package version
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Version is set at build time via ldflags: -X git.lohmar.co.uk/lexton-it/NextWks/core/version.Version=2026.6.0001
|
|
||||||
Version = "dev"
|
|
||||||
|
|
||||||
// BuildTime is set at build time via ldflags.
|
|
||||||
BuildTime = "unknown"
|
|
||||||
|
|
||||||
// CommitSHA is set at build time via ldflags.
|
|
||||||
CommitSHA = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Info returns a formatted version info response.
|
|
||||||
func Info() map[string]string {
|
|
||||||
return map[string]string{
|
|
||||||
"version": Version,
|
|
||||||
"build_time": BuildTime,
|
|
||||||
"commit": CommitSHA,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
src/go.mod
21
src/go.mod
|
|
@ -1,21 +0,0 @@
|
||||||
module git.lohmar.co.uk/lexton-it/NextWks
|
|
||||||
|
|
||||||
go 1.25.0
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/a-h/templ v0.3.1020 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/go-chi/chi/v5 v5.3.0 // indirect
|
|
||||||
github.com/go-chi/cors v1.2.2 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
golang.org/x/crypto v0.53.0 // indirect
|
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
modernc.org/libc v1.72.3 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
modernc.org/sqlite v1.52.0 // indirect
|
|
||||||
)
|
|
||||||
34
src/go.sum
34
src/go.sum
|
|
@ -1,34 +0,0 @@
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
|
||||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
|
||||||
351
src/main.go
351
src/main.go
|
|
@ -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("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>NextWks</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n display: flex; justify-content: center; align-items: center;\n min-height: 100vh; background: #0f172a; color: #e2e8f0; }\n .card { background: #1e293b; padding: 3rem 4rem; border-radius: 16px;\n box-shadow: 0 4px 24px rgba(0,0,0,0.3); text-align: center;\n max-width: 520px; border: 1px solid #334155; }\n h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.75rem; color: #f8fafc; }\n p { color: #94a3b8; line-height: 1.6; margin-bottom: 1.5rem; }\n a { color: #38bdf8; text-decoration: none; font-weight: 600;\n padding: 0.5rem 1.5rem; border: 1px solid #38bdf8;\n border-radius: 8px; display: inline-block; transition: all 0.2s; }\n a:hover { background: #38bdf8; color: #0f172a; }\n </style>\n</head>\n<body>\n <div class=\"card\">\n <h1>Next Workspace</h1>\n <p>This application isn't available yet. It may still be provisioning or the route hasn't been configured.</p>\n <a href=\"/\">Return to Dashboard</a>\n </div>\n</body>\n</html>"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- 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()
|
|
||||||
}
|
|
||||||
27
testdata/authelia/configuration.yml
vendored
27
testdata/authelia/configuration.yml
vendored
|
|
@ -1,27 +0,0 @@
|
||||||
# Authelia Configuration (Development Mock)
|
|
||||||
# Path: used for testing config parsing
|
|
||||||
|
|
||||||
host: 0.0.0.0
|
|
||||||
port: 9091
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: debug
|
|
||||||
|
|
||||||
jwt_secret: dev-jwt-secret-change-in-production
|
|
||||||
|
|
||||||
session:
|
|
||||||
name: authelia_session
|
|
||||||
secret: dev-authelia-session-secret-please-change
|
|
||||||
expiration: 1h
|
|
||||||
inactivity: 5m
|
|
||||||
|
|
||||||
storage:
|
|
||||||
local:
|
|
||||||
path: /opt/authelia/data/db.sqlite
|
|
||||||
|
|
||||||
access_control:
|
|
||||||
default_policy: deny
|
|
||||||
|
|
||||||
authentication_backend:
|
|
||||||
file:
|
|
||||||
path: /opt/authelia/data/users_database.yml
|
|
||||||
68
update.sh
68
update.sh
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue