diff --git a/Dockerfile.demo b/Dockerfile.demo new file mode 100644 index 0000000..33b69b9 --- /dev/null +++ b/Dockerfile.demo @@ -0,0 +1,79 @@ +# Demo image: Python backend (Doorman) + Next.js web client +# All demo environment is baked into the image (no .env required). + +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# Install Node.js + npm and useful tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + nodejs npm curl ca-certificates git \ + && npm i -g npm@^10 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Backend dependencies first for better layer caching +COPY backend-services/requirements.txt /app/backend-services/requirements.txt +RUN python -m pip install --upgrade pip \ + && pip install -r /app/backend-services/requirements.txt + +# Prepare web client dependencies separately for better caching +WORKDIR /app/web-client +COPY web-client/package*.json ./ +RUN npm ci --include=dev + +# Copy backend source only (avoid copying entire repo) +WORKDIR /app +COPY backend-services /app/backend-services + +# Copy web client sources (excluding node_modules via .dockerignore) +WORKDIR /app/web-client +COPY web-client/ . + +# Build-time args for frontend env (baked into Next.js bundle) +# For demo, leave gateway URL empty so client uses same-origin and Next.js rewrites proxy to the API. +ARG NEXT_PUBLIC_PROTECTED_USERS=demo@doorman.dev +ARG NEXT_PUBLIC_GATEWAY_URL= + +# Build Next.js (production) with baked public env +RUN echo "export NEXT_PUBLIC_PROTECTED_USERS=${NEXT_PUBLIC_PROTECTED_USERS}" > /tmp/build-env.sh && \ + echo "export NEXT_PUBLIC_GATEWAY_URL=${NEXT_PUBLIC_GATEWAY_URL}" >> /tmp/build-env.sh && \ + echo "export NODE_ENV=production" >> /tmp/build-env.sh && \ + echo "export NEXT_TELEMETRY_DISABLED=1" >> /tmp/build-env.sh && \ + echo "export DEMO_MODE=true" >> /tmp/build-env.sh && \ + . /tmp/build-env.sh && \ + npm run build && \ + npm prune --omit=dev + +# Runtime configuration baked for demo +WORKDIR /app + +# Demo defaults (no external .env needed) +ENV ENV=development \ + MEM_OR_EXTERNAL=MEM \ + THREADS=1 \ + HTTPS_ONLY=false \ + DOORMAN_ADMIN_EMAIL=demo@doorman.dev \ + DOORMAN_ADMIN_PASSWORD=DemoPassword123! \ + JWT_SECRET_KEY=demo-secret-change-me-please-32-bytes-min \ + DEMO_SEED=false \ + DEMO_MODE=true \ + ENABLE_STARLETTE_CORS=true \ + ALLOWED_ORIGINS=http://localhost:3000 \ + ALLOW_METHODS="GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD" \ + ALLOW_HEADERS="*" \ + ALLOW_CREDENTIALS=true \ + PORT=3001 \ + WEB_PORT=3000 + +# Add entrypoint +COPY docker/entrypoint.sh /app/docker/entrypoint.sh +RUN chmod +x /app/docker/entrypoint.sh + +EXPOSE 3001 3000 + +CMD ["/app/docker/entrypoint.sh"] diff --git a/README.md b/README.md index de10485..71e2040 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,25 @@ docker compose up When ready: - Web UI: `http://localhost:3000` - Gateway API: `http://localhost:3001` +- Data & logs persist in Docker volumes (`doorman-generated`, `doorman-logs`). + +### One‑Command Demo (in‑memory) + +Spin up a preconfigured demo (auto‑cleans on exit) without editing `.env`: + +```bash +# First time (build the demo image to include frontend proxy config) +docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build + +# Next runs (no rebuild needed) +docker compose -f docker-compose.yml -f docker-compose.demo.yml up +``` + +Defaults (demo‑only): +- Admin: `demo@doorman.dev` / `DemoPassword123!` +- Web UI: `http://localhost:3000` +- API: `http://localhost:3001` +- Mode: in‑memory (no Redis/Mongo); no seed data created ## Frontend Gateway Configuration @@ -71,11 +90,19 @@ docker compose logs -f docker compose down ``` +### Data & Logs + +- By default, Compose stores generated data and logs in Docker volumes, not in the repo folders: + - Volume `doorman-generated` → `/app/backend-services/generated` + - Volume `doorman-logs` → `/app/backend-services/logs` +- To inspect inside the container: `docker compose exec doorman sh` +- To reset data: `docker compose down -v` (removes volumes) + ## Configuration ### Required Environment Variables - `DOORMAN_ADMIN_EMAIL` — initial admin user email -- `DOORMAN_ADMIN_PASSWORD` — initial admin password +- `DOORMAN_ADMIN_PASSWORD` — initial admin password (12+ characters required) - `JWT_SECRET_KEY` — secret key for JWT tokens (32+ chars) Optional (recommended in some setups): @@ -83,16 +110,24 @@ Optional (recommended in some setups): ### High Availability Setup -For production/HA environments with Redis and MongoDB: +For production/HA with Redis and MongoDB via Docker Compose: ```bash -# Set in .env: +# In .env (compose service names inside the network) MEM_OR_EXTERNAL=REDIS +MONGO_DB_HOSTS=mongo:27017 +MONGO_DB_USER=doorman_admin +MONGO_DB_PASSWORD=changeme # set a stronger password in real deployments +REDIS_HOST=redis -# Start with production profile (includes Redis + MongoDB) +# Start with production profile (brings up Redis + MongoDB) docker compose --profile production up -d ``` +Notes: +- Ensure `MONGO_DB_USER`/`MONGO_DB_PASSWORD` match the values in `docker-compose.yml` (defaults are provided for convenience; change in production). +- When running under Compose, use `mongo` and `redis` service names (not `localhost`). + ### Alternative: Manual Docker Commands If you prefer not to use Docker Compose: diff --git a/backend-services/doorman.py b/backend-services/doorman.py index 2abf4f2..a6df7fd 100755 --- a/backend-services/doorman.py +++ b/backend-services/doorman.py @@ -433,6 +433,44 @@ async def app_lifespan(app: FastAPI): except Exception as e: gateway_logger.error(f'Memory mode restore failed: {e}') + # Optional: in-process demo seeding for MEM mode + try: + if database.memory_only and str(os.getenv('DEMO_SEED', 'false')).lower() in ( + '1', 'true', 'yes', 'on' + ): + from utils.demo_seed_util import run_seed as _run_seed_demo + + def _int_env(name: str, default: int) -> int: + try: + return int(os.getenv(name, default)) + except Exception: + return default + + users = _int_env('DEMO_USERS', 40) + apis = _int_env('DEMO_APIS', 15) + endpoints = _int_env('DEMO_ENDPOINTS', 6) + groups = _int_env('DEMO_GROUPS', 8) + protos = _int_env('DEMO_PROTOS', 6) + logs = _int_env('DEMO_LOGS', 1500) + gateway_logger.info( + f'DEMO_SEED enabled. Seeding in-memory store (users={users}, apis={apis}, endpoints={endpoints}, groups={groups}, protos={protos}, logs={logs})' + ) + try: + await asyncio.to_thread( + _run_seed_demo, + users, + apis, + endpoints, + groups, + protos, + logs, + None, + ) + except Exception as _se: + gateway_logger.warning(f'DEMO_SEED failed: {_se}') + except Exception: + pass + try: loop = asyncio.get_event_loop() diff --git a/backend-services/routes/config_routes.py b/backend-services/routes/config_routes.py index afaed66..6beab68 100644 --- a/backend-services/routes/config_routes.py +++ b/backend-services/routes/config_routes.py @@ -35,11 +35,12 @@ def _strip_id(doc: dict[str, Any]) -> dict[str, Any]: def _export_all() -> dict[str, Any]: - apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)] - endpoints = [_strip_id(e) for e in endpoint_collection.find().to_list(length=None)] - roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)] - groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)] - routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)] + # PyMongo cursors are synchronous; convert to lists directly + apis = [_strip_id(a) for a in list(api_collection.find())] + endpoints = [_strip_id(e) for e in list(endpoint_collection.find())] + roles = [_strip_id(r) for r in list(role_collection.find())] + groups = [_strip_id(g) for g in list(group_collection.find())] + routings = [_strip_id(r) for r in list(routing_collection.find())] return { 'apis': apis, 'endpoints': endpoints, @@ -147,9 +148,9 @@ async def export_apis( 'rest', ) api.get('api_id') - eps = endpoint_collection.find( - {'api_name': api_name, 'api_version': api_version} - ).to_list(length=None) + eps = list( + endpoint_collection.find({'api_name': api_name, 'api_version': api_version}) + ) audit( request, actor=username, @@ -166,7 +167,7 @@ async def export_apis( ).dict(), 'rest', ) - apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)] + apis = [_strip_id(a) for a in list(api_collection.find())] audit( request, actor=username, @@ -237,7 +238,7 @@ async def export_roles(request: Request, role_name: str | None = None): return process_response( ResponseModel(status_code=200, response={'role': _strip_id(role)}).dict(), 'rest' ) - roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)] + roles = [_strip_id(r) for r in list(role_collection.find())] audit( request, actor=username, @@ -308,7 +309,7 @@ async def export_groups(request: Request, group_name: str | None = None): return process_response( ResponseModel(status_code=200, response={'group': _strip_id(group)}).dict(), 'rest' ) - groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)] + groups = [_strip_id(g) for g in list(group_collection.find())] audit( request, actor=username, @@ -380,7 +381,7 @@ async def export_routings(request: Request, client_key: str | None = None): ResponseModel(status_code=200, response={'routing': _strip_id(routing)}).dict(), 'rest', ) - routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)] + routings = [_strip_id(r) for r in list(routing_collection.find())] audit( request, actor=username, @@ -440,7 +441,7 @@ async def export_endpoints( query['api_name'] = api_name if api_version: query['api_version'] = api_version - eps = [_strip_id(e) for e in endpoint_collection.find(query).to_list(length=None)] + eps = [_strip_id(e) for e in list(endpoint_collection.find(query))] return process_response( ResponseModel(status_code=200, response={'endpoints': eps}).dict(), 'rest' ) diff --git a/backend-services/routes/gateway_routes.py b/backend-services/routes/gateway_routes.py index 3706d15..fe3ee2f 100644 --- a/backend-services/routes/gateway_routes.py +++ b/backend-services/routes/gateway_routes.py @@ -365,6 +365,24 @@ async def gateway(request: Request, path: str): await limit_and_throttle(request) payload = await auth_required(request) username = payload.get('sub') + # Enforce API allowed roles when configured + try: + allowed_roles = resolved_api.get('api_allowed_roles') or [] + if allowed_roles: + from services.user_service import UserService as _US + u = await _US.get_user_by_username_helper(username) + if (u.get('role') or '') not in set(allowed_roles): + return process_response( + ResponseModel( + status_code=403, + response_headers={'request_id': request_id}, + error_code='GTW014', + error_message='Forbidden: role not allowed for this API', + ).dict(), + 'rest', + ) + except Exception: + pass await enforce_pre_request_limit(request, username) else: pass @@ -653,6 +671,24 @@ async def soap_gateway(request: Request, path: str): await limit_and_throttle(request) payload = await auth_required(request) username = payload.get('sub') + # Enforce API allowed roles when configured + try: + allowed_roles = api.get('api_allowed_roles') or [] + if allowed_roles: + from services.user_service import UserService as _US + u = await _US.get_user_by_username_helper(username) + if (u.get('role') or '') not in set(allowed_roles): + return process_response( + ResponseModel( + status_code=403, + response_headers={'request_id': request_id}, + error_code='GTW014', + error_message='Forbidden: role not allowed for this API', + ).dict(), + 'graphql', + ) + except Exception: + pass await enforce_pre_request_limit(request, username) else: pass @@ -833,6 +869,24 @@ async def graphql_gateway(request: Request, path: str): await limit_and_throttle(request) payload = await auth_required(request) username = payload.get('sub') + # Enforce API allowed roles when configured + try: + allowed_roles = api.get('api_allowed_roles') or [] + if allowed_roles: + from services.user_service import UserService as _US + u = await _US.get_user_by_username_helper(username) + if (u.get('role') or '') not in set(allowed_roles): + return process_response( + ResponseModel( + status_code=403, + response_headers={'request_id': request_id}, + error_code='GTW014', + error_message='Forbidden: role not allowed for this API', + ).dict(), + 'grpc', + ) + except Exception: + pass await enforce_pre_request_limit(request, username) else: pass @@ -1107,6 +1161,24 @@ async def grpc_gateway(request: Request, path: str): await limit_and_throttle(request) payload = await auth_required(request) username = payload.get('sub') + # Enforce API allowed roles when configured + try: + allowed_roles = api.get('api_allowed_roles') or [] + if allowed_roles: + from services.user_service import UserService as _US + u = await _US.get_user_by_username_helper(username) + if (u.get('role') or '') not in set(allowed_roles): + return process_response( + ResponseModel( + status_code=403, + response_headers={'request_id': request_id}, + error_code='GTW014', + error_message='Forbidden: role not allowed for this API', + ).dict(), + 'soap', + ) + except Exception: + pass await enforce_pre_request_limit(request, username) else: pass diff --git a/backend-services/routes/security_routes.py b/backend-services/routes/security_routes.py index 44e62dc..9de581a 100644 --- a/backend-services/routes/security_routes.py +++ b/backend-services/routes/security_routes.py @@ -250,8 +250,29 @@ async def restart_gateway(request: Request): 'rest', ) - pid_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'doorman.pid') - pid_file = os.path.abspath(pid_file) + # Try multiple likely locations for doorman.pid to avoid CWD ambiguity + candidates = [] + try: + routes_dir = os.path.dirname(os.path.abspath(__file__)) + be_root = os.path.abspath(os.path.join(routes_dir, '..')) + candidates.append(os.path.join(be_root, 'doorman.pid')) + except Exception: + pass + try: + # CWD-relative (used by doorman.start in some setups) + candidates.append(os.path.abspath('doorman.pid')) + except Exception: + pass + try: + # Alongside doorman.py + from importlib import import_module as _imp + _dm = _imp('doorman') + _dm_path = os.path.abspath(getattr(_dm, '__file__', '')) + if _dm_path: + candidates.append(os.path.join(os.path.dirname(_dm_path), 'doorman.pid')) + except Exception: + pass + pid_file = next((p for p in candidates if p and os.path.exists(p)), candidates[0] if candidates else os.path.abspath('doorman.pid')) if not os.path.exists(pid_file): return process_response( ResponseModel( diff --git a/backend-services/routes/subscription_routes.py b/backend-services/routes/subscription_routes.py index 2d65699..09652d7 100644 --- a/backend-services/routes/subscription_routes.py +++ b/backend-services/routes/subscription_routes.py @@ -58,6 +58,18 @@ async def subscribe_api(api_data: SubscribeModel, request: Request): ) logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}') + # If targeting a different user, require manage_subscriptions permission + if api_data.username and api_data.username != username: + if not await platform_role_required_bool(username, 'manage_subscriptions'): + return respond_rest( + ResponseModel( + status_code=403, + response_headers={'request_id': request_id}, + error_code='SUB009', + error_message='You do not have permission to subscribe another user', + ) + ) + if not await group_required( request, api_data.api_name + '/' + api_data.api_version, api_data.username ): @@ -145,6 +157,18 @@ async def unsubscribe_api(api_data: SubscribeModel, request: Request): ) logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}') + # If targeting a different user, require manage_subscriptions permission + if api_data.username and api_data.username != username: + if not await platform_role_required_bool(username, 'manage_subscriptions'): + return respond_rest( + ResponseModel( + status_code=403, + response_headers={'request_id': request_id}, + error_code='SUB010', + error_message='You do not have permission to unsubscribe another user', + ) + ) + if not await group_required( request, api_data.api_name + '/' + api_data.api_version, api_data.username ): @@ -324,7 +348,7 @@ async def available_apis(username: str, request: Request): can_manage = await platform_role_required_bool(actor, 'manage_subscriptions') cursor = api_collection.find().sort('api_name', 1) - apis = cursor.to_list(length=None) + apis = list(cursor) for a in apis: if a.get('_id'): del a['_id'] diff --git a/backend-services/routes/user_routes.py b/backend-services/routes/user_routes.py index 8b7c985..51bbe55 100644 --- a/backend-services/routes/user_routes.py +++ b/backend-services/routes/user_routes.py @@ -559,7 +559,7 @@ Response: @user_router.get( - '/email/{email}', description='Get user by email', response_model=list[UserModelResponse] + '/email/{email}', description='Get user by email', response_model=ResponseModel ) async def get_user_by_email(email: str, request: Request): request_id = str(uuid.uuid4()) diff --git a/backend-services/services/user_service.py b/backend-services/services/user_service.py index 44b647b..583aae3 100644 --- a/backend-services/services/user_service.py +++ b/backend-services/services/user_service.py @@ -124,10 +124,6 @@ class UserService: """ logger.info(f'{request_id} | Getting user by email: {email}') user = await db_find_one(user_collection, {'email': email}) - if '_id' in user: - del user['_id'] - if 'password' in user: - del user['password'] if not user: logger.error(f'{request_id} | User retrieval failed with code USR002') return ResponseModel( @@ -136,6 +132,10 @@ class UserService: error_code='USR002', error_message='User not found', ).dict() + if '_id' in user: + del user['_id'] + if 'password' in user: + del user['password'] logger.info(f'{request_id} | User retrieval successful') if not active_username == user.get('username') and not await platform_role_required_bool( active_username, 'manage_users' diff --git a/backend-services/utils/database.py b/backend-services/utils/database.py index 28f3507..04f2e02 100644 --- a/backend-services/utils/database.py +++ b/backend-services/utils/database.py @@ -154,11 +154,17 @@ class Database: # for tests that adjust the env and call initialize_collections again. try: env_pwd = os.getenv('DOORMAN_ADMIN_PASSWORD') + env_email = os.getenv('DOORMAN_ADMIN_EMAIL') if env_pwd: users.update_one( {'username': 'admin'}, {'$set': {'password': password_util.hash_password(env_pwd)}}, ) + if env_email: + users.update_one( + {'username': 'admin'}, + {'$set': {'email': env_email}}, + ) except Exception: pass diff --git a/backend-services/utils/database_async.py b/backend-services/utils/database_async.py index 200d6b6..c3c2de7 100644 --- a/backend-services/utils/database_async.py +++ b/backend-services/utils/database_async.py @@ -189,6 +189,12 @@ class AsyncDatabase: raise RuntimeError( 'Admin user missing password and DOORMAN_ADMIN_PASSWORD not set' ) + # Ensure admin email matches env if provided + env_email = os.getenv('DOORMAN_ADMIN_EMAIL') + if adm and env_email and adm.get('email') != env_email: + await self.db.users.update_one( + {'username': 'admin'}, {'$set': {'email': env_email}} + ) except Exception: pass diff --git a/backend-services/utils/subscription_util.py b/backend-services/utils/subscription_util.py index 5fa0628..75e5c16 100644 --- a/backend-services/utils/subscription_util.py +++ b/backend-services/utils/subscription_util.py @@ -9,6 +9,7 @@ import os from fastapi import HTTPException, Request from jose import JWTError +from utils.role_util import is_admin_user from utils.async_db import db_find_one from utils.auth_util import auth_required @@ -26,11 +27,12 @@ async def subscription_required(request: Request): raise HTTPException(status_code=401, detail='Invalid token') # Admin user bypasses subscription requirements - admin_email = os.getenv('DOORMAN_ADMIN_EMAIL', '') - logger.debug(f'Subscription check - username: "{username}", admin_email: "{admin_email}", match: {username == admin_email}') - if username == admin_email and admin_email: - logger.info(f'Admin user {username} bypassing subscription check') - return payload + try: + if await is_admin_user(username): + logger.info(f'Admin user {username} bypassing subscription check') + return payload + except Exception: + pass full_path = request.url.path if full_path.startswith('/api/rest/'): prefix = '/api/rest/' diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml new file mode 100644 index 0000000..2c32345 --- /dev/null +++ b/docker-compose.demo.yml @@ -0,0 +1,48 @@ +name: doorman-demo + +services: + doorman: + # Build from demo Dockerfile with baked env + build: + context: . + dockerfile: Dockerfile.demo + image: doorman-demo:latest + # Provide critical runtime env so it works even without a fresh build + environment: + DOORMAN_ADMIN_EMAIL: demo@doorman.dev + DOORMAN_ADMIN_PASSWORD: DemoPassword123! + JWT_SECRET_KEY: demo-secret-change-me-please-32-bytes-min + # Keep demo memory isolated; avoid writing/reading host dumps + MEM_AUTO_SAVE_ENABLED: "false" + MEM_DUMP_PATH: "/tmp/demo_memory_dump.bin" + ENABLE_STARLETTE_CORS: "true" + ALLOWED_ORIGINS: http://localhost:3000 + ALLOW_METHODS: GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD + ALLOW_HEADERS: "*" + ALLOW_CREDENTIALS: "true" + PORT: 3001 + WEB_PORT: 3000 + + # Tip: use a different host port if you already run something on 3000/3001 + ports: + - "3001:3001" + - "3000:3000" + + # Ensure it doesn't restart and doesn't persist bind mounts from base compose + restart: "no" + volumes: [] + container_name: doorman-demo + + demo-cleanup: + image: docker:27.3.1-cli + container_name: doorman-demo-cleanup + restart: "no" + # Wait idly; on SIGTERM/INT (when you Ctrl+C compose), run cleanup + command: ["/bin/sh", "-c", "trap '/bin/sh /cleanup.sh' TERM INT; tail -f /dev/null"] + environment: + - PROJECT=doorman-demo + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./docker/cleanup.sh:/cleanup.sh:ro + depends_on: + - doorman diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0fb1107 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,11 @@ +services: + doorman: + # Persist data and logs in Docker-managed volumes for production-like runs + volumes: + - doorman-generated:/app/backend-services/generated + - doorman-logs:/app/backend-services/logs + +volumes: + doorman-generated: + doorman-logs: + diff --git a/docker-compose.yml b/docker-compose.yml index 8915ece..cff6912 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,6 @@ # 1. Set MEM_OR_EXTERNAL=REDIS in .env # 2. Run: docker compose --profile production up -version: '3.8' - services: doorman: build: @@ -33,8 +31,8 @@ services: # Encryption key for dumps (set a strong value in .env for real use) MEM_ENCRYPTION_KEY: ${MEM_ENCRYPTION_KEY:-change-me-in-prod} volumes: - - ./generated:/app/backend-services/generated - - ./logs:/app/backend-services/logs + - doorman-generated:/app/backend-services/generated + - doorman-logs:/app/backend-services/logs restart: unless-stopped healthcheck: test: ["CMD", "sh", "-c", "curl -f $([ \"$${HTTPS_ONLY:-false}\" = \"true\" ] && echo https || echo http)://localhost:$${PORT:-3001}/platform/monitor/liveness"] @@ -83,3 +81,5 @@ services: volumes: redis-data: mongo-data: + doorman-generated: + doorman-logs: diff --git a/docker/cleanup.sh b/docker/cleanup.sh new file mode 100755 index 0000000..2c6d4fa --- /dev/null +++ b/docker/cleanup.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env sh +set -eu + +# Best-effort cleanup of demo resources when the stack is stopped via Ctrl+C. +# Requires access to the Docker socket (provided by compose volume mount). + +PROJECT="${PROJECT:-doorman-demo}" + +echo "[cleanup] Waiting for containers to stop..." +sleep 2 + +echo "[cleanup] Removing stopped containers for project: $PROJECT" +docker container prune -f --filter "label=com.docker.compose.project=${PROJECT}" || true + +echo "[cleanup] Removing volumes for project: $PROJECT" +VOL_IDS=$(docker volume ls -q --filter "label=com.docker.compose.project=${PROJECT}" || true) +if [ -n "${VOL_IDS:-}" ]; then + echo "$VOL_IDS" | xargs -r docker volume rm -f || true +fi + +echo "[cleanup] Removing networks for project: $PROJECT" +NET_IDS=$(docker network ls -q --filter "label=com.docker.compose.project=${PROJECT}" || true) +if [ -n "${NET_IDS:-}" ]; then + echo "$NET_IDS" | xargs -r docker network rm || true +fi + +echo "[cleanup] Done" + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a2959ea..0fdf667 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -88,6 +88,34 @@ WEB_PID=$! echo "[entrypoint] Services launched. Backend PID=$BACK_PID Web PID=$WEB_PID" -# Wait on either process to exit, then stop gracefully -wait -n || true +# Optional: Demo seeding (in-memory, for quick start demos) +if [ "${DEMO_SEED:-false}" = "true" ]; then + ( + set +e + BASE="http://localhost:${PORT:-3001}" + echo "[entrypoint] Waiting for backend to become healthy before seeding..." + for i in $(seq 1 40); do + if curl -sf "${BASE}/platform/monitor/liveness" >/dev/null 2>&1; then + break + fi + sleep 1 + done + echo "[entrypoint] Attempting demo seed via API..." + COOKIE_FILE="/tmp/doorman.demo.cookies" + # Login with admin to obtain session cookie + if curl -sc "$COOKIE_FILE" -H 'Content-Type: application/json' \ + -d "{\"email\":\"${DOORMAN_ADMIN_EMAIL:-admin@doorman.dev}\",\"password\":\"${DOORMAN_ADMIN_PASSWORD:-change-me}\"}" \ + "${BASE}/platform/authorization" >/dev/null 2>&1; then + # Trigger seed (idempotent-ish) + curl -sb "$COOKIE_FILE" -X POST "${BASE}/platform/demo/seed" >/dev/null 2>&1 || true + echo "[entrypoint] Demo seed triggered" + else + echo "[entrypoint] Demo seed skipped (login failed)" + fi + rm -f "$COOKIE_FILE" 2>/dev/null || true + ) & +fi + +# Wait on either main service to exit, then stop gracefully +wait -n "$BACK_PID" "$WEB_PID" || true graceful_stop diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..9b2cfd9 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run the demo compose stack and auto-clean containers, networks, and volumes on exit. + +COMPOSE=${COMPOSE:-docker compose} +FILES="-f docker-compose.yml -f docker-compose.demo.yml" +PROJECT=${PROJECT:-doorman-demo-$(date +%s)} + +cleanup() { + echo "[demo] Cleaning up demo stack..." + $COMPOSE $FILES -p "$PROJECT" down -v --remove-orphans || true +} + +trap cleanup EXIT INT TERM + +echo "[demo] Starting demo stack (project: $PROJECT)..." +$COMPOSE $FILES -p "$PROJECT" up --abort-on-container-exit --force-recreate diff --git a/web-client/next.config.js b/web-client/next.config.js new file mode 100644 index 0000000..2bf2b72 --- /dev/null +++ b/web-client/next.config.js @@ -0,0 +1,19 @@ +/** @type {import('next').NextConfig} */ +const isDemo = process.env.DEMO_MODE === 'true' + +const nextConfig = { + // Keep production builds resilient (regular and demo) + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + async rewrites() { + if (!isDemo) return [] + // Demo: proxy API to backend on 3001, keep browser same-origin (3000) + const target = process.env.GATEWAY_INTERNAL_URL || 'http://localhost:3001' + return [ + { source: '/platform/:path*', destination: `${target}/platform/:path*` }, + { source: '/api/:path*', destination: `${target}/api/:path*` }, + ] + }, +} + +module.exports = nextConfig diff --git a/web-client/next.config.ts b/web-client/next.config.ts index 4a7319c..f11e25d 100644 --- a/web-client/next.config.ts +++ b/web-client/next.config.ts @@ -42,6 +42,15 @@ const nextConfig: NextConfig = { // Allow opt-out to disable optimizer entirely when desired unoptimized: (process.env.NEXT_IMAGE_UNOPTIMIZED || '').toLowerCase() === 'true', }, + async redirects() { + return [ + { + source: '/favicon.ico', + destination: '/favicon-32x32.png', + permanent: true, + }, + ] + }, async headers() { return [ { diff --git a/web-client/public/android-chrome-192x192.png b/web-client/public/android-chrome-192x192.png new file mode 100644 index 0000000..10ad9cb Binary files /dev/null and b/web-client/public/android-chrome-192x192.png differ diff --git a/web-client/public/android-chrome-512x512.png b/web-client/public/android-chrome-512x512.png new file mode 100644 index 0000000..c4430d6 Binary files /dev/null and b/web-client/public/android-chrome-512x512.png differ diff --git a/web-client/public/apple-touch-icon.png b/web-client/public/apple-touch-icon.png new file mode 100644 index 0000000..f99dead Binary files /dev/null and b/web-client/public/apple-touch-icon.png differ diff --git a/web-client/public/favicon-16x16.png b/web-client/public/favicon-16x16.png new file mode 100644 index 0000000..a73186a Binary files /dev/null and b/web-client/public/favicon-16x16.png differ diff --git a/web-client/public/favicon-32x32.png b/web-client/public/favicon-32x32.png new file mode 100644 index 0000000..32da247 Binary files /dev/null and b/web-client/public/favicon-32x32.png differ diff --git a/web-client/src/app/apis/[apiId]/page.tsx b/web-client/src/app/apis/[apiId]/page.tsx index ad7662c..d79d45e 100644 --- a/web-client/src/app/apis/[apiId]/page.tsx +++ b/web-client/src/app/apis/[apiId]/page.tsx @@ -1281,10 +1281,11 @@ const ApiDetailPage = () => { onChange={setNewRole} onAdd={addRole} onKeyPress={(e) => e.key === 'Enter' && addRole()} - placeholder="Select or type role name" + placeholder="Select role" fetchOptions={fetchRoles} disabled={saving} addButtonText="Add" + restrictToOptions /> )} @@ -1324,10 +1325,11 @@ const ApiDetailPage = () => { onChange={setNewGroup} onAdd={addGroup} onKeyPress={(e) => e.key === 'Enter' && addGroup()} - placeholder="Select or type group name" + placeholder="Select group" fetchOptions={fetchGroups} disabled={saving} addButtonText="Add" + restrictToOptions /> )} diff --git a/web-client/src/app/apis/add/page.tsx b/web-client/src/app/apis/add/page.tsx index d783a79..e5d5521 100644 --- a/web-client/src/app/apis/add/page.tsx +++ b/web-client/src/app/apis/add/page.tsx @@ -593,10 +593,11 @@ const AddApiPage = () => { onChange={setNewRole} onAdd={addRole} onKeyPress={(e) => e.key === 'Enter' && addRole()} - placeholder="Select or type role name" + placeholder="Select role" fetchOptions={fetchRoles} disabled={loading} addButtonText="Add" + restrictToOptions />