mirror of
https://github.com/apidoorman/doorman.git
synced 2026-02-09 02:29:42 -06:00
79
Dockerfile.demo
Normal file
79
Dockerfile.demo
Normal file
@@ -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"]
|
||||
43
README.md
43
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/'
|
||||
|
||||
48
docker-compose.demo.yml
Normal file
48
docker-compose.demo.yml
Normal file
@@ -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
|
||||
11
docker-compose.prod.yml
Normal file
11
docker-compose.prod.yml
Normal file
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
28
docker/cleanup.sh
Executable file
28
docker/cleanup.sh
Executable file
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
18
scripts/demo.sh
Executable file
18
scripts/demo.sh
Executable file
@@ -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
|
||||
19
web-client/next.config.js
Normal file
19
web-client/next.config.js
Normal file
@@ -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
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
BIN
web-client/public/android-chrome-192x192.png
Normal file
BIN
web-client/public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web-client/public/android-chrome-512x512.png
Normal file
BIN
web-client/public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
BIN
web-client/public/apple-touch-icon.png
Normal file
BIN
web-client/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web-client/public/favicon-16x16.png
Normal file
BIN
web-client/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 386 B |
BIN
web-client/public/favicon-32x32.png
Normal file
BIN
web-client/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.api_allowed_roles.map((r, i) => (
|
||||
@@ -630,10 +631,11 @@ const AddApiPage = () => {
|
||||
onChange={setNewGroup}
|
||||
onAdd={addGroup}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addGroup()}
|
||||
placeholder="Select or type group name"
|
||||
placeholder="Select group"
|
||||
fetchOptions={fetchGroups}
|
||||
disabled={loading}
|
||||
addButtonText="Add"
|
||||
restrictToOptions
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.api_allowed_groups.map((g, i) => (
|
||||
|
||||
@@ -233,9 +233,10 @@ const UserSubscriptionsPage = () => {
|
||||
<SearchableSelect
|
||||
value={selectedApi}
|
||||
onChange={setSelectedApi}
|
||||
placeholder="Select or search for an API"
|
||||
placeholder="Select API"
|
||||
fetchOptions={fetchApiOptions}
|
||||
disabled={isAdding}
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary w-full" disabled={isAdding || !selectedApi}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import SearchableSelect from '@/components/SearchableSelect'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
@@ -146,21 +147,24 @@ export default function UserCreditsDetailPage() {
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Group <InfoTooltip text="Credit group to assign to this user" /></label>
|
||||
<select className="input" value={addGroupName} onChange={e => { setAddGroupName(e.target.value); setAddGroupTier('') }}>
|
||||
<option value="">Select group</option>
|
||||
{availableGroupsToAdd.map(g => (
|
||||
<option key={g} value={g}>{g}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
value={addGroupName}
|
||||
onChange={(v) => { setAddGroupName(v); setAddGroupTier('') }}
|
||||
fetchOptions={async () => Promise.resolve(availableGroupsToAdd)}
|
||||
placeholder="Select group"
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tier <InfoTooltip text="Tier within the selected group determining total credits" /></label>
|
||||
<select className="input" value={addGroupTier} onChange={e => setAddGroupTier(e.target.value)} disabled={!addGroupName}>
|
||||
<option value="">{addGroupName ? 'Select tier' : 'Select group first'}</option>
|
||||
{addGroupName && Object.keys(defs[addGroupName] || {}).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
value={addGroupTier}
|
||||
onChange={setAddGroupTier}
|
||||
fetchOptions={async () => Promise.resolve(addGroupName ? Object.keys(defs[addGroupName] || {}) : [])}
|
||||
placeholder={addGroupName ? 'Select tier' : 'Select group first'}
|
||||
disabled={!addGroupName}
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Available Credits <InfoTooltip text="Initial available credits; must be between 0 and the tier total" /></label>
|
||||
|
||||
@@ -371,28 +371,26 @@ export default function CreditsPage() {
|
||||
value={assignForm.username}
|
||||
onChange={(val: string) => setAssignForm({ ...assignForm, username: val })}
|
||||
fetchOptions={fetchUserOptions}
|
||||
placeholder="Select or type username"
|
||||
placeholder="Select username"
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credit Group *
|
||||
</label>
|
||||
<select
|
||||
value={assignForm.credit_group}
|
||||
onChange={(e) => setAssignForm({ ...assignForm, credit_group: e.target.value, tier_name: '' })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select credit group</option>
|
||||
{Object.keys(defs).map((group) => (
|
||||
<option key={group} value={group}>{group}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Select from available credit definitions
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credit Group *
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={assignForm.credit_group}
|
||||
onChange={(v: string) => setAssignForm({ ...assignForm, credit_group: v, tier_name: '' })}
|
||||
fetchOptions={async () => Promise.resolve(Object.keys(defs))}
|
||||
placeholder="Select credit group"
|
||||
restrictToOptions
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Select from available credit definitions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{assignForm.credit_group && (
|
||||
<div>
|
||||
|
||||
@@ -1,123 +1,14 @@
|
||||
'use client'
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import MarkdownViewer from '@/components/MarkdownViewer'
|
||||
import HtmlViewer from '@/components/HtmlViewer'
|
||||
import OpenApiViewer from '@/components/OpenApiViewer'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
|
||||
export default function DocumentationPage() {
|
||||
// Serve the gateway documentation (static HTML) from the app public folder
|
||||
// Keep a link to the API reference (Swagger) for developers who need it
|
||||
// User-facing documentation tabs (exclude setup/ops)
|
||||
const tabs = [
|
||||
{ key: 'guides', label: 'Guides', type: 'md', src: '/docs/guides.md' },
|
||||
{ key: 'users', label: 'Users & Roles', type: 'md', src: '/docs/howto-users-roles.md' },
|
||||
{ key: 'apis', label: 'Publish REST API', type: 'md', src: '/docs/howto-create-api-rest.md' },
|
||||
{ key: 'rate', label: 'Rate & Throttle', type: 'md', src: '/docs/howto-rate-throttle.md' },
|
||||
{ key: 'credits', label: 'Credits & Quotas', type: 'md', src: '/docs/howto-credits-quotas.md' },
|
||||
{ key: 'workflows', label: 'API Workflows', type: 'md', src: '/docs/api-workflows.md' },
|
||||
{ key: 'fields', label: 'Using Fields', type: 'html', src: '/docs/using-fields.html' },
|
||||
{ key: 'security', label: 'Security', type: 'md', src: '/docs/security.md' },
|
||||
{ key: 'troubleshooting', label: 'Troubleshooting', type: 'md', src: '/docs/troubleshooting.md' },
|
||||
{ key: 'openapi', label: 'Open API Docs', type: 'frame', src: `${SERVER_URL}/platform/docs` },
|
||||
]
|
||||
const [active, setActive] = useState<typeof tabs[number]['key']>('guides')
|
||||
const [search, setSearch] = useState('')
|
||||
const [index, setIndex] = useState<Array<{ key: string; label: string; src: string; headings: Array<{ text: string; id: string }>; text: string }>>([])
|
||||
|
||||
// Build a simple search index for markdown tabs
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function build() {
|
||||
try {
|
||||
const mdTabs = tabs.filter(t => t.type === 'md')
|
||||
const out: Array<{ key: string; label: string; src: string; headings: Array<{ text: string; id: string }>; text: string }> = []
|
||||
for (const t of mdTabs) {
|
||||
const res = await fetch(t.src, { cache: 'no-store' })
|
||||
const raw = await res.text()
|
||||
const lines = raw.replaceAll('\r\n', '\n').split('\n')
|
||||
const headings: Array<{ text: string; id: string }> = []
|
||||
let text = ''
|
||||
let inCode = false
|
||||
for (const r of lines) {
|
||||
const line = r.trimEnd()
|
||||
if (line.startsWith('```')) { inCode = !inCode; continue }
|
||||
if (!inCode) {
|
||||
const m = line.match(/^(#{1,6})\s+(.*)$/)
|
||||
if (m) {
|
||||
const ht = m[2].trim()
|
||||
const id = ht.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').slice(0, 64)
|
||||
headings.push({ text: ht, id })
|
||||
}
|
||||
}
|
||||
text += r + '\n'
|
||||
}
|
||||
out.push({ key: t.key, label: t.label, src: t.src, headings, text })
|
||||
}
|
||||
if (!cancelled) setIndex(out)
|
||||
} catch {}
|
||||
}
|
||||
build()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const results = useMemo(() => {
|
||||
const q = search.trim()
|
||||
if (q.length < 2) return [] as Array<{ key: string; label: string; anchor?: string; snippet: string }>
|
||||
const lower = q.toLowerCase()
|
||||
const hits: Array<{ key: string; label: string; anchor?: string; snippet: string }> = []
|
||||
for (const doc of index) {
|
||||
const pos = doc.text.toLowerCase().indexOf(lower)
|
||||
if (pos >= 0) {
|
||||
// Find closest heading
|
||||
let anchor: string | undefined
|
||||
try {
|
||||
const before = doc.text.slice(0, pos)
|
||||
const lines = before.split('\n')
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const m = lines[i].match(/^#{1,6}\s+(.*)$/)
|
||||
if (m) { anchor = m[1].toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').slice(0, 64); break }
|
||||
}
|
||||
} catch {}
|
||||
const start = Math.max(0, pos - 40)
|
||||
const end = Math.min(doc.text.length, pos + q.length + 40)
|
||||
const snippet = doc.text.slice(start, end).replace(/\n/g, ' ')
|
||||
hits.push({ key: doc.key, label: doc.label, anchor, snippet })
|
||||
}
|
||||
}
|
||||
return hits.slice(0, 8)
|
||||
}, [search, index])
|
||||
|
||||
// Removed "Open API Reference" button per request
|
||||
|
||||
// Intercept clicks on /docs/... links to switch tabs instead of navigating
|
||||
function handleDocsLinkClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
try {
|
||||
const target = e.target as HTMLElement
|
||||
const a = target?.closest('a') as HTMLAnchorElement | null
|
||||
if (!a) return
|
||||
const href = a.getAttribute('href') || ''
|
||||
const url = new URL(href, window.location.origin)
|
||||
if (url.origin === window.location.origin && url.pathname.startsWith('/docs/')) {
|
||||
const tab = tabs.find(t => t.src === url.pathname)
|
||||
if (tab) {
|
||||
e.preventDefault()
|
||||
setActive(tab.key)
|
||||
const anchor = (url.hash || '').replace(/^#/, '')
|
||||
if (anchor) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const el = document.getElementById(anchor)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
} catch {}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const openapiUrl = `${SERVER_URL}/platform/openapi.json`
|
||||
const swaggerUrl = `${SERVER_URL}/platform/docs`
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
@@ -125,96 +16,17 @@ export default function DocumentationPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Gateway Documentation</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Guides and field references for configuring and operating the Doorman gateway
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">API Reference</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={openapiUrl} target="_blank" rel="noreferrer" className="btn btn-outline">OpenAPI JSON</a>
|
||||
<a href={swaggerUrl} target="_blank" rel="noreferrer" className="btn btn-secondary">Swagger UI</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar under title, above tabs */}
|
||||
<div className="relative">
|
||||
<div className="flex items-center rounded-md border border-gray-200 dark:border-white/10 bg-white dark:bg-dark-surface px-3 py-2 w-full max-w-2xl">
|
||||
<svg className="h-4 w-4 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 21l-4.35-4.35"/><circle cx="11" cy="11" r="7"/></svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search docs..."
|
||||
className="bg-transparent flex-1 text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-white/50"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{search.trim().length >= 2 && results.length > 0 && (
|
||||
<div className="absolute z-20 mt-2 w-full max-w-2xl rounded-md border border-gray-200 dark:border-white/10 bg-white dark:bg-dark-surface shadow-lg">
|
||||
<div className="max-h-[300px] overflow-auto divide-y divide-gray-100 dark:divide-white/10">
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setActive(r.key as any)
|
||||
setTimeout(() => { if (r.anchor) { try { window.location.hash = r.anchor } catch {} } }, 50)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-white/5"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{r.label}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-white/70 truncate">{r.snippet}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-dark-surface shadow-sm overflow-hidden p-4">
|
||||
<OpenApiViewer openapiUrl={openapiUrl} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div role="tablist" aria-label="Documentation sections" className="-mb-px flex gap-2 overflow-x-auto no-scrollbar border-b border-gray-200 dark:border-white/10">
|
||||
{tabs.map(t => {
|
||||
const isActive = active === t.key
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
key={t.key}
|
||||
onClick={() => setActive(t.key)}
|
||||
className={
|
||||
'relative px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:rounded-md ' +
|
||||
(isActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-white/70 hover:text-gray-900 dark:hover:text-white')
|
||||
}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
<span className={
|
||||
'absolute inset-x-2 -bottom-px h-0.5 rounded-full transition-all ' +
|
||||
(isActive ? 'bg-blue-600 dark:bg-blue-400' : 'bg-transparent')
|
||||
} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-surface rounded-lg shadow-sm border border-gray-200 dark:border-white/[0.08] overflow-hidden" onClick={handleDocsLinkClick}>
|
||||
{tabs.map(t => (
|
||||
<div key={t.key} style={{ display: active === t.key ? 'block' : 'none' }}>
|
||||
{t.type === 'md' ? (
|
||||
<div className="p-5" style={{ minHeight: '600px' }}>
|
||||
<MarkdownViewer src={t.src} searchTerm={search} />
|
||||
</div>
|
||||
) : t.type === 'html' ? (
|
||||
<div className="p-5" style={{ minHeight: '600px' }}>
|
||||
<HtmlViewer src={t.src} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-0" style={{ minHeight: '600px' }}>
|
||||
<iframe title="OpenAPI Docs" src={t.src as any} className="w-full" style={{ minHeight: 'calc(100vh - 260px)', border: 0 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info panel removed per request */}
|
||||
</div>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -7,7 +7,8 @@ import Layout from '@/components/Layout'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { postJson } from '@/utils/api'
|
||||
import { postJson, fetchAllPaginated } from '@/utils/api'
|
||||
import SearchableSelect from '@/components/SearchableSelect'
|
||||
|
||||
interface CreateGroupData {
|
||||
group_name: string
|
||||
@@ -26,6 +27,24 @@ const AddGroupPage = () => {
|
||||
})
|
||||
const [newApi, setNewApi] = useState('')
|
||||
|
||||
const fetchApiOptions = async (): Promise<string[]> => {
|
||||
const items = await fetchAllPaginated<any>(
|
||||
(p, s) => `${SERVER_URL}/platform/api/all?page=${p}&page_size=${s}`,
|
||||
(data) => (Array.isArray(data) ? data : (data.apis || data.response?.apis || [])),
|
||||
undefined,
|
||||
undefined,
|
||||
'cache:apis:all'
|
||||
)
|
||||
return items
|
||||
.map((api: any) => {
|
||||
const name = api.api_name || api.name || ''
|
||||
const version = api.api_version || api.version || ''
|
||||
if (!name || !version) return null
|
||||
return `${name}/${version}`
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof CreateGroupData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
@@ -144,25 +163,17 @@ const AddGroupPage = () => {
|
||||
<InfoTooltip text="Add API name/version pairs this group may access, e.g., users/v1" />
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder="Enter API name to grant access"
|
||||
value={newApi}
|
||||
onChange={(e) => setNewApi(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addApi())}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addApi}
|
||||
disabled={loading || !newApi.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
value={newApi}
|
||||
onChange={setNewApi}
|
||||
onAdd={addApi}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addApi())}
|
||||
placeholder="Select API (name/version)"
|
||||
fetchOptions={fetchApiOptions}
|
||||
disabled={loading}
|
||||
addButtonText="Add"
|
||||
restrictToOptions
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.api_access.map((api, index) => (
|
||||
|
||||
BIN
web-client/src/app/icon.png
Normal file
BIN
web-client/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -105,9 +105,7 @@ const LoginPage = () => {
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-white/[0.08] rounded-lg p-8 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary-600 dark:bg-[#e5e5e5] rounded flex items-center justify-center">
|
||||
<span className="text-white dark:text-[#1a1a1a] font-semibold">D</span>
|
||||
</div>
|
||||
<img src="/android-chrome-192x192.png" alt="Doorman logo" className="mx-auto mb-4 w-12 h-12 object-contain" />
|
||||
<h1 className="text-[22px] font-medium text-gray-900 dark:text-white/90 mb-1">Welcome to Doorman</h1>
|
||||
<p className="text-gray-600 dark:text-white/40 text-[13px]">Sign in to manage your API gateway</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { getJson, postJson, delJson } from '@/utils/api'
|
||||
import { getJson, postJson, delJson, fetchAllPaginated } from '@/utils/api'
|
||||
import SearchableSelect from '@/components/SearchableSelect'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
|
||||
interface TierAssignment {
|
||||
@@ -38,6 +39,22 @@ export default function TierUsersPage() {
|
||||
const [expirationDate, setExpirationDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [assigning, setAssigning] = useState(false)
|
||||
|
||||
const fetchUserOptions = async (): Promise<string[]> => {
|
||||
try {
|
||||
const users = await fetchAllPaginated<any>(
|
||||
(page, size) => `${SERVER_URL}/platform/user/all?page=${page}&page_size=${size}`,
|
||||
(data) => (data?.users || data?.response?.users || []),
|
||||
undefined,
|
||||
undefined,
|
||||
'cache:users:all'
|
||||
)
|
||||
return users.map((u: any) => u?.username).filter(Boolean)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch users:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -273,14 +290,14 @@ export default function TierUsersPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
User ID *
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<SearchableSelect
|
||||
value={newUserId}
|
||||
onChange={(e) => setNewUserId(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Enter user ID or email"
|
||||
onChange={setNewUserId}
|
||||
fetchOptions={fetchUserOptions}
|
||||
placeholder="Select username"
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -560,9 +560,10 @@ const UserDetailPage = () => {
|
||||
<SearchableSelect
|
||||
value={editData.role || ''}
|
||||
onChange={(value) => handleInputChange('role', value)}
|
||||
placeholder="Select or type role name"
|
||||
placeholder="Select role"
|
||||
fetchOptions={fetchRoles}
|
||||
disabled={saving}
|
||||
restrictToOptions
|
||||
/>
|
||||
) : (
|
||||
<span className="badge badge-primary">{user.role}</span>
|
||||
@@ -575,18 +576,18 @@ const UserDetailPage = () => {
|
||||
<InfoTooltip text="Assign user to a pricing tier. Tier limits take priority over custom rate limits." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
<SearchableSelect
|
||||
value={editData.tier_id || ''}
|
||||
onChange={(e) => handleInputChange('tier_id', e.target.value || undefined)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">No Tier (Use custom rate limits)</option>
|
||||
{Array.isArray(availableTiers) && availableTiers.map((tier) => (
|
||||
<option key={tier.tier_id} value={tier.tier_id}>
|
||||
{tier.display_name || tier.name} - {tier.limits?.requests_per_minute || 0} req/min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(val) => handleInputChange('tier_id', val || undefined)}
|
||||
fetchOptions={async () => Promise.resolve(
|
||||
(Array.isArray(availableTiers) ? availableTiers : []).map(t => ({
|
||||
label: `${t.display_name || t.name}`,
|
||||
value: t.tier_id,
|
||||
}))
|
||||
)}
|
||||
placeholder="Select tier"
|
||||
restrictToOptions
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{currentTier ? (
|
||||
@@ -737,29 +738,33 @@ const UserDetailPage = () => {
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
<SearchableSelect
|
||||
value={newGroup}
|
||||
onChange={setNewGroup}
|
||||
onAdd={addGroup}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addGroup()}
|
||||
placeholder="Select or type group name"
|
||||
fetchOptions={fetchGroups}
|
||||
disabled={saving}
|
||||
addButtonText="Add Group"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={newGroup}
|
||||
onChange={setNewGroup}
|
||||
onAdd={addGroup}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addGroup()}
|
||||
placeholder="Select group"
|
||||
fetchOptions={fetchGroups}
|
||||
disabled={saving}
|
||||
addButtonText="Add Group"
|
||||
restrictToOptions
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{(isEditing ? editData.groups : user.groups)?.map((group, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={group}
|
||||
onChange={(e) => handleGroupChange(index, e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Enter group name"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<SearchableSelect
|
||||
value={group}
|
||||
onChange={(val) => handleGroupChange(index, val)}
|
||||
placeholder="Select group"
|
||||
fetchOptions={fetchGroups}
|
||||
disabled={saving}
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200 px-3 py-1 rounded-full flex-1">
|
||||
{group}
|
||||
|
||||
@@ -275,9 +275,10 @@ const AddUserPage = () => {
|
||||
<SearchableSelect
|
||||
value={formData.role}
|
||||
onChange={(value) => handleInputChange('role', value)}
|
||||
placeholder="Select or type role name"
|
||||
placeholder="Select role"
|
||||
fetchOptions={fetchRoles}
|
||||
disabled={loading}
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -343,10 +344,11 @@ const AddUserPage = () => {
|
||||
onChange={setNewGroup}
|
||||
onAdd={addGroup}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addGroup()}
|
||||
placeholder="Select or type group name"
|
||||
placeholder="Select group"
|
||||
fetchOptions={fetchGroups}
|
||||
disabled={loading}
|
||||
addButtonText="Add Group"
|
||||
restrictToOptions
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,19 +17,33 @@ interface MenuItem {
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
// Top: primary overview
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ label: 'Users', href: '/users', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z', permission: 'manage_users' },
|
||||
{ label: 'Analytics', href: '/analytics', icon: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6', permission: 'view_analytics' },
|
||||
|
||||
// Observability
|
||||
{ label: 'Logs', href: '/logging', icon: 'M8 16h8M8 12h8M8 8h8M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', permission: 'view_logs' },
|
||||
|
||||
// Core configuration and usage
|
||||
{ label: 'APIs', href: '/apis', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', permission: 'manage_apis' },
|
||||
{ label: 'Documentation', href: '/documentation', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' },
|
||||
|
||||
// Identity and access
|
||||
{ label: 'Users', href: '/users', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z', permission: 'manage_users' },
|
||||
{ label: 'Groups', href: '/groups', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', permission: 'manage_groups' },
|
||||
{ label: 'Roles', href: '/roles', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', permission: 'manage_roles' },
|
||||
{ label: 'Routings', href: '/routings', icon: 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', permission: 'manage_routings' },
|
||||
{ label: 'Analytics', href: '/analytics', icon: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6', permission: 'view_analytics' },
|
||||
{ label: 'Documentation', href: '/documentation', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' },
|
||||
|
||||
// Commercial controls
|
||||
{ label: 'Subscriptions', href: '/authorizations', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', permission: 'manage_subscriptions' },
|
||||
{ label: 'Credits', href: '/credits', icon: 'M12 8c-1.657 0-3 1.343-3 3 0 2.239 3 5 3 5s3-2.761 3-5c0-1.657-1.343-3-3-3z M12 13a2 2 0 110-4 2 2 0 010 4z', permission: 'manage_credits' },
|
||||
{ label: 'Tiers', href: '/tiers', icon: 'M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z', permission: 'manage_tiers' },
|
||||
{ label: 'Subscriptions', href: '/authorizations', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', permission: 'manage_subscriptions' },
|
||||
|
||||
// Traffic and policy
|
||||
{ label: 'Routings', href: '/routings', icon: 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', permission: 'manage_routings' },
|
||||
{ label: 'Auth Control', href: '/auth-admin', icon: 'M5 13l4 4L19 7', permission: 'manage_auth' },
|
||||
{ label: 'Security', href: '/security', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z', permission: 'manage_security' },
|
||||
|
||||
// Bottom utilities
|
||||
{ label: 'Tools', href: '/tools', icon: 'M12 8v8m-4-4h8M5 3a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V8.414A2 2 0 0019.586 7L15 2.414A2 2 0 0013.586 2H5z', permission: 'manage_security' },
|
||||
{ label: 'Import/Export', href: '/import-export', icon: 'M4 4v6h6M20 20v-6h-6M4 10l6-6m4 12l6 6', permission: 'manage_gateway' },
|
||||
{ label: 'Settings', href: '/settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }
|
||||
@@ -89,9 +103,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="px-6 py-3.5 border-b border-gray-200 dark:border-white/[0.08]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-6 h-6 bg-primary-600 dark:bg-[#e5e5e5] rounded flex items-center justify-center">
|
||||
<span className="text-white dark:text-[#1a1a1a] font-semibold text-xs">D</span>
|
||||
</div>
|
||||
<img src="/android-chrome-192x192.png" alt="Doorman" className="w-5 h-5 object-contain shrink-0" />
|
||||
<h1 className="text-[15px] font-medium text-gray-900 dark:text-white">Doorman</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,5 +259,4 @@ export default function OpenApiViewer({ openapiUrl }: { openapiUrl: string }) {
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
type Option = string | { label: string; value: string }
|
||||
type NormalizedOption = { label: string; value: string }
|
||||
|
||||
interface SearchableSelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onAdd?: () => void
|
||||
onKeyPress?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
fetchOptions: () => Promise<string[]>
|
||||
fetchOptions: () => Promise<Option[]>
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
addButtonText?: string
|
||||
// When true, the user must pick an option from the list.
|
||||
// Typing only filters; it will not set the bound value until an option is selected.
|
||||
restrictToOptions?: boolean
|
||||
}
|
||||
|
||||
export default function SearchableSelect({
|
||||
@@ -23,13 +29,20 @@ export default function SearchableSelect({
|
||||
fetchOptions,
|
||||
disabled = false,
|
||||
className = '',
|
||||
addButtonText = 'Add'
|
||||
addButtonText = 'Add',
|
||||
restrictToOptions = false,
|
||||
}: SearchableSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [options, setOptions] = useState<string[]>([])
|
||||
const [options, setOptions] = useState<NormalizedOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [query, setQuery] = useState<string>(value || '')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep internal query in sync with selected value
|
||||
useEffect(() => {
|
||||
setQuery(value || '')
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
@@ -41,6 +54,12 @@ export default function SearchableSelect({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const normalize = (items: Option[]): NormalizedOption[] => {
|
||||
return items
|
||||
.map((it) => (typeof it === 'string' ? { label: it, value: it } : it))
|
||||
.filter((it): it is NormalizedOption => !!it && typeof it.label === 'string' && typeof it.value === 'string')
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (options.length > 0) {
|
||||
setIsOpen(true)
|
||||
@@ -50,7 +69,7 @@ export default function SearchableSelect({
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchOptions()
|
||||
setOptions(data)
|
||||
setOptions(normalize(data))
|
||||
setIsOpen(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to load options:', err)
|
||||
@@ -65,18 +84,23 @@ export default function SearchableSelect({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (option: string) => {
|
||||
onChange(option)
|
||||
const handleSelect = (option: string | NormalizedOption) => {
|
||||
const val = typeof option === 'string' ? option : option.value
|
||||
const label = typeof option === 'string' ? option : option.label
|
||||
onChange(val)
|
||||
setQuery(label)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const filterText = (restrictToOptions ? query : value) || ''
|
||||
const filteredOptions = options.filter(opt =>
|
||||
opt.toLowerCase().includes(value.toLowerCase())
|
||||
opt.label.toLowerCase().includes(filterText.toLowerCase())
|
||||
)
|
||||
|
||||
// Check if current value exists in the options list (case-insensitive)
|
||||
const isValidValue = options.length === 0 || options.some(opt =>
|
||||
opt.toLowerCase() === value.trim().toLowerCase()
|
||||
const hasLoadedOptions = options.length > 0
|
||||
const isValidValue = hasLoadedOptions && options.some(opt =>
|
||||
opt.value.toLowerCase() === (value || '').trim().toLowerCase()
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -84,8 +108,15 @@ export default function SearchableSelect({
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={restrictToOptions ? query : value}
|
||||
onChange={(e) => {
|
||||
if (restrictToOptions) {
|
||||
setQuery(e.target.value)
|
||||
if (!isOpen) setIsOpen(true)
|
||||
} else {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
}}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyPress={onKeyPress}
|
||||
placeholder={placeholder}
|
||||
@@ -126,7 +157,7 @@ export default function SearchableSelect({
|
||||
onClick={() => handleSelect(option)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{option}
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,16 +10,45 @@ const PUBLIC_PATH_PREFIXES = [
|
||||
'/public',
|
||||
'/_next',
|
||||
'/api',
|
||||
// Common root-level static assets
|
||||
'/favicon.ico',
|
||||
'/favicon.png',
|
||||
'/favicon-16x16.png',
|
||||
'/favicon-32x32.png',
|
||||
'/favicon.svg',
|
||||
'/apple-touch-icon.png',
|
||||
'/android-chrome-192x192.png',
|
||||
'/android-chrome-512x512.png',
|
||||
'/safari-pinned-tab.svg',
|
||||
'/icon.png',
|
||||
]
|
||||
|
||||
function isStaticAsset(pathname: string): boolean {
|
||||
const lower = pathname.toLowerCase()
|
||||
return [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.txt',
|
||||
'.json',
|
||||
'.css',
|
||||
'.js',
|
||||
'.map',
|
||||
'.xml',
|
||||
].some(ext => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATH_PREFIXES.some(p => pathname === p || pathname.startsWith(p + '/'))
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname, search } = req.nextUrl
|
||||
if (isPublicPath(pathname)) {
|
||||
if (isPublicPath(pathname) || isStaticAsset(pathname)) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Gateway URL from environment - required for API calls
|
||||
// Set NEXT_PUBLIC_GATEWAY_URL in root .env file (loaded via dotenv-cli for dev, build arg for Docker)
|
||||
const GATEWAY_URL_RAW = process.env.NEXT_PUBLIC_GATEWAY_URL || ''
|
||||
const DEMO = process.env.DEMO_MODE === 'true'
|
||||
// In demo, force same-origin by ignoring NEXT_PUBLIC_GATEWAY_URL
|
||||
const GATEWAY_URL_RAW = DEMO ? '' : (process.env.NEXT_PUBLIC_GATEWAY_URL || '')
|
||||
|
||||
let _cachedUrl: string | null = null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user