diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..4444c8eba --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# ============================================================================ +# Dungeon Master's Vault — Docker Environment Configuration +# +# Copy this file to .env and update the values: +# cp .env.example .env +# +# Or run the setup script to generate .env with secure random values: +# ./docker-setup.sh +# ============================================================================ + +# --- Application --- +PORT=8890 + +# --- Datomic Database --- +# ADMIN_PASSWORD secures the Datomic admin interface +# DATOMIC_PASSWORD is used by the application to connect to Datomic +# The password in DATOMIC_URL must match DATOMIC_PASSWORD +ADMIN_PASSWORD=change-me-admin +DATOMIC_PASSWORD=change-me-datomic +DATOMIC_URL=datomic:free://datomic:4334/orcpub?password=change-me-datomic + +# --- Security --- +# Secret used to sign JWT tokens (20+ characters recommended) +SIGNATURE=change-me-to-something-unique-and-long + +# --- Email (SMTP) --- +# Leave EMAIL_SERVER_URL empty to disable email functionality +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE + +# --- Initial Admin User (optional) --- +# Set these then run: ./docker-user.sh init +# Safe to run multiple times — duplicates are skipped. +INIT_ADMIN_USER= +INIT_ADMIN_EMAIL= +INIT_ADMIN_PASSWORD= diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml new file mode 100644 index 000000000..4ecf2ca08 --- /dev/null +++ b/.github/workflows/docker-integration.yml @@ -0,0 +1,303 @@ +name: Docker Integration Test + +on: + pull_request: + branches: [develop] + paths: + - 'docker/**' + - 'docker-compose*.yaml' + - 'docker-setup.sh' + - 'docker-user.sh' + - 'deploy/**' + - '.github/workflows/docker-integration.yml' + workflow_dispatch: + +jobs: + docker-test: + name: Docker Setup & User Management + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Lint shell scripts + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck docker-setup.sh docker-user.sh + + - name: Run docker-setup.sh --auto + run: | + ./docker-setup.sh --auto + echo "--- Generated .env (secrets redacted) ---" + sed 's/=.*/=***/' .env + + - name: Validate .env password consistency + run: | + PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) + URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') + if [ "$PW" != "$URL_PW" ]; then + echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" + exit 1 + fi + echo "OK: Passwords match" + + - name: Test — setup --force preserves existing values + run: | + # Save original passwords + ORIG_ADMIN=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2) + ORIG_DATOMIC=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) + ORIG_SIG=$(grep '^SIGNATURE=' .env | cut -d= -f2) + + # Re-run with --force --auto (should regenerate) + ./docker-setup.sh --auto --force + + # Verify .env was regenerated (new passwords, since --auto generates fresh ones) + NEW_ADMIN=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2) + NEW_DATOMIC=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) + + # Verify structure is intact + grep -q '^DATOMIC_URL=' .env || { echo "FAIL: DATOMIC_URL missing"; exit 1; } + grep -q '^SIGNATURE=' .env || { echo "FAIL: SIGNATURE missing"; exit 1; } + grep -q '^PORT=' .env || { echo "FAIL: PORT missing"; exit 1; } + + # Re-check password consistency after --force + PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) + URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') + if [ "$PW" != "$URL_PW" ]; then + echo "FAIL: Password mismatch after --force re-run" + exit 1 + fi + echo "OK: --force regenerated .env with consistent passwords" + + - name: Start datomic (no deps) + run: | + docker compose pull + docker compose up -d --no-deps datomic + echo "Datomic container started, waiting for health..." + + - name: Wait for datomic healthy + run: | + for i in $(seq 1 90); do + CID=$(docker compose ps -q datomic 2>/dev/null) || true + if [ -z "$CID" ]; then + echo " [$i/90] datomic container not found yet" + sleep 2 + continue + fi + RUNNING=$(docker inspect --format='{{.State.Running}}' "$CID" 2>/dev/null || echo "false") + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting") + echo " [$i/90] running=$RUNNING health=$STATUS" + if [ "$STATUS" = "healthy" ]; then + echo "Datomic is healthy (after ~$((i * 2))s)" + break + fi + if [ "$RUNNING" = "false" ]; then + echo "WARN: datomic container stopped — dumping logs" + docker compose logs datomic + echo "Container will restart (restart: always), continuing to wait..." + fi + if [ "$i" -eq 90 ]; then + echo "FAIL: Datomic did not become healthy within 180s" + echo "=== container state ===" + docker inspect --format='{{json .State}}' "$CID" | python3 -m json.tool || true + echo "=== datomic logs ===" + docker compose logs datomic + exit 1 + fi + sleep 2 + done + + - name: Start orcpub and web + run: | + docker compose up -d + docker compose ps + + - name: Wait for orcpub healthy + run: | + for i in $(seq 1 90); do + CID=$(docker compose ps -q orcpub 2>/dev/null) || true + if [ -z "$CID" ]; then + echo " [$i/90] orcpub container not found yet" + sleep 2 + continue + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting") + echo " [$i/90] orcpub health=$STATUS" + if [ "$STATUS" = "healthy" ]; then + echo "orcpub is healthy (after ~$((i * 2))s)" + break + fi + if [ "$STATUS" = "unhealthy" ]; then + echo "FAIL: orcpub reported unhealthy" + echo "=== all logs ===" + docker compose logs + exit 1 + fi + if [ "$i" -eq 90 ]; then + echo "FAIL: orcpub did not become healthy within 180s" + docker compose logs + exit 1 + fi + sleep 2 + done + docker compose ps + + - name: Test — create user + run: | + ./docker-user.sh create testadmin admin@test.local SecurePass123 + echo "Exit code: $?" + + - name: Test — check user exists + run: | + OUTPUT=$(./docker-user.sh check testadmin) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + echo "$OUTPUT" | grep -q "admin@test.local" + echo "$OUTPUT" | grep -q "true" # verified + + - name: Test — list includes user + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + + - name: Test — duplicate user fails + run: | + if ./docker-user.sh create testadmin admin@test.local SecurePass123 2>&1; then + echo "FAIL: Should have rejected duplicate user" + exit 1 + fi + echo "OK: Duplicate user correctly rejected" + + - name: Test — create second user + run: ./docker-user.sh create player2 player2@test.local AnotherPass456 + + - name: Test — list shows both users + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + echo "$OUTPUT" | grep -q "player2" + + - name: Test — verify already-verified user is idempotent + run: | + OUTPUT=$(./docker-user.sh verify testadmin) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "already verified" + + - name: Test — batch create users (with duplicates) + run: | + cat > /tmp/test-users.txt <<'TXT' + # Test batch file + batch1 batch1@test.local BatchPass111 + batch2 batch2@test.local BatchPass222 + # This next line is a duplicate from earlier single-create test + testadmin admin@test.local SecurePass123 + TXT + OUTPUT=$(./docker-user.sh batch /tmp/test-users.txt) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "batch1" + echo "$OUTPUT" | grep -q "batch2" + echo "$OUTPUT" | grep -q "SKIP.*testadmin" + echo "$OUTPUT" | grep -q "2 created" + echo "$OUTPUT" | grep -q "1 skipped (duplicate)" + echo "$OUTPUT" | grep -q "0 failed" + echo "OK: Batch created 2 new, skipped 1 duplicate" + + - name: Test — batch users appear in list + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "batch1" + echo "$OUTPUT" | grep -q "batch2" + + - name: Test — init creates admin from .env + run: | + # Append INIT_ADMIN_* vars to .env + printf '\nINIT_ADMIN_USER=initadmin\nINIT_ADMIN_EMAIL=initadmin@test.local\nINIT_ADMIN_PASSWORD=InitPass789\n' >> .env + + # Run init + OUTPUT=$(./docker-user.sh init) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "initadmin" + + # Verify user was created + CHECK=$(./docker-user.sh check initadmin) + echo "$CHECK" + echo "$CHECK" | grep -q "initadmin" + echo "$CHECK" | grep -q "initadmin@test.local" + echo "$CHECK" | grep -q "true" + echo "OK: init created admin from .env" + + - name: Test — init is idempotent (re-run skips existing) + run: | + # Running init again should not fail — duplicate is handled + if ./docker-user.sh init 2>&1; then + echo "FAIL: init should exit non-zero for duplicate user" + exit 1 + fi + echo "OK: init correctly reports duplicate on re-run" + + - name: Test — check nonexistent user fails + run: | + if ./docker-user.sh check nobody@nowhere.com 2>&1; then + echo "FAIL: Should have reported user not found" + exit 1 + fi + echo "OK: Nonexistent user correctly not found" + + - name: Test — created user can log in via HTTP + run: | + # Use nginx (port 443) since orcpub:8890 is not exposed to host + RESPONSE=$(curl -sk -X POST https://localhost/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testadmin","password":"SecurePass123"}' \ + -w "\n%{http_code}" 2>&1) || true + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP $HTTP_CODE" + echo "$BODY" + + if [ "$HTTP_CODE" = "200" ]; then + echo "OK: Login succeeded" + echo "$BODY" | grep -q "token" + echo "OK: Response contains JWT token" + else + echo "FAIL: Expected HTTP 200, got $HTTP_CODE" + exit 1 + fi + + - name: Test — wrong password is rejected + run: | + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + -X POST https://localhost/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testadmin","password":"WrongPassword"}' 2>&1) || true + + echo "HTTP $HTTP_CODE" + if [ "$HTTP_CODE" = "401" ]; then + echo "OK: Wrong password correctly rejected" + else + echo "FAIL: Expected HTTP 401, got $HTTP_CODE" + exit 1 + fi + + - name: Collect logs on failure + if: failure() + run: | + echo "=== docker compose ps ===" + docker compose ps + echo "=== datomic logs ===" + docker compose logs datomic + echo "=== orcpub logs ===" + docker compose logs orcpub + echo "=== web logs ===" + docker compose logs web + + - name: Cleanup + if: always() + run: docker compose down -v diff --git a/.gitignore b/.gitignore index 3e3c55535..9638d7758 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ pom.xml orcpub.iml profiles.clj env.sh +.env .repl .nrepl-port *~ @@ -34,3 +35,6 @@ deploy/homebrew/* # As created by some LSP-protocol tooling, e.g. nvim-lsp .lsp + +# Claude Code local session data +.claude-data/ diff --git a/README.md b/README.md index f70fcafaa..f64254102 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,25 @@ You will need a few tools: `git clone https://github.com/Orcpub/orcpub.git` if you don't have a github account `git clone git@github.com:Orcpub/orcpub.git` if you do want to make changes to the code and make pull requests. - + +### Quick Setup (Recommended) + +Run the automated setup script to generate secure passwords, SSL certificates, and all required directories: + +```bash +./docker-setup.sh # Interactive — prompts for each value +./docker-setup.sh --auto # Non-interactive — generates secure defaults +``` + +Then start the containers and create your first user: + +```bash +docker-compose up -d +./docker-user.sh create admin admin@example.com MySecurePass123 +``` + +The `create` command creates a **pre-verified** account — no SMTP server or email confirmation needed. For batch user creation, additional commands, and full details see the [Docker User Management](docs/docker-user-management.md) guide. + ### Edit docker-compose.yaml Edit the `docker-compose.yaml` and update all the environmental variables and or paths as needed. diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml index 296e97768..015b6daa8 100644 --- a/docker-compose-build.yaml +++ b/docker-compose-build.yaml @@ -1,40 +1,47 @@ --- -version: '3' services: orcpub: build: context: . dockerfile: docker/orcpub/Dockerfile environment: - PORT: 8890 - EMAIL_SERVER_URL: '' - EMAIL_ACCESS_KEY: '' - EMAIL_SECRET_KEY: '' - EMAIL_SERVER_PORT: 587 - # Email address to send from, will default to 'no-reply@orcpub.com' - EMAIL_FROM_ADDRESS: '' - # Email address to send errors to - EMAIL_ERRORS_TO: '' - EMAIL_SSL: 'TRUE' - EMAIL_TLS: 'FALSE' - # Datomic connection string - Make sure the matches the DATOMIC_PASSWORD below - DATOMIC_URL: datomic:free://datomic:4334/orcpub?password= - # The secret used to hash your password in the browser, 20+ characters recommended - SIGNATURE: '' + PORT: ${PORT:-8890} + EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} + EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} + EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} + EMAIL_SSL: ${EMAIL_SSL:-FALSE} + EMAIL_TLS: ${EMAIL_TLS:-FALSE} + DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} + SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - - datomic + datomic: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8890/"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s restart: always datomic: build: context: . dockerfile: docker/datomic/Dockerfile environment: - ADMIN_PASSWORD: - # Must match the in the DATOMIC_URL above. - DATOMIC_PASSWORD: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} volumes: - ./data:/data - ./logs:/logs + healthcheck: + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 40s restart: always web: image: nginx:alpine @@ -46,5 +53,6 @@ services: - ./deploy/snakeoil.crt:/etc/nginx/snakeoil.crt - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key depends_on: - - orcpub + orcpub: + condition: service_healthy restart: always diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ebaa5048..08e508977 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,36 +1,43 @@ --- -version: '3' services: orcpub: image: orcpub/orcpub:latest environment: - PORT: 8890 - EMAIL_SERVER_URL: '' - EMAIL_ACCESS_KEY: '' - EMAIL_SECRET_KEY: '' - EMAIL_SERVER_PORT: 587 - # Email address to send from, will default to 'no-reply@orcpub.com' - EMAIL_FROM_ADDRESS: '' - # Email address to send errors to - EMAIL_ERRORS_TO: '' - EMAIL_SSL: 'FALSE' - EMAIL_TLS: 'FALSE' - # Datomic connection string - Make sure the matches the DATOMIC_PASSWORD below - DATOMIC_URL: datomic:free://datomic:4334/orcpub?password= - # The secret used to hash your password in the browser, 20+ characters recommended - SIGNATURE: '' + PORT: ${PORT:-8890} + EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} + EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} + EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} + EMAIL_SSL: ${EMAIL_SSL:-FALSE} + EMAIL_TLS: ${EMAIL_TLS:-FALSE} + DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} + SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - - datomic + datomic: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8890/"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s restart: always datomic: image: orcpub/datomic:latest environment: - ADMIN_PASSWORD: - # Must match the in the DATOMIC_URL above. - DATOMIC_PASSWORD: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} volumes: - ./data:/data - ./logs:/logs + healthcheck: + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 40s restart: always web: image: nginx:alpine @@ -43,5 +50,6 @@ services: - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key - ./deploy/homebrew/:/usr/share/nginx/html/homebrew/ depends_on: - - orcpub + orcpub: + condition: service_healthy restart: always diff --git a/docker-setup.sh b/docker-setup.sh new file mode 100755 index 000000000..6d97a64fa --- /dev/null +++ b/docker-setup.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# +# OrcPub / Dungeon Master's Vault — Docker Setup Script +# +# Prepares everything needed to run the application via Docker Compose: +# 1. Generates secure random passwords and a signing secret +# 2. Creates a .env file (or uses an existing one) +# 3. Generates self-signed SSL certificates (if missing) +# 4. Creates required directories (data, logs, deploy/homebrew) +# +# Usage: +# ./docker-setup.sh # Interactive mode — prompts for optional values +# ./docker-setup.sh --auto # Non-interactive — accepts all defaults +# ./docker-setup.sh --help # Show usage +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green='\033[0;32m' +color_yellow='\033[1;33m' +color_red='\033[0;31m' +color_cyan='\033[0;36m' +color_reset='\033[0m' + +info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } + +generate_password() { + # Generate a URL-safe random password (no special chars that break URLs/YAML) + local length="${1:-24}" + if command -v openssl &>/dev/null; then + openssl rand -base64 "$length" | tr -d '/+=' | head -c "$length" + elif [ -r /dev/urandom ]; then + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$length" + else + error "Cannot generate random password: no openssl or /dev/urandom available" + exit 1 + fi +} + +prompt_value() { + local prompt_text="$1" + local default_value="$2" + local result + + if [ "${AUTO_MODE:-false}" = "true" ]; then + echo "$default_value" + return + fi + + if [ -n "$default_value" ]; then + read -rp "${prompt_text} [${default_value}]: " result + echo "${result:-$default_value}" + else + read -rp "${prompt_text}: " result + echo "$result" + fi +} + +usage() { + cat <<'USAGE' +Usage: ./docker-setup.sh [OPTIONS] + +Options: + --auto Non-interactive mode; accept all defaults + --force Overwrite existing .env file + --help Show this help message + +Examples: + ./docker-setup.sh # Interactive setup + ./docker-setup.sh --auto # Quick setup with generated defaults + ./docker-setup.sh --auto --force # Regenerate everything from scratch +USAGE +} + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- + +AUTO_MODE=false +FORCE_MODE=false + +for arg in "$@"; do + case "$arg" in + --auto) AUTO_MODE=true ;; + --force) FORCE_MODE=true ;; + --help) usage; exit 0 ;; + *) + error "Unknown option: $arg" + usage + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +header "Dungeon Master's Vault — Docker Setup" + +# ---- Step 1: .env file --------------------------------------------------- + +if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then + info "Existing .env file found. Skipping generation (use --force to overwrite)." +else + # Source existing .env (if any) so current values become defaults for prompts + if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ENV_FILE" + fi + + header "Database Passwords" + + # Generate defaults but let user override + DEFAULT_ADMIN_PW="$(generate_password 24)" + DEFAULT_DATOMIC_PW="$(generate_password 24)" + DEFAULT_SIGNATURE="$(generate_password 32)" + + ADMIN_PASSWORD=$(prompt_value "Datomic admin password" "$DEFAULT_ADMIN_PW") + DATOMIC_PASSWORD=$(prompt_value "Datomic application password" "$DEFAULT_DATOMIC_PW") + SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") + + header "Application" + + PORT=$(prompt_value "Application port" "8890") + EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") + EMAIL_ACCESS_KEY="" + EMAIL_SECRET_KEY="" + EMAIL_SERVER_PORT="587" + EMAIL_FROM_ADDRESS="" + EMAIL_ERRORS_TO="" + EMAIL_SSL="FALSE" + EMAIL_TLS="FALSE" + + if [ -n "$EMAIL_SERVER_URL" ]; then + EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") + EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") + EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") + EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") + EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") + EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") + EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") + fi + + header "Initial Admin User" + + # Check environment / existing .env for pre-set values + INIT_ADMIN_USER="${INIT_ADMIN_USER:-}" + INIT_ADMIN_EMAIL="${INIT_ADMIN_EMAIL:-}" + INIT_ADMIN_PASSWORD="${INIT_ADMIN_PASSWORD:-}" + + if [ -n "$INIT_ADMIN_USER" ] && [ -n "$INIT_ADMIN_EMAIL" ] && [ -n "$INIT_ADMIN_PASSWORD" ]; then + info "Using admin user from environment: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + elif [ "${AUTO_MODE}" = "true" ]; then + info "No INIT_ADMIN_* variables set. Skipping admin user setup." + info "Create users later with: ./docker-user.sh create ..." + else + info "Optionally create an initial admin account." + info "You can skip this and create users later with ./docker-user.sh" + echo "" + INIT_ADMIN_USER=$(prompt_value "Admin username (leave empty to skip)" "") + if [ -n "$INIT_ADMIN_USER" ]; then + INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "") + INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "") + if [ -z "$INIT_ADMIN_EMAIL" ] || [ -z "$INIT_ADMIN_PASSWORD" ]; then + warn "Email and password are required. Skipping admin user setup." + INIT_ADMIN_USER="" + INIT_ADMIN_EMAIL="" + INIT_ADMIN_PASSWORD="" + fi + fi + fi + + info "Writing .env file..." + + cat > "$ENV_FILE" </dev/null; then + info "Generating self-signed SSL certificate..." + openssl req \ + -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ + -x509 \ + -nodes \ + -days 365 \ + -newkey rsa:2048 \ + -keyout "$KEY_FILE" \ + -out "$CERT_FILE" \ + 2>/dev/null + info "SSL certificate generated (valid for 365 days)." + else + warn "openssl not found — cannot generate SSL certificates." + warn "Install openssl and run: ./deploy/snakeoil.sh" + fi +fi + +# ---- Step 4: Validation -------------------------------------------------- + +header "Validation" + +ERRORS=0 + +check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING (${path})" + ERRORS=$((ERRORS + 1)) + fi +} + +check_dir() { + local label="$1" path="$2" + if [ -d "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING (${path})" + ERRORS=$((ERRORS + 1)) + fi +} + +check_file ".env" "$ENV_FILE" +check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" +check_file "nginx.conf" "${SCRIPT_DIR}/deploy/nginx.conf" +check_file "SSL certificate" "$CERT_FILE" +check_file "SSL key" "$KEY_FILE" +check_dir "data/" "${SCRIPT_DIR}/data" +check_dir "logs/" "${SCRIPT_DIR}/logs" +check_dir "deploy/homebrew/" "${SCRIPT_DIR}/deploy/homebrew" + +echo "" + +if [ "$ERRORS" -gt 0 ]; then + warn "Setup completed with ${ERRORS} warning(s). Review the items above." +else + info "All checks passed!" +fi + +# ---- Step 5: Next steps --------------------------------------------------- + +header "Next Steps" + +cat <<'NEXT' +1. Review your .env file and adjust values if needed. + +2. Launch the application: + docker-compose up -d + +3. Create your first user (once containers are running): + ./docker-user.sh init # uses admin from .env + ./docker-user.sh create # or specify directly + +4. Access the site at: + https://localhost + +5. Manage users later with: + ./docker-user.sh list # List all users + ./docker-user.sh check # Check a user's status + ./docker-user.sh verify # Verify an unverified user + +6. To import homebrew content, place your .orcbrew file at: + deploy/homebrew/homebrew.orcbrew + +7. To build from source instead of pulling images: + docker-compose -f docker-compose-build.yaml build + docker-compose -f docker-compose-build.yaml up -d + +For more details, see README.md. +NEXT diff --git a/docker-user.sh b/docker-user.sh new file mode 100755 index 000000000..1ca3151e2 --- /dev/null +++ b/docker-user.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# +# OrcPub Docker User Management +# +# Injects and verifies users in the Datomic database running inside Docker. +# Works by executing Clojure code inside the orcpub container, using the +# uberjar classpath (which already has datomic.api and buddy.hashers). +# +# Usage: +# ./docker-user.sh create +# ./docker-user.sh verify +# ./docker-user.sh check +# ./docker-user.sh list +# +# The script auto-detects the orcpub container name from docker-compose. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANAGE_SCRIPT="${SCRIPT_DIR}/docker/scripts/manage-user.clj" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green='\033[0;32m' +color_red='\033[0;31m' +color_yellow='\033[1;33m' +color_reset='\033[0m' + +info() { printf '%s[OK]%s %s\n' "$color_green" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } + +usage() { + cat <<'USAGE' +OrcPub Docker User Management + +Usage: + ./docker-user.sh init + Create the initial admin user from INIT_ADMIN_* variables in .env + Safe to run multiple times — duplicates are skipped. + + ./docker-user.sh create + Create a new user (auto-verified, skips email) + + ./docker-user.sh batch + Create multiple users from a file (one JVM startup). + File format: one user per line — username email password + Lines starting with # and blank lines are skipped. + Duplicates are logged and skipped (not treated as errors). + + ./docker-user.sh verify + Verify an existing unverified user + + ./docker-user.sh check + Check if a user exists and show their status + + ./docker-user.sh list + List all users in the database + +Options: + --container Override container name detection + --help Show this help + +Examples: + ./docker-user.sh init + ./docker-user.sh create admin admin@example.com MySecurePass123 + ./docker-user.sh batch users.txt + ./docker-user.sh check admin + ./docker-user.sh list +USAGE +} + +# --------------------------------------------------------------------------- +# Find the orcpub container +# --------------------------------------------------------------------------- + +find_container() { + local container="" + + # Try docker-compose/docker compose service name first + if command -v docker-compose &>/dev/null; then + container=$(docker-compose ps -q orcpub 2>/dev/null || true) + fi + if [ -z "$container" ] && docker compose version &>/dev/null 2>&1; then + container=$(docker compose ps -q orcpub 2>/dev/null || true) + fi + + # Fallback: search by image name + if [ -z "$container" ]; then + container=$(docker ps -q --filter "ancestor=orcpub/orcpub:latest" 2>/dev/null | head -1 || true) + fi + + # Fallback: search by container name pattern + if [ -z "$container" ]; then + container=$(docker ps -q --filter "name=orcpub" 2>/dev/null | head -1 || true) + fi + + echo "$container" +} + +# --------------------------------------------------------------------------- +# Wait for container and Datomic to be ready +# --------------------------------------------------------------------------- + +wait_for_ready() { + local container="$1" + local max_wait=120 + local waited=0 + + # Check container is running + if ! docker inspect --format='{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + error "Container $container is not running." + error "Start it first: docker-compose up -d" + exit 1 + fi + + # Wait for Docker's native healthcheck (defined in docker-compose.yaml) + # to report the container as healthy. This avoids spawning a JVM per check. + local health + health=$(docker inspect --format='{{if .State.Health}}yes{{end}}' "$container" 2>/dev/null || true) + + if [ "$health" = "yes" ]; then + printf "Waiting for container health check" + while [ $waited -lt $max_wait ]; do + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || true) + if [ "$status" = "healthy" ]; then + echo "" + info "Container is healthy" + return 0 + fi + if [ "$status" = "unhealthy" ]; then + echo "" + error "Container reported unhealthy" + exit 1 + fi + printf "." + sleep 2 + waited=$((waited + 2)) + done + echo "" + error "Timed out waiting for healthy status (${max_wait}s)." + exit 1 + fi + + # Fallback: no healthcheck defined — check HTTP readiness directly + warn "No Docker healthcheck found; polling HTTP on container port..." + printf "Waiting for app" + while [ $waited -lt $max_wait ]; do + if docker exec "$container" wget --no-verbose --tries=1 --spider \ + "http://localhost:${PORT:-8890}/" 2>/dev/null; then + echo "" + return 0 + fi + printf "." + sleep 2 + waited=$((waited + 2)) + done + + echo "" + error "Timed out waiting for app (${max_wait}s). Is the datomic container running?" + exit 1 +} + +# --------------------------------------------------------------------------- +# Run the management script inside the container +# --------------------------------------------------------------------------- + +run_in_container() { + local container="$1" + shift + + # Copy the management script into the container + docker cp "$MANAGE_SCRIPT" "${container}:/tmp/manage-user.clj" + + # Run it with the uberjar classpath + docker exec "$container" \ + java -cp /orcpub.jar clojure.main /tmp/manage-user.clj "$@" +} + +# --------------------------------------------------------------------------- +# Parse args and dispatch +# --------------------------------------------------------------------------- + +CONTAINER_OVERRIDE="" + +# Extract --container flag if present +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --container) + CONTAINER_OVERRIDE="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +set -- "${ARGS[@]+"${ARGS[@]}"}" + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +# Verify manage-user.clj exists +if [ ! -f "$MANAGE_SCRIPT" ]; then + error "Management script not found at: $MANAGE_SCRIPT" + exit 1 +fi + +# Find or use specified container +if [ -n "$CONTAINER_OVERRIDE" ]; then + CONTAINER="$CONTAINER_OVERRIDE" +else + CONTAINER=$(find_container) +fi + +if [ -z "$CONTAINER" ]; then + error "Cannot find the orcpub container." + error "Make sure the containers are running: docker-compose up -d" + exit 1 +fi + +# Wait for Datomic to be reachable, then run the command +wait_for_ready "$CONTAINER" + +# For init: read INIT_ADMIN_* from .env and create the user +if [ "${1:-}" = "init" ]; then + ENV_FILE="${SCRIPT_DIR}/.env" + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found. Run ./docker-setup.sh first." + exit 1 + fi + + # Source .env to get INIT_ADMIN_* variables + # shellcheck disable=SC1090 + . "$ENV_FILE" + + if [ -z "${INIT_ADMIN_USER:-}" ]; then + error "INIT_ADMIN_USER is not set in .env" + error "Run ./docker-setup.sh to configure, or set it manually in .env" + exit 1 + fi + if [ -z "${INIT_ADMIN_EMAIL:-}" ] || [ -z "${INIT_ADMIN_PASSWORD:-}" ]; then + error "INIT_ADMIN_EMAIL and INIT_ADMIN_PASSWORD must also be set in .env" + exit 1 + fi + + info "Creating initial admin user: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + run_in_container "$CONTAINER" create "$INIT_ADMIN_USER" "$INIT_ADMIN_EMAIL" "$INIT_ADMIN_PASSWORD" + +# For batch: copy the user file into the container and rewrite the path +elif [ "${1:-}" = "batch" ]; then + USER_FILE="${2:-}" + if [ -z "$USER_FILE" ]; then + error "Usage: ./docker-user.sh batch " + exit 1 + fi + if [ ! -f "$USER_FILE" ]; then + error "File not found: $USER_FILE" + exit 1 + fi + docker cp "$USER_FILE" "${CONTAINER}:/tmp/batch-users.txt" + run_in_container "$CONTAINER" batch /tmp/batch-users.txt +else + run_in_container "$CONTAINER" "$@" +fi diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj new file mode 100644 index 000000000..b02cac2ca --- /dev/null +++ b/docker/scripts/manage-user.clj @@ -0,0 +1,212 @@ +;; OrcPub Docker User Management Script +;; +;; Runs inside the orcpub container using the uberjar classpath: +;; java -cp /orcpub.jar clojure.main /scripts/manage-user.clj [args...] +;; +;; Commands: +;; create — Create and auto-verify a user +;; batch — Create users from a file (one per line) +;; verify — Verify an existing unverified user +;; check — Check if a user exists and their status +;; list — List all users (username + email + verified) + +(ns manage-user + (:require [datomic.api :as d] + [buddy.hashers :as hashers] + [clojure.string :as s])) + +(def datomic-url + (or (System/getenv "DATOMIC_URL") + "datomic:free://datomic:4334/orcpub?password=datomic")) + +(defn get-conn [] + (try + (d/connect datomic-url) + (catch Exception e + (binding [*out* *err*] + (println "ERROR: Cannot connect to Datomic at" datomic-url) + (println " Is the transactor running? Cause:" (.getMessage e))) + (System/exit 1)))) + +(defn find-user [db username-or-email] + (d/q '[:find (pull ?e [:orcpub.user/username + :orcpub.user/email + :orcpub.user/verified? + :orcpub.user/created + :db/id]) . + :in $ ?needle + :where + (or [?e :orcpub.user/username ?needle] + [?e :orcpub.user/email ?needle])] + db + username-or-email)) + +(defn try-create-user! + "Creates a user. Returns {:ok true} on success, {:duplicate \"reason\"} if the + user/email already exists, or {:error \"message\"} on unexpected failure." + [conn username email password] + (let [db (d/db conn) + email (s/lower-case (s/trim email)) + username (s/trim username)] + (cond + (d/q '[:find ?e . :in $ ?email + :where [?e :orcpub.user/email ?email]] db email) + {:duplicate (str "Email already registered: " email)} + + (d/q '[:find ?e . :in $ ?username + :where [?e :orcpub.user/username ?username]] db username) + {:duplicate (str "Username already taken: " username)} + + :else + (do + @(d/transact conn + [{:orcpub.user/email email + :orcpub.user/username username + :orcpub.user/password (hashers/encrypt password) + :orcpub.user/verified? true + :orcpub.user/send-updates? false + :orcpub.user/created (java.util.Date.)}]) + (println "OK: User created and verified —" username "<" email ">") + {:ok true})))) + +(defn batch-create-users! + "Reads a user file (one user per line: username email password) and creates + all users in a single JVM session. Blank lines and #-comments are skipped. + Duplicates are logged and skipped (not counted as failures). + Returns exit code 0 if no hard failures, 1 otherwise." + [conn path] + (let [lines (->> (s/split-lines (slurp path)) + (map s/trim) + (remove #(or (s/blank? %) (s/starts-with? % "#")))) + results (doall + (for [line lines] + (let [parts (s/split line #"\s+")] + (if (< (count parts) 3) + (do (binding [*out* *err*] + (println "SKIP: bad line (need: username email password):" line)) + {:error "bad line"}) + (let [[username email password] parts + result (try + (try-create-user! conn username email password) + (catch Exception e + {:error (.getMessage e)}))] + (when (:duplicate result) + (println "SKIP:" username "—" (:duplicate result))) + (when (:error result) + (binding [*out* *err*] + (println "FAIL:" username "—" (:error result)))) + result))))) + total (count results) + created (count (filter :ok results)) + dupes (count (filter :duplicate results)) + failed (count (filter :error results))] + (println) + (println (format "Batch complete: %d created, %d skipped (duplicate), %d failed, %d total" + created dupes failed total)) + (if (pos? failed) 1 0))) + +(defn verify-user! [conn username-or-email] + (let [db (d/db conn) + user (find-user db username-or-email)] + (if-not user + (do (binding [*out* *err*] + (println "ERROR: User not found:" username-or-email)) + (System/exit 1)) + (if (:orcpub.user/verified? user) + (println "OK: User already verified —" (:orcpub.user/username user)) + (do + @(d/transact conn + [[:db/add (:db/id user) :orcpub.user/verified? true]]) + (println "OK: User verified —" (:orcpub.user/username user))))))) + +(defn check-user [db username-or-email] + (if-let [user (find-user db username-or-email)] + (do + (println "Found user:") + (println " Username:" (:orcpub.user/username user)) + (println " Email: " (:orcpub.user/email user)) + (println " Verified:" (:orcpub.user/verified? user)) + (println " Created: " (:orcpub.user/created user))) + (do + (println "User not found:" username-or-email) + (System/exit 1)))) + +(defn list-users [db] + (let [users (d/q '[:find [(pull ?e [:orcpub.user/username + :orcpub.user/email + :orcpub.user/verified?]) ...] + :where [?e :orcpub.user/username]] + db)] + (if (empty? users) + (println "No users found.") + (do + (println (format "%-20s %-30s %s" "USERNAME" "EMAIL" "VERIFIED")) + (println (apply str (repeat 65 "-"))) + (doseq [u (sort-by :orcpub.user/username users)] + (println (format "%-20s %-30s %s" + (:orcpub.user/username u) + (:orcpub.user/email u) + (:orcpub.user/verified? u)))))))) + +;; --- CLI dispatch --- + +(let [args *command-line-args* + cmd (first args)] + (case cmd + "create" (let [[_ username email password] args] + (when-not (and username email password) + (binding [*out* *err*] + (println "Usage: manage-user.clj create ")) + (System/exit 1)) + (let [conn (get-conn) + result (try-create-user! conn username email password)] + (when-let [msg (or (:duplicate result) (:error result))] + (binding [*out* *err*] + (println "ERROR:" msg)) + (System/exit 1)))) + + "batch" (let [[_ path] args] + (when-not path + (binding [*out* *err*] + (println "Usage: manage-user.clj batch ") + (println " File format: one user per line — username email password") + (println " Lines starting with # and blank lines are skipped")) + (System/exit 1)) + (let [conn (get-conn) + exit (batch-create-users! conn path)] + (System/exit exit))) + + "verify" (let [[_ username-or-email] args] + (when-not username-or-email + (binding [*out* *err*] + (println "Usage: manage-user.clj verify ")) + (System/exit 1)) + (let [conn (get-conn)] + (verify-user! conn username-or-email))) + + "check" (let [[_ username-or-email] args] + (when-not username-or-email + (binding [*out* *err*] + (println "Usage: manage-user.clj check ")) + (System/exit 1)) + (let [conn (get-conn) + db (d/db conn)] + (check-user db username-or-email))) + + "list" (let [conn (get-conn) + db (d/db conn)] + (list-users db)) + + (do + (println "OrcPub User Management") + (println "") + (println "Commands:") + (println " create Create and auto-verify a user") + (println " batch Create users from a file (one per line)") + (println " verify Verify an existing user") + (println " check Check if a user exists") + (println " list List all users") + (when-not cmd + (System/exit 1)))) + ;; Datomic peer threads are non-daemon and keep the JVM alive; force exit. + (System/exit 0)) diff --git a/docker/scripts/users.example.txt b/docker/scripts/users.example.txt new file mode 100644 index 000000000..2d0404dc4 --- /dev/null +++ b/docker/scripts/users.example.txt @@ -0,0 +1,13 @@ +# Batch user file template +# +# Copy this file, replace the example users with your own, then run: +# cp docker/scripts/users.example.txt users.txt +# ./docker-user.sh batch users.txt +# +# Format: username email password (whitespace-separated) +# Lines starting with # and blank lines are skipped. +# Duplicates are logged and skipped (not treated as errors). + +admin admin@example.com SecurePass123 +player1 player1@example.com AnotherPass456 +player2 player2@example.com YetAnotherPass789 diff --git a/docs/docker-user-management.md b/docs/docker-user-management.md new file mode 100644 index 000000000..eb2a1908e --- /dev/null +++ b/docs/docker-user-management.md @@ -0,0 +1,205 @@ +# Docker User Management + +Dungeon Master's Vault uses email verification for new accounts by default. When self-hosting with Docker, you likely don't have (or want) an SMTP server configured. These scripts let you create pre-verified user accounts directly in the Datomic database, bypassing the email verification step entirely. + +## Quick Start (Without Cloning) + +You don't need to clone the whole repo. Grab the three files you need and go: + +```bash +# 1. Get the compose file and user scripts +mkdir orcpub && cd orcpub +curl -fLO https://raw.githubusercontent.com/Orcpub/orcpub/develop/docker-compose.yaml +curl -fLO https://raw.githubusercontent.com/Orcpub/orcpub/develop/docker-user.sh +mkdir -p docker/scripts +curl -fL https://raw.githubusercontent.com/Orcpub/orcpub/develop/docker/scripts/manage-user.clj \ + -o docker/scripts/manage-user.clj +chmod +x docker-user.sh + +# 2. Start the stack +docker compose up -d + +# 3. Create your account (script waits for healthcheck automatically) +./docker-user.sh create myname me@email.com MyPassword123 +``` + +Open https://localhost and log in. That's it. + +### Day-to-day + +```bash +docker compose up -d # start (if not already running) +docker compose down # stop +docker compose down -v # stop and wipe data +``` + +User management works any time the stack is up: + +```bash +./docker-user.sh list # who's in there? +./docker-user.sh create buddy friend@email.com TheirPass456 # add someone +./docker-user.sh batch players.txt # add many at once +./docker-user.sh check buddy # look up a user +``` + +### The script handles the waiting + +You don't need to know when Datomic is "ready." If you run `docker compose up -d` and immediately run `./docker-user.sh create ...`, it will: + +1. Find the orcpub container automatically +2. Poll the Docker healthcheck (dots printed while waiting) +3. Run the command once healthy + +No "wait 30 seconds then..." — just run it. + +If you forget the commands: `./docker-user.sh --help` + +## Full Setup (With Cloned Repo) + +If you've cloned the repo, the setup script generates secure passwords, SSL certs, and required directories in one step: + +```bash +./docker-setup.sh # Interactive — prompts for optional values +./docker-setup.sh --auto # Non-interactive — accepts all defaults +./docker-setup.sh --auto --force # Regenerate everything from scratch +``` + +In interactive mode, the setup script will prompt for an initial admin account. Then start the stack and initialize: + +```bash +docker compose up -d +./docker-user.sh init # creates the admin user configured in .env +``` + +Or skip the prompt and create users directly: + +```bash +docker compose up -d +./docker-user.sh create admin admin@example.com MySecurePass123 +``` + +The setup script creates a `.env` file used by both `docker-compose.yaml` and `docker-compose-build.yaml`. You can also copy and edit `.env.example` manually if you prefer. + +### Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Application port | `8890` | +| `ADMIN_PASSWORD` | Datomic admin interface password | generated | +| `DATOMIC_PASSWORD` | Datomic application password | generated | +| `SIGNATURE` | JWT signing secret (20+ chars) | generated | +| `EMAIL_SERVER_URL` | SMTP server (leave empty to skip email) | empty | +| `EMAIL_ACCESS_KEY` | SMTP username | empty | +| `EMAIL_SECRET_KEY` | SMTP password | empty | +| `EMAIL_SERVER_PORT` | SMTP port | `587` | +| `EMAIL_FROM_ADDRESS` | Sender address | empty | +| `EMAIL_SSL` / `EMAIL_TLS` | SMTP encryption | `FALSE` | +| `INIT_ADMIN_USER` | Initial admin username (for `./docker-user.sh init`) | empty | +| `INIT_ADMIN_EMAIL` | Initial admin email | empty | +| `INIT_ADMIN_PASSWORD` | Initial admin password | empty | + +## User Management Commands + +### Initialize Admin from .env + +```bash +./docker-user.sh init +``` + +Reads `INIT_ADMIN_USER`, `INIT_ADMIN_EMAIL`, and `INIT_ADMIN_PASSWORD` from `.env` and creates the account. Safe to run multiple times — if the user already exists, it's skipped. + +This is the easiest path after running `docker-setup.sh` in interactive mode, which prompts for these values. + +### Create a Single User + +```bash +./docker-user.sh create +``` + +Creates a new user account that is **automatically verified** — no email confirmation needed. The password is hashed with bcrypt before storage. + +### Create Multiple Users (Batch) + +```bash +cp docker/scripts/users.example.txt users.txt +# edit users.txt with your actual users +./docker-user.sh batch users.txt +``` + +Creates multiple users from a local file in a single JVM session (much faster than calling `create` repeatedly). The script copies the file into the container automatically — you don't need to interact with Docker directly. A template is included at [`docker/scripts/users.example.txt`](../docker/scripts/users.example.txt). File format: + +``` +# Comments and blank lines are ignored +admin admin@example.com SecurePass123 +player1 player1@example.com AnotherPass456 +player2 player2@example.com YetAnotherPass789 +``` + +Duplicates are logged and skipped (not treated as errors). + +### Verify an Existing User + +```bash +./docker-user.sh verify +``` + +If a user registered through the web UI but never received/clicked the verification email, this marks them as verified. + +### Check a User + +```bash +./docker-user.sh check +``` + +Displays the user's username, email, verification status, and creation date. + +### List All Users + +```bash +./docker-user.sh list +``` + +Prints a table of all users in the database with their verification status. + +### Options + +| Flag | Description | +|---|---| +| `--container ` | Override automatic container detection | +| `--help` | Show usage information | + +## Docker Compose Changes + +Both `docker-compose.yaml` and `docker-compose-build.yaml` have been updated: + +- **Environment variables use `.env` interpolation** — no more editing passwords directly in the YAML. All config flows from the `.env` file generated by `docker-setup.sh`. +- **Native healthchecks** — Datomic and the application containers declare healthchecks so that dependent services wait for readiness automatically. This replaces fragile startup-order workarounds. +- **Service dependencies use `condition: service_healthy`** — nginx won't start until the app is actually serving, and the app won't start until Datomic is accepting connections. + +## How It Works + +The user management script ([docker/scripts/manage-user.clj](../docker/scripts/manage-user.clj)) runs as a Clojure program inside the orcpub container, reusing the application's own uberjar classpath. This gives it direct access to `datomic.api` and `buddy.hashers` without installing anything extra. + +The shell wrapper (`docker-user.sh`) handles: +1. Auto-detecting the running orcpub container +2. Waiting for Docker's native healthcheck to report healthy +3. Copying the Clojure script into the container +4. Executing it via `java -cp /orcpub.jar clojure.main` + +## Troubleshooting + +**"Cannot find the orcpub container"** +- Ensure containers are running: `docker compose ps` +- If using a non-standard project name, pass `--container ` explicitly + +**"Cannot connect to Datomic"** +- The datomic container may still be starting. The script waits up to 120 seconds, but on slow systems it may need longer. +- Check datomic logs: `docker compose logs datomic` + +**"Container reported unhealthy"** +- Check the application logs: `docker compose logs orcpub` +- Verify `.env` values match between `DATOMIC_PASSWORD` and the password in `DATOMIC_URL` + +**User created but can't log in** +- Run `./docker-user.sh check ` to confirm the account exists and is verified +- Make sure `SIGNATURE` in `.env` hasn't changed since the container started (restart containers after `.env` changes) diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index 1826d7267..463c369d5 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -4,6 +4,7 @@ [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.units :as units5e] [orcpub.dnd.e5.party :as party5e] + [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.magic-items :as mi5e] [orcpub.dnd.e5.weapons :as weapon5e] [orcpub.dnd.e5.spells :as spells5e] @@ -312,6 +313,17 @@ :db/valueType :db.type/ref :db/cardinality :db.cardinality/many}]) +(def folder-schema + [{:db/ident ::folder5e/owner + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident ::folder5e/name + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident ::folder5e/character-ids + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/many}]) + (def character-equipment-schema (concat [(string-prop ::char-equip-5e/name) @@ -389,5 +401,6 @@ character-equipment-schema features-used-schema party-schema + folder-schema magic-item-schema weapon-schema)) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index a40390546..6bf5ae706 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -40,6 +40,7 @@ [orcpub.entity :as entity] [orcpub.security :as security] [orcpub.routes.party :as party] + [orcpub.routes.folder :as folder] [orcpub.oauth :as oauth] [hiccup.page :as page] [environ.core :as environ] @@ -161,6 +162,22 @@ context (terminate-request context 401 "You don't own this party"))))}) +(defn folder-owner [db id] + (d/q '[:find ?owner . + :in $ ?id + :where [?id :orcpub.dnd.e5.folder/owner ?owner]] + db + id)) + +(def check-folder-owner + {:name :check-folder-owner + :enter (fn [context] + (let [{:keys [identity db] {:keys [id]} :path-params} (:request context) + folder-owner (folder-owner db id)] + (if (= (:user identity) folder-owner) + context + (terminate-request context 401 "You don't own this folder"))))}) + (defn redirect [route-key] (ring-resp/redirect (route-map/path-for route-key))) @@ -1063,6 +1080,17 @@ {:post `party/add-character}] [(route-map/path-for route-map/dnd-e5-char-party-character-route :id ":id" :character-id ":character-id") ^:interceptors [check-auth parse-id check-party-owner] {:delete `party/remove-character}] + [(route-map/path-for route-map/dnd-e5-char-folders-route) ^:interceptors [check-auth] + {:post `folder/create-folder + :get `folder/folders}] + [(route-map/path-for route-map/dnd-e5-char-folder-route :id ":id") ^:interceptors [check-auth parse-id check-folder-owner] + {:delete `folder/delete-folder}] + [(route-map/path-for route-map/dnd-e5-char-folder-name-route :id ":id") ^:interceptors [check-auth parse-id check-folder-owner] + {:put `folder/update-folder-name}] + [(route-map/path-for route-map/dnd-e5-char-folder-characters-route :id ":id") ^:interceptors [check-auth parse-id check-folder-owner] + {:post `folder/add-character}] + [(route-map/path-for route-map/dnd-e5-char-folder-character-route :id ":id" :character-id ":character-id") ^:interceptors [check-auth parse-id check-folder-owner] + {:delete `folder/remove-character}] [(route-map/path-for route-map/login-route) {:post `login}] [(route-map/path-for route-map/character-pdf-route) diff --git a/src/clj/orcpub/routes/folder.clj b/src/clj/orcpub/routes/folder.clj new file mode 100644 index 000000000..f04f28992 --- /dev/null +++ b/src/clj/orcpub/routes/folder.clj @@ -0,0 +1,59 @@ +(ns orcpub.routes.folder + (:require [datomic.api :as d] + [orcpub.dnd.e5.folder :as folder] + [orcpub.entity.strict :as se])) + +(def pull-folder + [:db/id ::folder/name {::folder/character-ids [:db/id ::se/owner ::se/summary]}]) + +(defn create-folder [{:keys [conn identity] folder-data :transit-params}] + ;; Whitelist only ::folder/name from client data; owner is always server-set + (let [username (:user identity) + result @(d/transact conn [{::folder/name (::folder/name folder-data) + ::folder/owner username}]) + new-id (-> result :tempids first val)] + {:status 200 :body (d/pull (d/db conn) '[*] new-id)})) + +(defn folders [{:keys [db identity]}] + (let [username (:user identity) + result (d/q [:find `(~'pull ~'?e ~pull-folder) + :in '$ '?username + :where ['?e ::folder/owner '?username]] + db username) + mapped (map (fn [[f]] + (update f ::folder/character-ids + (fn [chars] + (map (fn [{:keys [:db/id ::se/owner ::se/summary]}] + (assoc summary :db/id id ::se/owner owner)) + chars)))) + result)] + {:status 200 :body mapped})) + +(defn update-folder-name [{:keys [conn] + folder-name :transit-params + {:keys [id]} :path-params}] + @(d/transact conn [{:db/id id ::folder/name folder-name}]) + {:status 200 :body (d/pull (d/db conn) pull-folder id)}) + +(defn add-character [{:keys [db conn] + character-id :transit-params + {:keys [id]} :path-params}] + ;; Enforce at-most-one-folder: retract from existing folder first + (when-let [existing-id (d/q '[:find ?f . + :in $ ?char + :where [?f :orcpub.dnd.e5.folder/character-ids ?char]] + db character-id)] + @(d/transact conn [[:db/retract existing-id ::folder/character-ids character-id]])) + @(d/transact conn [{:db/id id ::folder/character-ids character-id}]) + {:status 200 :body (d/pull (d/db conn) '[*] id)}) + +(defn remove-character [{:keys [conn] + {:keys [id character-id]} :path-params}] + @(d/transact conn [[:db/retract id ::folder/character-ids (Long/parseLong character-id)]]) + {:status 200 :body (d/pull (d/db conn) '[*] id)}) + +(defn delete-folder [{:keys [conn] + {:keys [id]} :path-params}] + ;; retractEntity removes only the folder entity; referenced character entities are unaffected + @(d/transact conn [[:db/retractEntity id]]) + {:status 200}) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 8062422dc..e846eec8b 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1184,6 +1184,39 @@ [:.checkbox-text {:margin-left "5px"}] + ;; Character filter bar — scoped styles for dropdowns and checkboxes + [:.char-filter-bar + [:.filter-dropdown + {:position :absolute + :background-color "#313A4D" + :padding "6px 4px" + :top "100%" + :margin-top "4px" + :border "1px solid rgba(255,255,255,0.15)" + :border-radius "4px" + :max-height "300px" + :overflow-y :auto + :font-weight :normal + :font-size "14px" + :z-index 200 + :box-shadow "0 4px 12px rgba(0,0,0,0.4)"}] + [:.filter-dropdown-item + {:padding "6px 10px" + :border-radius "3px" + :cursor :pointer}] + [:.filter-dropdown-item:hover + {:background-color "rgba(255,255,255,0.08)"}] + [:.checkbox + {:width "14px" + :height "14px" + :min-width "14px" + :flex-shrink 0} + [:.fa-check + {:font-size "12px"}]] + [:.flex.pointer + {:align-items :center + :gap "8px"}]] + [:#selection-stepper {:transition "top 2s ease-in-out" :width "240px" diff --git a/src/cljc/orcpub/dnd/e5/char_filter.cljc b/src/cljc/orcpub/dnd/e5/char_filter.cljc new file mode 100644 index 000000000..bf73eaadd --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/char_filter.cljc @@ -0,0 +1,32 @@ +(ns orcpub.dnd.e5.char-filter + (:require [clojure.string :as s] + [orcpub.dnd.e5.character :as char5e])) + +(defn char-matches? + "Returns true if `char` satisfies all active filter criteria. + + Filters: + name-filter - string; blank = no filter, otherwise case-insensitive substring + level-filters - set of ints; empty = no filter, any class level must be in set + class-filters - set of strings; empty = no filter, any class name must be in set + has-portrait? - nil = all, true = must have image-url, false = must not + has-faction-pic? - nil = all, true = must have faction-image-url, false = must not" + [char name-filter level-filters class-filters has-portrait? has-faction-pic?] + (and + (or (s/blank? name-filter) + (s/includes? (s/lower-case (or (::char5e/character-name char) "")) + (s/lower-case name-filter))) + (or (empty? level-filters) + (some #(level-filters (::char5e/level %)) (::char5e/classes char))) + (or (empty? class-filters) + (some #(class-filters (::char5e/class-name %)) (::char5e/classes char))) + (or (nil? has-portrait?) + (= has-portrait? (boolean (::char5e/image-url char)))) + (or (nil? has-faction-pic?) + (= has-faction-pic? (boolean (::char5e/faction-image-url char)))))) + +(defn filter-characters + "Filter `characters` by all active filter criteria." + [characters name-filter level-filters class-filters has-portrait? has-faction-pic?] + (filter #(char-matches? % name-filter level-filters class-filters has-portrait? has-faction-pic?) + characters)) diff --git a/src/cljc/orcpub/dnd/e5/folder.cljc b/src/cljc/orcpub/dnd/e5/folder.cljc new file mode 100644 index 000000000..8dd4db74b --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/folder.cljc @@ -0,0 +1,8 @@ +(ns orcpub.dnd.e5.folder + (:require [clojure.spec.alpha :as spec])) + +(spec/def ::owner string?) +(spec/def ::name string?) +(spec/def ::character-id int?) +(spec/def ::character-ids (spec/coll-of ::character-id)) +(spec/def ::folder (spec/keys :req [::name])) diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index ba34805b0..71062b06a 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -15,6 +15,11 @@ (def dnd-e5-char-party-characters-route :char-party-characters-5e) (def dnd-e5-char-party-character-route :char-party-character-5e) (def dnd-e5-char-parties-page-route :char-parties-5e-page) +(def dnd-e5-char-folders-route :char-folders-5e) +(def dnd-e5-char-folder-route :char-folder-5e) +(def dnd-e5-char-folder-name-route :char-folder-name-5e) +(def dnd-e5-char-folder-characters-route :char-folder-characters-5e) +(def dnd-e5-char-folder-character-route :char-folder-character-5e) (def dnd-e5-orcacle-page-route :orcacle-page) (def dnd-e5-char-page-routes #{default-route @@ -150,6 +155,11 @@ "/name" dnd-e5-char-party-name-route "/characters" {"" dnd-e5-char-party-characters-route ["/" :character-id] dnd-e5-char-party-character-route}}} + "folders" {"" dnd-e5-char-folders-route + ["/" :id] {"" dnd-e5-char-folder-route + "/name" dnd-e5-char-folder-name-route + "/characters" {"" dnd-e5-char-folder-characters-route + ["/" :character-id] dnd-e5-char-folder-character-route}}} "character-summaries" dnd-e5-char-summary-list-route}} "pages/" {"my-account" my-account-page-route "register-page" register-page-route diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 4ef2ca9ac..fa147b328 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -18,6 +18,7 @@ [orcpub.dnd.e5.classes :as class5e] [orcpub.dnd.e5.units :as units5e] [orcpub.dnd.e5.party :as party5e] + [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.character.random :as char-rand5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.monsters :as monsters] @@ -361,6 +362,7 @@ subrace (char5e/subrace built-char) character-name (char5e/character-name built-char) image-url (char5e/image-url built-char) + faction-image-url (char5e/faction-image-url built-char) age (char5e/age built-char) sex (char5e/sex built-char) height (char5e/height built-char) @@ -368,11 +370,12 @@ hair (char5e/hair built-char) eyes (char5e/eyes built-char) skin (char5e/skin built-char) - ;alignment (char5e/get-prop built-char ::alignment) ;This is not available? - ;background (char5e/get-prop built-char ::background) ;This is not available? + ;alignment (char5e/get-prop built-char ::alignment) ;This is not available? + ;background (char5e/get-prop built-char ::background) ;This is not available? ] (cond-> {::char5e/character-name (or character-name "")} image-url (assoc ::char5e/image-url image-url) + faction-image-url (assoc ::char5e/faction-image-url faction-image-url) race (assoc ::char5e/race-name race) subrace (assoc ::char5e/subrace-name subrace) age (assoc ::char5e/age age) @@ -799,6 +802,154 @@ parties))) :dispatch [::party5e/add-character-remote id character-id show-confirmation?]})) +;; ---- Folder Events ------------------------------------------------------- + +(reg-event-db + ::folder5e/set-folders + (fn [db [_ folders]] + (assoc db ::folder5e/folders folders))) + +(reg-event-fx + ::folder5e/create-folder + (fn [{:keys [db]} [_]] + {:http {:method :post + :headers (authorization-headers db) + :transit-params {::folder5e/name "New Folder"} + :url (url-for-route routes/dnd-e5-char-folders-route) + :on-success [::folder5e/create-folder-success]}})) + +(reg-event-fx + ::folder5e/create-folder-success + (fn [{:keys [db]} [_ response]] + (let [folder (:body response)] + {:db (update db ::folder5e/folders conj folder) + :dispatch [::folder5e/toggle-renaming (:db/id folder)]}))) + +(reg-event-fx + ::folder5e/rename-folder + (fn [{:keys [db]} [_ id new-name]] + {:db (update db ::folder5e/folders + (fn [folders] + (map (fn [f] + (if (= id (:db/id f)) + (assoc f ::folder5e/name new-name) + f)) + folders))) + :http {:method :put + :headers (authorization-headers db) + :transit-params new-name + :url (url-for-route routes/dnd-e5-char-folder-name-route :id id)}})) + +(reg-event-fx + ::folder5e/delete-folder + (fn [{:keys [db]} [_ id]] + {:db (update db ::folder5e/folders + (fn [folders] + (remove #(= id (:db/id %)) folders))) + :http {:method :delete + :headers (authorization-headers db) + :url (url-for-route routes/dnd-e5-char-folder-route :id id)}})) + +(reg-event-fx + ::folder5e/add-character + (fn [{:keys [db]} [_ folder-id character-id]] + {:db (update db ::folder5e/folders + (fn [folders] + (map (fn [f] + (if (= folder-id (:db/id f)) + (update f ::folder5e/character-ids + conj + (get-in db [::char5e/summary-map character-id])) + ;; remove from any other folder (at-most-one constraint) + (update f ::folder5e/character-ids + (fn [chars] + (remove #(= character-id (:db/id %)) chars))))) + folders))) + :http {:method :post + :headers (authorization-headers db) + :transit-params character-id + :url (url-for-route routes/dnd-e5-char-folder-characters-route :id folder-id)}})) + +(reg-event-fx + ::folder5e/remove-character + (fn [{:keys [db]} [_ folder-id character-id]] + {:db (update db ::folder5e/folders + (fn [folders] + (map (fn [f] + (if (= folder-id (:db/id f)) + (update f ::folder5e/character-ids + (fn [chars] + (remove #(= character-id (:db/id %)) chars))) + f)) + folders))) + :http {:method :delete + :headers (authorization-headers db) + :url (url-for-route routes/dnd-e5-char-folder-character-route + :id folder-id + :character-id character-id)}})) + +;; When expanding a folder, collapse its characters so they start closed +(reg-event-db + ::folder5e/toggle-expanded + (fn [db [_ folder-id char-ids]] + (let [opening? (not (get-in db [::folder5e/expanded folder-id]))] + (cond-> (update-in db [::folder5e/expanded folder-id] not) + (and opening? (seq char-ids)) + (update :expanded-characters + (fn [ec] (apply dissoc (or ec {}) char-ids))))))) + +(reg-event-db + ::folder5e/toggle-renaming + (fn [db [_ folder-id]] + (update-in db [::folder5e/renaming folder-id] not))) + +;; ---- End Folder Events ---------------------------------------------------- + +;; ---- Character Filter Events ----------------------------------------------- + +(reg-event-db + ::char5e/set-char-name-filter + (fn [db [_ v]] + (assoc db ::char5e/char-name-filter v))) + +(reg-event-db + ::char5e/toggle-char-level-filter + (fn [db [_ level]] + (let [filters (or (get db ::char5e/char-level-filters) #{})] + (assoc db ::char5e/char-level-filters + ((if (filters level) disj conj) filters level))))) + +(reg-event-db + ::char5e/toggle-char-class-filter + (fn [db [_ cls]] + (let [filters (or (get db ::char5e/char-class-filters) #{})] + (assoc db ::char5e/char-class-filters + ((if (filters cls) disj conj) filters cls))))) + +(reg-event-db + ::char5e/toggle-char-has-portrait + (fn [db _] + (update db ::char5e/char-has-portrait? + #(case % nil true, true false, false nil)))) + +(reg-event-db + ::char5e/toggle-char-has-faction-pic + (fn [db _] + (update db ::char5e/char-has-faction-pic? + #(case % nil true, true false, false nil)))) + +(reg-event-db + ::char5e/clear-char-filters + (fn [db _] + (dissoc db + ::char5e/char-name-filter + ::char5e/char-level-filters + ::char5e/char-class-filters + ::char5e/char-has-portrait? + ::char5e/char-has-faction-pic?))) + +;; ---- End Character Filter Events ------------------------------------------- + (reg-event-fx :follow-user-success (fn [])) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 1c1f40217..b66bfd809 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -12,8 +12,10 @@ [orcpub.dnd.e5.events :refer [url-for-route] :as events] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.char-decision-tree :as char-dec5e] + [orcpub.dnd.e5.char-filter :as char-filter] [orcpub.dnd.e5.character.equipment :as char-equip5e] [orcpub.dnd.e5.party :as party5e] + [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.monsters :as monsters5e] [orcpub.dnd.e5.spells :as spells5e] [orcpub.dnd.e5.armor :as armor5e] @@ -390,6 +392,48 @@ (fn [parties _] (common/map-by-id parties))) +(reg-sub-raw + ::folder5e/folders + (fn [app-db _] + (go (dispatch [:set-loading true]) + (let [response (> characters + (mapcat ::char5e/classes) + (map ::char5e/class-name) + (remove nil?) + distinct + sort + vec))) + +(reg-sub + ::char5e/char-levels-available + :<- [::char5e/characters] + (fn [characters _] + (->> characters + (mapcat ::char5e/classes) + (map ::char5e/level) + (remove nil?) + distinct + sort + vec))) + +(reg-sub + ::char5e/filtered-characters + :<- [::char5e/characters] + :<- [::char5e/char-name-filter] + :<- [::char5e/char-level-filters] + :<- [::char5e/char-class-filters] + :<- [::char5e/char-has-portrait?] + :<- [::char5e/char-has-faction-pic?] + (fn [[characters name-filter level-filters class-filters has-portrait? has-faction-pic?] _] + (char-filter/filter-characters characters name-filter level-filters class-filters has-portrait? has-faction-pic?))) + +;; ---- End Character List Filter Subscriptions -------------------------------- diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index bf366b88d..4f2a393bb 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -20,6 +20,7 @@ [orcpub.dnd.e5.feats :as feats] [orcpub.dnd.e5.units :as units] [orcpub.dnd.e5.party :as party] + [orcpub.dnd.e5.folder :as folder] [orcpub.dnd.e5.character.random :as char-random] [orcpub.dnd.e5.character.equipment :as char-equip] [orcpub.registration :as registration] @@ -7701,47 +7702,65 @@ (builder-page "Feat" ::feats/reset-feat ::feats/save-feat feat-builder)) (defn expanded-character-list-item [id owner username char-page-route] - [:div - {:style character-display-style} - [:div.flex.justify-cont-end.uppercase.align-items-c - [share-link id] - (if (= username owner) - [:button.form-button - {:on-click (make-event-handler :edit-character @(subscribe [::char/character id]))} - "edit"]) - (if (= username owner) + (let [folders @(subscribe [::folder/folders]) + char-folder-map @(subscribe [::folder/character-folder-map]) + current-folder-id (get char-folder-map id)] + [:div + {:style character-display-style} + [:div.flex.justify-cont-end.uppercase.align-items-c + [share-link id] + (if (= username owner) + [:button.form-button + {:on-click (make-event-handler :edit-character @(subscribe [::char/character id]))} + "edit"]) + (if (= username owner) + [:button.form-button.m-l-5 + {:on-click (make-event-handler ::char/save-character id)} + "save"]) [:button.form-button.m-l-5 - {:on-click (make-event-handler ::char/save-character id)} - "save"]) - [:button.form-button.m-l-5 - {:on-click (make-event-handler :route char-page-route)} - "view"] - [:button.form-button.m-l-5 - {:on-click (export-pdf - @(subscribe [::char/built-character id]) - id - {:print-character-sheet? true - :print-spell-cards? true - :print-prepared-spells? false - :print-character-sheet-style? 1 - :print-spell-card-dc-mod? true})} - "print"] - (if (= username owner) + {:on-click (make-event-handler :route char-page-route)} + "view"] [:button.form-button.m-l-5 - {:on-click (make-event-handler ::char/show-delete-confirmation id)} - "delete"])] - (if @(subscribe [::char/delete-confirmation-shown? id]) - [:div.p-20.flex.justify-cont-end - [:div - [:div.m-b-10 "Are you sure you want to delete this character?"] - [:div.flex - [:button.form-button - {:on-click (make-event-handler ::char/hide-delete-confirmation id)} - "cancel"] - [:span.link-button - {:on-click (make-event-handler :delete-character id)} - "delete"]]]]) - [character-display id false (if (= :mobile @(subscribe [:device-type])) 1 2)]]) + {:on-click (export-pdf + @(subscribe [::char/built-character id]) + id + {:print-character-sheet? true + :print-spell-cards? true + :print-prepared-spells? false + :print-character-sheet-style? 1 + :print-spell-card-dc-mod? true})} + "print"] + (if (and (= username owner) (seq folders)) + [:select.form-button.m-l-5.builder-dropdown + {:value (or current-folder-id "") + :on-change (fn [e] + (let [val (.-value (.-target e))] + (if (= val "") + (when current-folder-id + (dispatch [::folder/remove-character current-folder-id id])) + (dispatch [::folder/add-character (js/parseInt val) id]))))} + [:option.builder-dropdown-item {:value ""} "No folder"] + (doall + (map (fn [f] + ^{:key (:db/id f)} + [:option.builder-dropdown-item {:value (:db/id f)} (::folder/name f)]) + (sort-by ::folder/name folders)))]) + (if (= username owner) + [:button.form-button.m-l-5 + {:on-click (make-event-handler ::char/show-delete-confirmation id)} + "delete"])] + (if @(subscribe [::char/delete-confirmation-shown? id]) + [:div.p-20.flex.justify-cont-end + [:div + [:div.m-b-10 "Are you sure you want to delete this character?"] + [:div.flex + [:button.form-button + {:on-click (make-event-handler ::char/hide-delete-confirmation id)} + "cancel"] + [:span.link-button + {:on-click (make-event-handler :delete-character id)} + "delete"]]]]) + [character-display id false (if (= :mobile @(subscribe [:device-type])) 1 2)]])) (defn character-list-item [expanded-characters selected-ids @@ -7774,16 +7793,209 @@ (if expanded? [expanded-character-list-item id owner username char-page-route])]])) +(defn folder-item [f expanded-characters selected-ids username filtered-char-ids] + (let [edit-name (r/atom (::folder/name f)) + confirm-delete? (r/atom false)] + (fn [f expanded-characters selected-ids username filtered-char-ids] + (let [folder-id (:db/id f) + folder-name (::folder/name f) + expanded? @(subscribe [::folder/expanded]) + folder-expanded? (get expanded? folder-id) + renaming? @(subscribe [::folder/renaming]) + folder-renaming? (get renaming? folder-id) + chars (::folder/character-ids f) + visible-chars (filter #(filtered-char-ids (:db/id %)) chars) + save-fn (fn [] + (dispatch [::folder/rename-folder folder-id @edit-name]) + (dispatch [::folder/toggle-renaming folder-id]))] + [:div.main-text-color.item-list-item.m-b-5 + ^{:key folder-id} + [:div + ;; Entire row is clickable to expand/collapse folder + [:div.flex.justify-cont-s-b.align-items-c.pointer + {:on-click (when (not folder-renaming?) + #(dispatch [::folder/toggle-expanded folder-id (mapv :db/id chars)]))} + [:div.flex.align-items-c.m-l-10 + (when (not folder-renaming?) + [:i.fa.m-r-10.orange + {:class-name (if folder-expanded? "fa-folder-open" "fa-folder")}]) + (if folder-renaming? + [:div.flex.align-items-c + [:input.input + {:auto-focus true + :value @edit-name + :style {:width "160px"} + :on-change #(reset! edit-name (.-value (.-target %))) + :on-key-down (fn [e] + (when (= "Enter" (.-key e)) + (save-fn)))}] + [:button.form-button.m-l-5 + {:on-click (fn [e] + (.stopPropagation e) + (save-fn))} + "save"]] + [:span.f-s-18.f-w-b + folder-name]) + (when (not folder-renaming?) + [:span.m-l-10.f-s-12.opacity-5 + (str "(" (count visible-chars) ")")])] + (when (not folder-renaming?) + [:div.flex.align-items-c.m-r-10 + [:span.link-button.m-r-10.f-s-12 + {:on-click (fn [e] + (.stopPropagation e) + (reset! edit-name folder-name) + (dispatch [::folder/toggle-renaming folder-id]))} + "rename"] + [:span.link-button.f-s-12 + {:on-click (fn [e] + (.stopPropagation e) + (reset! confirm-delete? true))} + "delete"] + [:i.fa.m-l-10.orange + {:class-name (if folder-expanded? "fa-caret-up" "fa-caret-down")}]])] + (when @confirm-delete? + [:div.p-20.flex.justify-cont-end + [:div + [:div.m-b-10 "Are you sure you want to delete this folder? Characters will not be deleted."] + [:div.flex + [:button.form-button + {:on-click (fn [e] + (.stopPropagation e) + (reset! confirm-delete? false))} + "cancel"] + [:span.link-button.m-l-10 + {:on-click (fn [e] + (.stopPropagation e) + (dispatch [::folder/delete-folder folder-id]))} + "delete"]]]]) + (if folder-expanded? + [:div.item-list + (doall + (map + (fn [{:keys [:db/id ::se/owner] :as summary}] + ^{:key (:db/id summary)} + [character-list-item + expanded-characters + selected-ids + (:db/id summary) + owner + username + summary]) + (sort-by ::char/character-name visible-chars)))])]])))) + (defn orcacle-page [] [content-page "Orcacle" [] [orcacle]]) +(defn character-filter-bar [] + (let [classes-open? (r/atom false) + levels-open? (r/atom false)] + (fn [] + (let [name-filter @(subscribe [::char/char-name-filter]) + level-filters @(subscribe [::char/char-level-filters]) + class-filters @(subscribe [::char/char-class-filters]) + has-portrait? @(subscribe [::char/char-has-portrait?]) + has-faction-pic? @(subscribe [::char/char-has-faction-pic?]) + avail-classes @(subscribe [::char/char-classes-available]) + avail-levels @(subscribe [::char/char-levels-available]) + any-filter? (or (not (s/blank? name-filter)) + (seq level-filters) + (seq class-filters) + has-portrait? + has-faction-pic?)] + [:div.main-text-color.m-b-10.char-filter-bar + (when @classes-open? + [:div.posn-fixed + {:style {:top 0 :left 0 :right 0 :bottom 0 :z-index 100} + :on-click (fn [e] (.stopPropagation e) (reset! classes-open? false))}]) + (when @levels-open? + [:div.posn-fixed + {:style {:top 0 :left 0 :right 0 :bottom 0 :z-index 100} + :on-click (fn [e] (.stopPropagation e) (reset! levels-open? false))}]) + [:div.flex.align-items-c.flex-wrap.p-5 + ;; Name search + [:div.posn-rel.m-r-5.m-b-5 + [:input.input + {:placeholder "Search by name..." + :style {:width "200px"} + :value name-filter + :on-change #(dispatch [::char/set-char-name-filter (.. % -target -value)])}] + (when (not (s/blank? name-filter)) + [:i.fa.fa-times.posn-abs.pointer.orange.f-s-14 + {:style {:right "8px" :top "8px"} + :on-click #(dispatch [::char/set-char-name-filter ""])}])] + + ;; Classes multi-select dropdown + [:div.posn-rel.m-r-5.m-b-5 + {:style {:z-index (if @classes-open? 200 1)}} + [:button.form-button + {:on-click (fn [e] (.stopPropagation e) (swap! classes-open? not))} + (str "Classes" (when (seq class-filters) (str " (" (count class-filters) ")"))) + [:i.fa.m-l-5 {:class-name (if @classes-open? "fa-caret-up" "fa-caret-down")}]] + (when @classes-open? + [:div.filter-dropdown.main-text-color + {:style {:min-width "160px"}} + (if (seq avail-classes) + (doall + (map (fn [cls] + ^{:key cls} + [:div.filter-dropdown-item + [comps/labeled-checkbox cls (contains? class-filters cls) false + (fn [] (dispatch [::char/toggle-char-class-filter cls]))]]) + avail-classes)) + [:span.opacity-5.f-s-12.p-5 "No classes yet"])])] + + ;; Levels multi-select dropdown + [:div.posn-rel.m-r-5.m-b-5 + {:style {:z-index (if @levels-open? 200 1)}} + [:button.form-button + {:on-click (fn [e] (.stopPropagation e) (swap! levels-open? not))} + (str "Levels" (when (seq level-filters) (str " (" (count level-filters) ")"))) + [:i.fa.m-l-5 {:class-name (if @levels-open? "fa-caret-up" "fa-caret-down")}]] + (when @levels-open? + [:div.filter-dropdown.main-text-color + {:style {:min-width "120px"}} + (if (seq avail-levels) + (doall + (map (fn [lvl] + ^{:key lvl} + [:div.filter-dropdown-item + [comps/labeled-checkbox (str "Level " lvl) (contains? level-filters lvl) false + (fn [] (dispatch [::char/toggle-char-level-filter lvl]))]]) + avail-levels)) + [:span.opacity-5.f-s-12.p-5 "No levels yet"])])] + + ;; Portrait toggle (nil=all, true=with portrait, false=without portrait) + [:button.form-button.m-r-5.m-b-5 + {:on-click #(dispatch [::char/toggle-char-has-portrait])} + [:i.fa.fa-user.m-r-5] + (cond (true? has-portrait?) "Portrait: Has" + (false? has-portrait?) "Portrait: None" + :else "Portrait: All")] + + ;; Faction Pic toggle (nil=all, true=with faction pic, false=without faction pic) + [:button.form-button.m-r-5.m-b-5 + {:on-click #(dispatch [::char/toggle-char-has-faction-pic])} + [:i.fa.fa-flag.m-r-5] + (cond (true? has-faction-pic?) "Faction Pic: Has" + (false? has-faction-pic?) "Faction Pic: None" + :else "Faction Pic: All")] + + ;; Clear button — only shown when any filter is active + (when any-filter? + [:button.form-button.m-b-5 + {:on-click #(dispatch [::char/clear-char-filters])} + [:i.fa.fa-times.m-r-5] + "Clear"])]])))) + (defn character-list [] - (let [characters @(subscribe [::char/characters]) + (let [characters @(subscribe [::char/filtered-characters]) + folders @(subscribe [::folder/folders]) + char-folder-map @(subscribe [::folder/character-folder-map]) expanded-characters @(subscribe [:expanded-characters]) - device-type @(subscribe [:device-type]) username @(subscribe [:username]) selected-ids @(subscribe [::char/selected]) has-selected? @(subscribe [::char/has-selected?])] @@ -7795,36 +8007,67 @@ {:title "Make Party" :icon "users" :class-name (if (not has-selected?) "opacity-5 cursor-disabled") - :on-click (if has-selected? (make-event-handler ::party/make-party selected-ids))}] + :on-click (if has-selected? (make-event-handler ::party/make-party selected-ids))} + {:title "New Folder" + :icon "folder" + :on-click #(dispatch [::folder/create-folder])}] [:div.p-5 + [character-filter-bar] [:div - (let [grouped-characters (group-by ::se/owner characters) + (let [filtered-char-ids (into #{} (map :db/id) characters) + grouped-characters (group-by ::se/owner characters) user-characters (find grouped-characters username) other-characters (sort-by key (dissoc grouped-characters username)) - sorted-groups (if user-characters - (cons user-characters other-characters) - other-characters)] - (doall - (map - (fn [[owner owner-characters]] - ^{:key owner} - [:div.m-b-40 - [:div.m-b-10.main-text-color.f-w-b.f-s-16 - [other-user-component owner "f-s-24 m-l-10 m-r-20 i" true]] - [:div.item-list - (doall - (map - (fn [{:keys [:db/id ::se/owner] :as summary}] - ^{:key id} - [character-list-item - expanded-characters - selected-ids - id - owner - username - summary]) - (sort-by ::char/character-name owner-characters)))]]) - sorted-groups)))]]])) + user-chars-list (second user-characters) + unfiled-user-chars (remove #(get char-folder-map (:db/id %)) user-chars-list) + user-folders (sort-by ::folder/name folders)] + [:div + (when (and username (seq user-folders)) + [:div.m-b-20 + (doall + (map + (fn [f] + ^{:key (:db/id f)} + [folder-item f expanded-characters selected-ids username filtered-char-ids]) + user-folders))]) + (when (and username (seq unfiled-user-chars)) + [:div.m-b-40 + (when (seq user-folders) + [:div.m-b-10.main-text-color.f-s-14.opacity-5 "Unfiled"]) + [:div.item-list + (doall + (map + (fn [{:keys [:db/id ::se/owner] :as summary}] + ^{:key (:db/id summary)} + [character-list-item + expanded-characters + selected-ids + (:db/id summary) + owner + username + summary]) + (sort-by ::char/character-name unfiled-user-chars)))]]) + (doall + (map + (fn [[owner owner-characters]] + ^{:key owner} + [:div.m-b-40 + [:div.m-b-10.main-text-color.f-w-b.f-s-16 + [other-user-component owner "f-s-24 m-l-10 m-r-20 i" true]] + [:div.item-list + (doall + (map + (fn [{:keys [:db/id ::se/owner] :as summary}] + ^{:key (:db/id summary)} + [character-list-item + expanded-characters + selected-ids + (:db/id summary) + owner + username + summary]) + (sort-by ::char/character-name owner-characters)))]]) + (sort-by key other-characters)))])]]])) (def party-name-editor-style {:width "200px" @@ -8228,3 +8471,4 @@ {:style close-icon-style :on-click (make-event-handler ::char/filter-items "")}]]] [item-list-items]]])) + diff --git a/test/clj/orcpub/routes/folder_test.clj b/test/clj/orcpub/routes/folder_test.clj new file mode 100644 index 000000000..7ab4005d7 --- /dev/null +++ b/test/clj/orcpub/routes/folder_test.clj @@ -0,0 +1,230 @@ +(ns orcpub.routes.folder-test + (:require [clojure.test :refer [deftest is testing]] + [datomic.api :as d] + [datomock.core :as dm] + [orcpub.routes :as routes] + [orcpub.routes.folder :as folder] + [orcpub.db.schema :as schema] + [orcpub.entity.strict :as se] + [orcpub.dnd.e5.folder :as folder5e] + [orcpub.dnd.e5.character :as char5e]) + (:import [java.util UUID])) + +;; --------------------------------------------------------------------------- +;; Test helpers + +(defmacro with-conn [conn-binding & body] + `(let [uri# (str "datomic:mem:folder-test-" (UUID/randomUUID)) + ~conn-binding (do + (d/create-database uri#) + (d/connect uri#))] + (try ~@body + (finally (d/delete-database uri#))))) + +(defn setup-db! [conn] + @(d/transact conn schema/all-schemas) + @(d/transact conn [{:orcpub.user/username "alice" + :orcpub.user/email "alice@test.com"} + {:orcpub.user/username "bob" + :orcpub.user/email "bob@test.com"}])) + +(defn test-character [] + {::se/selections + [{::se/key :ability-scores + ::se/option + {::se/key :standard-scores + ::se/map-value + {::char5e/str 15 + ::char5e/dex 14 + ::char5e/con 13 + ::char5e/int 12 + ::char5e/wis 10 + ::char5e/cha 8}}} + {::se/key :class + ::se/options + [{::se/key :fighter + ::se/selections + [{::se/key :levels + ::se/options [{::se/key :level-1}]}]}]}] + ::se/summary + {::char5e/character-name "Aragorn" + ::char5e/classes [{::char5e/class-name "Fighter" + ::char5e/level 1}]}}) + +(defn save-char! [conn username] + (:body (routes/do-save-character (d/db conn) conn (test-character) {:user username}))) + +(defn create-folder! [conn username folder-name] + (:body (folder/create-folder {:conn conn + :identity {:user username} + :transit-params {::folder5e/name folder-name}}))) + +;; --------------------------------------------------------------------------- +;; create-folder + +(deftest test-create-folder + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn) + result (folder/create-folder {:conn mocked-conn + :identity {:user "alice"} + :transit-params {::folder5e/name "Campaign 1"}})] + (testing "returns HTTP 200" + (is (= 200 (:status result)))) + (testing "body has correct name" + (is (= "Campaign 1" (::folder5e/name (:body result))))) + (testing "body has correct owner" + (is (= "alice" (::folder5e/owner (:body result))))) + (testing "body has a db/id" + (is (int? (:db/id (:body result)))))))) + +;; --------------------------------------------------------------------------- +;; folders (list) + +(deftest test-list-folders + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn)] + (create-folder! mocked-conn "alice" "Alice Folder 1") + (create-folder! mocked-conn "alice" "Alice Folder 2") + (create-folder! mocked-conn "bob" "Bob Folder") + (testing "alice sees only her own folders" + (let [body (:body (folder/folders {:db (d/db mocked-conn) + :identity {:user "alice"}}))] + (is (= 2 (count body))) + (is (= #{"Alice Folder 1" "Alice Folder 2"} + (into #{} (map ::folder5e/name) body))))) + (testing "bob sees only his own folder" + (let [body (:body (folder/folders {:db (d/db mocked-conn) + :identity {:user "bob"}}))] + (is (= 1 (count body))) + (is (= "Bob Folder" (::folder5e/name (first body))))))))) + +;; --------------------------------------------------------------------------- +;; update-folder-name + +(deftest test-update-folder-name + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn) + created (create-folder! mocked-conn "alice" "Old Name") + folder-id (:db/id created) + result (folder/update-folder-name {:conn mocked-conn + :transit-params "New Name" + :path-params {:id folder-id}})] + (testing "returns HTTP 200" + (is (= 200 (:status result)))) + (testing "folder name is updated" + (is (= "New Name" (::folder5e/name (:body result)))))))) + +;; --------------------------------------------------------------------------- +;; add-character + +(deftest test-add-character + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn) + saved-char (save-char! mocked-conn "alice") + char-id (:db/id saved-char) + folder (create-folder! mocked-conn "alice" "My Folder") + folder-id (:db/id folder) + result (folder/add-character {:db (d/db mocked-conn) + :conn mocked-conn + :transit-params char-id + :path-params {:id folder-id}})] + (testing "returns HTTP 200" + (is (= 200 (:status result)))) + (testing "folder now contains the character" + (let [char-ids (->> (:body result) + ::folder5e/character-ids + (map :db/id) + set)] + (is (contains? char-ids char-id))))))) + +(deftest test-add-character-at-most-one-folder + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn) + saved-char (save-char! mocked-conn "alice") + char-id (:db/id saved-char) + folder-a (create-folder! mocked-conn "alice" "Folder A") + folder-b (create-folder! mocked-conn "alice" "Folder B")] + ;; Add character to Folder A + (folder/add-character {:db (d/db mocked-conn) + :conn mocked-conn + :transit-params char-id + :path-params {:id (:db/id folder-a)}}) + ;; Add same character to Folder B — should remove from A first + (folder/add-character {:db (d/db mocked-conn) + :conn mocked-conn + :transit-params char-id + :path-params {:id (:db/id folder-b)}}) + (let [db (d/db mocked-conn) + a-chars (d/q '[:find [?c ...] + :in $ ?f + :where [?f :orcpub.dnd.e5.folder/character-ids ?c]] + db (:db/id folder-a)) + b-chars (d/q '[:find [?c ...] + :in $ ?f + :where [?f :orcpub.dnd.e5.folder/character-ids ?c]] + db (:db/id folder-b))] + (testing "character is no longer in Folder A" + (is (empty? a-chars))) + (testing "character is in Folder B" + (is (= [char-id] b-chars))))))) + +;; --------------------------------------------------------------------------- +;; remove-character + +(deftest test-remove-character + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn) + saved-char (save-char! mocked-conn "alice") + char-id (:db/id saved-char) + folder (create-folder! mocked-conn "alice" "My Folder") + folder-id (:db/id folder)] + ;; First add the character + (folder/add-character {:db (d/db mocked-conn) + :conn mocked-conn + :transit-params char-id + :path-params {:id folder-id}}) + ;; Then remove it + (let [result (folder/remove-character {:conn mocked-conn + :path-params {:id folder-id + :character-id (str char-id)}})] + (testing "returns HTTP 200" + (is (= 200 (:status result)))) + (testing "folder no longer contains the character" + (let [char-ids (->> (:body result) + ::folder5e/character-ids + (map :db/id) + set)] + (is (not (contains? char-ids char-id))))))))) + +;; --------------------------------------------------------------------------- +;; delete-folder + +(deftest test-delete-folder + (with-conn conn + (setup-db! conn) + (let [mocked-conn (dm/fork-conn conn) + saved-char (save-char! mocked-conn "alice") + char-id (:db/id saved-char) + folder (create-folder! mocked-conn "alice" "Doomed Folder") + folder-id (:db/id folder)] + ;; Add a character to the folder before deleting + (folder/add-character {:db (d/db mocked-conn) + :conn mocked-conn + :transit-params char-id + :path-params {:id folder-id}}) + (let [result (folder/delete-folder {:conn mocked-conn + :path-params {:id folder-id}})] + (testing "returns HTTP 200" + (is (= 200 (:status result)))) + (testing "folder entity is gone from db" + (let [folder-entity (d/pull (d/db mocked-conn) '[*] folder-id)] + (is (= {:db/id folder-id} folder-entity)))) + (testing "character entity is NOT deleted" + (let [char-entity (d/pull (d/db mocked-conn) '[*] char-id)] + (is (some? (::se/owner char-entity))))))))) diff --git a/test/cljc/orcpub/dnd/e5/char_filter_test.clj b/test/cljc/orcpub/dnd/e5/char_filter_test.clj new file mode 100644 index 000000000..91c6b82ff --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/char_filter_test.clj @@ -0,0 +1,177 @@ +(ns orcpub.dnd.e5.char-filter-test + (:require [clojure.test :refer [deftest is testing]] + [orcpub.dnd.e5.char-filter :as char-filter] + [orcpub.dnd.e5.character :as char5e])) + +;; --------------------------------------------------------------------------- +;; Test data + +(def aragorn + {::char5e/character-name "Aragorn" + ::char5e/image-url "http://example.com/aragorn.png" + ::char5e/faction-image-url nil + ::char5e/classes [{::char5e/class-name "Fighter" ::char5e/level 5} + {::char5e/class-name "Ranger" ::char5e/level 3}]}) + +(def legolas + {::char5e/character-name "Legolas" + ::char5e/image-url nil + ::char5e/faction-image-url "http://example.com/mirkwood.png" + ::char5e/classes [{::char5e/class-name "Ranger" ::char5e/level 8}]}) + +(def gimli + {::char5e/character-name "Gimli" + ::char5e/image-url nil + ::char5e/faction-image-url nil + ::char5e/classes [{::char5e/class-name "Fighter" ::char5e/level 10}]}) + +(def all-chars [aragorn legolas gimli]) + +;; Shorthand: call filter-characters with all filters defaulted to "no filter" +(defn filter-by [& {:keys [name levels classes portrait faction] + :or {name "" levels #{} classes #{} portrait nil faction nil}}] + (vec (char-filter/filter-characters all-chars name levels classes portrait faction))) + +;; --------------------------------------------------------------------------- +;; Name filter + +(deftest name-filter-blank-returns-all + (is (= all-chars (filter-by :name "")))) + +(deftest name-filter-case-insensitive-substring + (testing "lowercase query matches mixed-case name" + (is (= [aragorn] (filter-by :name "ara")))) + (testing "uppercase query matches" + (is (= [gimli] (filter-by :name "GIM")))) + (testing "no match returns empty" + (is (= [] (filter-by :name "xyz")))) + (testing "partial match across multiple chars" + (is (= [legolas gimli] (filter-by :name "l"))))) + +(deftest name-filter-nil-treated-as-blank + ;; character-name may be nil for a newly-created character with no name set + (let [unnamed {::char5e/character-name nil + ::char5e/image-url nil + ::char5e/faction-image-url nil + ::char5e/classes []} + result (char-filter/filter-characters [unnamed] "" #{} #{} nil nil)] + (is (= [unnamed] (vec result))))) + +;; --------------------------------------------------------------------------- +;; Level filter + +(deftest level-filter-empty-returns-all + (is (= all-chars (filter-by :levels #{})))) + +(deftest level-filter-matches-any-class-level + (testing "level 3 matches Aragorn (Ranger 3)" + (is (= [aragorn] (filter-by :levels #{3})))) + (testing "level 5 matches Aragorn (Fighter 5)" + (is (= [aragorn] (filter-by :levels #{5})))) + (testing "level 8 matches Legolas" + (is (= [legolas] (filter-by :levels #{8})))) + (testing "level in multiple chars" + (is (= (set [aragorn gimli]) (set (filter-by :levels #{5 10}))))) + (testing "non-existent level returns empty" + (is (= [] (filter-by :levels #{99}))))) + +;; --------------------------------------------------------------------------- +;; Class filter + +(deftest class-filter-empty-returns-all + (is (= all-chars (filter-by :classes #{})))) + +(deftest class-filter-matches-any-class + (testing "Ranger matches Aragorn and Legolas" + (is (= (set [aragorn legolas]) (set (filter-by :classes #{"Ranger"}))))) + (testing "Fighter matches Aragorn and Gimli" + (is (= (set [aragorn gimli]) (set (filter-by :classes #{"Fighter"}))))) + (testing "multi-class filter: Fighter OR Ranger → all three" + (is (= (set all-chars) (set (filter-by :classes #{"Fighter" "Ranger"}))))) + (testing "non-existent class returns empty" + (is (= [] (filter-by :classes #{"Rogue"}))))) + +;; --------------------------------------------------------------------------- +;; Portrait (has-portrait?) filter + +(deftest portrait-filter-nil-returns-all + (is (= all-chars (filter-by :portrait nil)))) + +(deftest portrait-filter-true-returns-only-chars-with-portrait + (is (= [aragorn] (filter-by :portrait true)))) + +(deftest portrait-filter-false-returns-chars-without-portrait + (is (= (set [legolas gimli]) (set (filter-by :portrait false))))) + +;; --------------------------------------------------------------------------- +;; Faction-pic (has-faction-pic?) filter + +(deftest faction-filter-nil-returns-all + (is (= all-chars (filter-by :faction nil)))) + +(deftest faction-filter-true-returns-only-chars-with-faction-pic + (is (= [legolas] (filter-by :faction true)))) + +(deftest faction-filter-false-returns-chars-without-faction-pic + (is (= (set [aragorn gimli]) (set (filter-by :faction false))))) + +;; --------------------------------------------------------------------------- +;; Combined filters (AND logic) + +(deftest combined-name-and-class-filter + (testing "name contains 'gorn' AND class is Ranger → Aragorn only" + (is (= [aragorn] (filter-by :name "gorn" :classes #{"Ranger"}))))) + +(deftest combined-class-and-portrait-filter + (testing "Fighter AND has portrait → Aragorn only" + (is (= [aragorn] (filter-by :classes #{"Fighter"} :portrait true)))) + (testing "Fighter AND no portrait → Gimli only" + (is (= [gimli] (filter-by :classes #{"Fighter"} :portrait false))))) + +(deftest combined-level-and-faction-filter + (testing "any level 8 AND has faction pic → Legolas" + (is (= [legolas] (filter-by :levels #{8} :faction true))))) + +;; --------------------------------------------------------------------------- +;; Toggle event logic (pure functions, no re-frame required) + +(def toggle-set-item + "Pure fn matching the toggle-char-level-filter / toggle-char-class-filter logic." + (fn [db k v] + (let [filters (or (get db k) #{})] + (assoc db k ((if (filters v) disj conj) filters v))))) + +(deftest toggle-set-item-adds-to-empty-set + (let [db {} + result (toggle-set-item db ::char5e/char-level-filters 3)] + (is (= #{3} (::char5e/char-level-filters result))))) + +(deftest toggle-set-item-removes-existing-value + (let [db {::char5e/char-level-filters #{3 5}} + result (toggle-set-item db ::char5e/char-level-filters 3)] + (is (= #{5} (::char5e/char-level-filters result))))) + +(deftest toggle-set-item-adds-to-existing-set + (let [db {::char5e/char-level-filters #{5}} + result (toggle-set-item db ::char5e/char-level-filters 3)] + (is (= #{3 5} (::char5e/char-level-filters result))))) + +(deftest toggle-set-item-handles-nil-key-in-db + ;; When key not yet present, update must not call nil as a function + (let [db {} + result (toggle-set-item db ::char5e/char-class-filters "Rogue")] + (is (= #{"Rogue"} (::char5e/char-class-filters result))))) + +;; --------------------------------------------------------------------------- +;; Tri-state cycle logic (portrait / faction-pic toggle) + +(def cycle-tristate #(case % nil true, true false, false nil)) + +(deftest tristate-cycle-nil-to-true + (is (= true (cycle-tristate nil)))) + +(deftest tristate-cycle-true-to-false + (is (= false (cycle-tristate true)))) + +(deftest tristate-cycle-false-to-nil + (is (nil? (cycle-tristate false)))) diff --git a/test/cljc/orcpub/dnd/e5/folder_test.clj b/test/cljc/orcpub/dnd/e5/folder_test.clj new file mode 100644 index 000000000..49708f67b --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/folder_test.clj @@ -0,0 +1,62 @@ +(ns orcpub.dnd.e5.folder-test + (:require [clojure.test :refer [deftest is testing]] + [clojure.spec.alpha :as spec] + [orcpub.dnd.e5.folder :as folder5e])) + +;; --------------------------------------------------------------------------- +;; ::folder5e/name spec + +(deftest folder-name-valid + (is (spec/valid? ::folder5e/name "Campaign 1"))) + +(deftest folder-name-empty-string-is-valid + ;; spec only requires string? — emptiness is a UI concern, not a spec constraint + (is (spec/valid? ::folder5e/name ""))) + +(deftest folder-name-requires-string + (is (not (spec/valid? ::folder5e/name 42))) + (is (not (spec/valid? ::folder5e/name nil))) + (is (not (spec/valid? ::folder5e/name :keyword)))) + +;; --------------------------------------------------------------------------- +;; ::folder5e/character-id spec + +(deftest character-id-valid-int + (is (spec/valid? ::folder5e/character-id 1)) + (is (spec/valid? ::folder5e/character-id 987654321))) + +(deftest character-id-requires-int + (is (not (spec/valid? ::folder5e/character-id "1"))) + (is (not (spec/valid? ::folder5e/character-id nil)))) + +;; --------------------------------------------------------------------------- +;; ::folder5e/character-ids spec + +(deftest character-ids-valid-empty-collection + (is (spec/valid? ::folder5e/character-ids []))) + +(deftest character-ids-valid-collection-of-ints + (is (spec/valid? ::folder5e/character-ids [1 2 3]))) + +(deftest character-ids-invalid-contains-non-int + (is (not (spec/valid? ::folder5e/character-ids ["abc"]))) + (is (not (spec/valid? ::folder5e/character-ids [1 "two"])))) + +;; --------------------------------------------------------------------------- +;; ::folder5e/folder spec + +(deftest folder-valid-with-required-name + (is (spec/valid? ::folder5e/folder {::folder5e/name "My Folder"}))) + +(deftest folder-invalid-when-name-missing + (is (not (spec/valid? ::folder5e/folder {})))) + +(deftest folder-invalid-when-name-wrong-type + (is (not (spec/valid? ::folder5e/folder {::folder5e/name 123})))) + +(deftest folder-explain-data-nil-when-valid + (is (nil? (spec/explain-data ::folder5e/folder {::folder5e/name "Adventure"})))) + +(deftest folder-explain-data-non-nil-when-invalid + (is (some? (spec/explain-data ::folder5e/folder {}))) + (is (some? (spec/explain-data ::folder5e/folder {::folder5e/name nil}))))