mirror of
https://github.com/apidoorman/doorman.git
synced 2026-02-09 11:07:05 -06:00
tool tips and credit page updates
This commit is contained in:
@@ -40,6 +40,7 @@ from routes.security_routes import security_router
|
||||
from routes.credit_routes import credit_router
|
||||
from routes.demo_routes import demo_router
|
||||
from routes.monitor_routes import monitor_router
|
||||
from routes.config_routes import config_router
|
||||
from utils.security_settings_util import load_settings, start_auto_save_task, stop_auto_save_task, get_cached_settings
|
||||
from utils.memory_dump_util import dump_memory_to_file, restore_memory_from_file, find_latest_dump_path
|
||||
from utils.metrics_util import metrics_store
|
||||
@@ -452,6 +453,7 @@ doorman.include_router(monitor_router, prefix="/platform", tags=["Monitor"])
|
||||
# Expose token management under both legacy and new prefixes
|
||||
doorman.include_router(credit_router, prefix="/platform/credit", tags=["Credit"])
|
||||
doorman.include_router(demo_router, prefix="/platform/demo", tags=["Demo"])
|
||||
doorman.include_router(config_router, prefix="/platform", tags=["Config"])
|
||||
|
||||
def start():
|
||||
if os.path.exists(PID_FILE):
|
||||
|
||||
304
backend-services/routes/config_routes.py
Normal file
304
backend-services/routes/config_routes.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Routes to export and import platform configuration (APIs, Endpoints, Roles, Groups, Routings).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import copy
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from utils.response_util import process_response
|
||||
from utils.auth_util import auth_required
|
||||
from utils.role_util import platform_role_required_bool
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from utils.database import (
|
||||
api_collection,
|
||||
endpoint_collection,
|
||||
group_collection,
|
||||
role_collection,
|
||||
routing_collection,
|
||||
)
|
||||
|
||||
config_router = APIRouter()
|
||||
logger = logging.getLogger("doorman.gateway")
|
||||
|
||||
|
||||
def _strip_id(doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||
d = dict(doc)
|
||||
d.pop("_id", None)
|
||||
return d
|
||||
|
||||
|
||||
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)]
|
||||
return {
|
||||
"apis": apis,
|
||||
"endpoints": endpoints,
|
||||
"roles": roles,
|
||||
"groups": groups,
|
||||
"routings": routings,
|
||||
}
|
||||
|
||||
|
||||
@config_router.get("/config/export/all",
|
||||
description="Export all platform configuration (APIs, Endpoints, Roles, Groups, Routings)",
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def export_all(request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if not await platform_role_required_bool(username, 'manage_gateway'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG001", error_message="Insufficient permissions").dict(), "rest")
|
||||
data = _export_all()
|
||||
return process_response(ResponseModel(status_code=200, response_headers={"request_id": request_id}, response=data).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | export_all error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
finally:
|
||||
logger.info(f"{request_id} | export_all took {time.time()*1000 - start:.2f}ms")
|
||||
|
||||
|
||||
@config_router.get("/config/export/apis",
|
||||
description="Export APIs (optionally a single API with its endpoints)",
|
||||
response_model=ResponseModel)
|
||||
async def export_apis(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if not await platform_role_required_bool(username, 'manage_apis'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG002", error_message="Insufficient permissions").dict(), "rest")
|
||||
if api_name and api_version:
|
||||
api = api_collection.find_one({"api_name": api_name, "api_version": api_version})
|
||||
if not api:
|
||||
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="API not found").dict(), "rest")
|
||||
aid = api.get('api_id')
|
||||
eps = endpoint_collection.find({"api_name": api_name, "api_version": api_version}).to_list(length=None)
|
||||
return process_response(ResponseModel(status_code=200, response={
|
||||
"api": _strip_id(api),
|
||||
"endpoints": [_strip_id(e) for e in eps]
|
||||
}).dict(), "rest")
|
||||
apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)]
|
||||
return process_response(ResponseModel(status_code=200, response={"apis": apis}).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | export_apis error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
finally:
|
||||
logger.info(f"{request_id} | export_apis took {time.time()*1000 - start:.2f}ms")
|
||||
|
||||
|
||||
@config_router.get("/config/export/roles", description="Export Roles", response_model=ResponseModel)
|
||||
async def export_roles(request: Request, role_name: Optional[str] = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if not await platform_role_required_bool(username, 'manage_roles'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG003", error_message="Insufficient permissions").dict(), "rest")
|
||||
if role_name:
|
||||
role = role_collection.find_one({"role_name": role_name})
|
||||
if not role:
|
||||
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Role not found").dict(), "rest")
|
||||
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)]
|
||||
return process_response(ResponseModel(status_code=200, response={"roles": roles}).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | export_roles error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
|
||||
|
||||
@config_router.get("/config/export/groups", description="Export Groups", response_model=ResponseModel)
|
||||
async def export_groups(request: Request, group_name: Optional[str] = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if not await platform_role_required_bool(username, 'manage_groups'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG004", error_message="Insufficient permissions").dict(), "rest")
|
||||
if group_name:
|
||||
group = group_collection.find_one({"group_name": group_name})
|
||||
if not group:
|
||||
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Group not found").dict(), "rest")
|
||||
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)]
|
||||
return process_response(ResponseModel(status_code=200, response={"groups": groups}).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | export_groups error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
|
||||
|
||||
@config_router.get("/config/export/routings", description="Export Routings", response_model=ResponseModel)
|
||||
async def export_routings(request: Request, client_key: Optional[str] = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if not await platform_role_required_bool(username, 'manage_routings'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG005", error_message="Insufficient permissions").dict(), "rest")
|
||||
if client_key:
|
||||
routing = routing_collection.find_one({"client_key": client_key})
|
||||
if not routing:
|
||||
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Routing not found").dict(), "rest")
|
||||
return process_response(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)]
|
||||
return process_response(ResponseModel(status_code=200, response={"routings": routings}).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | export_routings error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
|
||||
|
||||
@config_router.get("/config/export/endpoints",
|
||||
description="Export endpoints (optionally filter by api_name/api_version)",
|
||||
response_model=ResponseModel)
|
||||
async def export_endpoints(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
# Reuse manage_endpoints permission for endpoint export
|
||||
if not await platform_role_required_bool(username, 'manage_endpoints'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG007", error_message="Insufficient permissions").dict(), "rest")
|
||||
query = {}
|
||||
if api_name:
|
||||
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)]
|
||||
return process_response(ResponseModel(status_code=200, response={"endpoints": eps}).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | export_endpoints error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
|
||||
|
||||
def _upsert_api(doc: Dict[str, Any]) -> None:
|
||||
api_name = doc.get('api_name')
|
||||
api_version = doc.get('api_version')
|
||||
if not api_name or not api_version:
|
||||
return
|
||||
existing = api_collection.find_one({"api_name": api_name, "api_version": api_version})
|
||||
to_set = copy.deepcopy(_strip_id(doc))
|
||||
# Ensure identifiers
|
||||
if existing:
|
||||
if not to_set.get('api_id'):
|
||||
to_set['api_id'] = existing.get('api_id')
|
||||
else:
|
||||
to_set.setdefault('api_id', str(uuid.uuid4()))
|
||||
to_set.setdefault('api_path', f"/{api_name}/{api_version}")
|
||||
if existing:
|
||||
api_collection.update_one({"api_name": api_name, "api_version": api_version}, {"$set": to_set})
|
||||
else:
|
||||
api_collection.insert_one(to_set)
|
||||
|
||||
|
||||
def _upsert_endpoint(doc: Dict[str, Any]) -> None:
|
||||
api_name = doc.get('api_name')
|
||||
api_version = doc.get('api_version')
|
||||
method = doc.get('endpoint_method')
|
||||
uri = doc.get('endpoint_uri')
|
||||
if not (api_name and api_version and method and uri):
|
||||
return
|
||||
# Ensure endpoint_id and api_id
|
||||
api_doc = api_collection.find_one({"api_name": api_name, "api_version": api_version})
|
||||
to_set = copy.deepcopy(_strip_id(doc))
|
||||
if api_doc:
|
||||
to_set['api_id'] = api_doc.get('api_id')
|
||||
to_set.setdefault('endpoint_id', str(uuid.uuid4()))
|
||||
existing = endpoint_collection.find_one({
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': method,
|
||||
'endpoint_uri': uri,
|
||||
})
|
||||
if existing:
|
||||
endpoint_collection.update_one({
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': method,
|
||||
'endpoint_uri': uri,
|
||||
}, {"$set": to_set})
|
||||
else:
|
||||
endpoint_collection.insert_one(to_set)
|
||||
|
||||
|
||||
def _upsert_role(doc: Dict[str, Any]) -> None:
|
||||
name = doc.get('role_name')
|
||||
if not name:
|
||||
return
|
||||
to_set = copy.deepcopy(_strip_id(doc))
|
||||
existing = role_collection.find_one({'role_name': name})
|
||||
if existing:
|
||||
role_collection.update_one({'role_name': name}, {"$set": to_set})
|
||||
else:
|
||||
role_collection.insert_one(to_set)
|
||||
|
||||
|
||||
def _upsert_group(doc: Dict[str, Any]) -> None:
|
||||
name = doc.get('group_name')
|
||||
if not name:
|
||||
return
|
||||
to_set = copy.deepcopy(_strip_id(doc))
|
||||
existing = group_collection.find_one({'group_name': name})
|
||||
if existing:
|
||||
group_collection.update_one({'group_name': name}, {"$set": to_set})
|
||||
else:
|
||||
group_collection.insert_one(to_set)
|
||||
|
||||
|
||||
def _upsert_routing(doc: Dict[str, Any]) -> None:
|
||||
key = doc.get('client_key')
|
||||
if not key:
|
||||
return
|
||||
to_set = copy.deepcopy(_strip_id(doc))
|
||||
existing = routing_collection.find_one({'client_key': key})
|
||||
if existing:
|
||||
routing_collection.update_one({'client_key': key}, {"$set": to_set})
|
||||
else:
|
||||
routing_collection.insert_one(to_set)
|
||||
|
||||
|
||||
@config_router.post("/config/import",
|
||||
description="Import platform configuration (any subset of apis, endpoints, roles, groups, routings)",
|
||||
response_model=ResponseModel)
|
||||
async def import_all(request: Request, body: Dict[str, Any]):
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
# Require broad gateway permission for bulk import
|
||||
if not await platform_role_required_bool(username, 'manage_gateway'):
|
||||
return process_response(ResponseModel(status_code=403, error_code="CFG006", error_message="Insufficient permissions").dict(), "rest")
|
||||
counts = {"apis": 0, "endpoints": 0, "roles": 0, "groups": 0, "routings": 0}
|
||||
for api in body.get('apis', []) or []:
|
||||
_upsert_api(api); counts['apis'] += 1
|
||||
for ep in body.get('endpoints', []) or []:
|
||||
_upsert_endpoint(ep); counts['endpoints'] += 1
|
||||
for r in body.get('roles', []) or []:
|
||||
_upsert_role(r); counts['roles'] += 1
|
||||
for g in body.get('groups', []) or []:
|
||||
_upsert_group(g); counts['groups'] += 1
|
||||
for rt in body.get('routings', []) or []:
|
||||
_upsert_routing(rt); counts['routings'] += 1
|
||||
# Invalidate caches so changes take immediate effect
|
||||
try:
|
||||
doorman_cache.clear_all_caches()
|
||||
except Exception:
|
||||
pass
|
||||
return process_response(ResponseModel(status_code=200, response={"imported": counts}).dict(), "rest")
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | import_all error: {e}")
|
||||
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
|
||||
finally:
|
||||
logger.info(f"{request_id} | import_all took {time.time()*1000 - start:.2f}ms")
|
||||
|
||||
@@ -139,7 +139,7 @@ async def restart_gateway(request: Request):
|
||||
status_code=409,
|
||||
response_headers={"request_id": request_id},
|
||||
error_code="SEC004",
|
||||
error_message="Restart not supported: no PID file found (run using 'doorman start' or use your orchestrator to restart)"
|
||||
error_message="Restart not supported: no PID file found (run using 'doorman start' or contact your admin)"
|
||||
).dict(), "rest")
|
||||
# Spawn a detached helper to perform restart so this request can return 202
|
||||
doorman_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'doorman.py'))
|
||||
|
||||
108
web-client/public/docs/using-fields.html
Normal file
108
web-client/public/docs/using-fields.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Doorman Field Guide</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,"Noto Sans",sans-serif;line-height:1.5;margin:0;padding:0;color:#1f2937;background:#fff}
|
||||
header{background:#111827;color:#e5e7eb;padding:16px 24px}
|
||||
main{max-width:920px;margin:24px auto;padding:0 16px}
|
||||
h1{font-size:24px;margin:0}
|
||||
h2{margin-top:28px;border-bottom:1px solid #e5e7eb;padding-bottom:6px}
|
||||
h3{margin-top:20px}
|
||||
code{background:#f3f4f6;padding:2px 4px;border-radius:4px}
|
||||
pre{background:#0b1020;color:#e5e7eb;padding:12px;border-radius:8px;overflow:auto}
|
||||
.tip{background:#ecfeff;border:1px solid #a5f3fc;color:#155e75;padding:10px;border-radius:8px}
|
||||
.warn{background:#fef3c7;border:1px solid #fde68a;color:#7c2d12;padding:10px;border-radius:8px}
|
||||
a{color:#2563eb}
|
||||
ul{margin-left:20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Doorman Field Guide</h1></header>
|
||||
<main>
|
||||
<p>This guide explains sensitive fields and common configurations with examples.</p>
|
||||
|
||||
<h2 id="apis">APIs</h2>
|
||||
<p><strong>API Name/Version</strong> define the base path clients call: <code>/api/rest/<name>/<version>/...</code>.</p>
|
||||
<div class="tip">Example: name <code>users</code>, version <code>v1</code> → client calls <code>/api/rest/users/v1/list</code></div>
|
||||
|
||||
<h3 id="api-config">Configuration</h3>
|
||||
<ul>
|
||||
<li><strong>Credits Enabled</strong>: deducts credits before proxying; configure a <em>Credit Group</em> that injects an API key header.</li>
|
||||
<li><strong>Authorization Field Swap</strong>: maps inbound <code>Authorization</code> into a custom upstream header (e.g., <code>X-Api-Key</code>).</li>
|
||||
<li><strong>Allowed Headers</strong>: restrict which upstream response headers are forwarded back (use lowercase names).</li>
|
||||
</ul>
|
||||
<pre>
|
||||
# Example curl
|
||||
curl -H "Authorization: Bearer ..." \
|
||||
http://localhost:5001/api/rest/users/v1/list
|
||||
</pre>
|
||||
|
||||
<h3 id="servers">Servers</h3>
|
||||
<p>Add one or more upstream base URLs (scheme + host + port). Endpoint-level servers can override. Selection defaults to round-robin.</p>
|
||||
|
||||
<h3 id="access-control">Access Control</h3>
|
||||
<p>Access requires BOTH an allowed <em>role</em> and membership in ANY allowed <em>group</em>.</p>
|
||||
|
||||
<h2 id="endpoints">Endpoints</h2>
|
||||
<p>Define <strong>Method</strong> and <strong>URI</strong> relative to the API base. Use <code>{param}</code> syntax for path variables.</p>
|
||||
<div class="tip">Example: <code>GET /items/{id}</code> matches <code>/api/rest/users/v1/items/123</code></div>
|
||||
<p>Enable <strong>Endpoint Servers</strong> to override API servers for this endpoint only.</p>
|
||||
|
||||
<h2 id="routing">Routing</h2>
|
||||
<p>Create named routing sets with an ordered list of upstreams. Doorman may choose an upstream based on client key, method, and policies.</p>
|
||||
|
||||
<h2 id="users">Users</h2>
|
||||
<ul>
|
||||
<li><strong>Password</strong>: minimum 16 chars with upper/lower/digit/symbol.</li>
|
||||
<li><strong>Role</strong>: determines platform permissions (e.g., manage_apis, view_logs).</li>
|
||||
<li><strong>UI Access</strong>: controls login to admin UI; API access is independent.</li>
|
||||
<li><strong>Groups</strong>: used in API group checks (see Access Control).</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="rate-limit">Rate Limiting</h3>
|
||||
<p>Limits requests per user over a time window (e.g., 100 per minute). Exceeding limits returns 429.</p>
|
||||
|
||||
<h3 id="throttle">Throttling</h3>
|
||||
<p>Controls burst behavior with <em>duration</em>, <em>wait</em>, and optional <em>queue size</em>.</p>
|
||||
<ul>
|
||||
<li><strong>Throttle Duration</strong>: period length before reset.</li>
|
||||
<li><strong>Wait Duration</strong>: how long requests wait when throttled before retry.</li>
|
||||
<li><strong>Queue Limit</strong>: max queued requests; null disables queuing.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="credits">Credits</h2>
|
||||
<p>Credit definitions specify a <strong>credit group</strong>, an API key header name, a default API key value, and one or more tiers.</p>
|
||||
<ul>
|
||||
<li><strong>API Credit Group</strong>: reference name used by APIs to deduct credits and inject keys.</li>
|
||||
<li><strong>API Key Header</strong>: header name injected when proxying (e.g., <code>x-api-key</code>).</li>
|
||||
<li><strong>API Key</strong>: default key used when proxying; users can also have per-user keys.</li>
|
||||
<li><strong>Tiers</strong>: JSON array with <code>tier_name</code>, <code>credits</code>, <code>input_limit</code>, <code>output_limit</code>, and <code>reset_frequency</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="security">Security</h2>
|
||||
<p><strong>Auto-save</strong> writes encrypted memory dumps periodically (requires <code>MEM_ENCRYPTION_KEY</code>). <strong>Dump Path</strong> stores the file; use an encrypted volume.
|
||||
<div class="warn">Restart is required to apply server TLS changes when running with built-in HTTPS. Use an edge proxy for zero-downtime rotation.</div>
|
||||
|
||||
<h2 id="auth-admin">Auth Admin</h2>
|
||||
<p>Check status, revoke tokens, and enable/disable users. Revocation immediately invalidates tokens; enable/disable toggles login capability.</p>
|
||||
|
||||
<h2 id="examples">Examples</h2>
|
||||
<pre>
|
||||
# REST call through Doorman
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
-H "client-key: demo-client" \
|
||||
http://localhost:5001/api/rest/users/v1/items/42
|
||||
|
||||
# GraphQL
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
-H "X-API-Version: v1" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ hello }"}' \
|
||||
http://localhost:5001/api/graphql/example
|
||||
</pre>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
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'
|
||||
|
||||
@@ -100,6 +102,9 @@ export default function AddEndpointPage() {
|
||||
|
||||
<div className="card max-w-3xl">
|
||||
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2 -mt-2 mb-2">
|
||||
<FormHelp docHref="/docs/using-fields.html#endpoints">Define method and URI; optional upstream override per endpoint.</FormHelp>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Method</label>
|
||||
<select className="input" value={method} onChange={e => setMethod(e.target.value)}>
|
||||
@@ -107,7 +112,10 @@ export default function AddEndpointPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">URI</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
URI
|
||||
<InfoTooltip text="Path pattern relative to the API base. Use {param} for path variables. Example: /items/{id}" />
|
||||
</label>
|
||||
<input className="input" value={uri} onChange={e => setUri(e.target.value)} placeholder="/path/{id}" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
@@ -118,7 +126,10 @@ export default function AddEndpointPage() {
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input id="use-override" type="checkbox" className="h-4 w-4" checked={useOverride} onChange={(e)=>setUseOverride(e.target.checked)} />
|
||||
<label htmlFor="use-override" className="text-sm">Use endpoint servers (override API servers)</label>
|
||||
<label htmlFor="use-override" className="text-sm">
|
||||
Use endpoint servers (override API servers)
|
||||
<InfoTooltip text="Provide endpoint-specific upstreams. If disabled or empty, the API-level servers are used." />
|
||||
</label>
|
||||
</div>
|
||||
<div className={`flex gap-2 ${useOverride ? '' : 'opacity-60'}`}>
|
||||
<input
|
||||
|
||||
@@ -740,10 +740,11 @@ const ApiDetailPage = () => {
|
||||
</div>
|
||||
|
||||
{api.api_credits_enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credit Group
|
||||
</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credit Group
|
||||
<InfoTooltip text="Configured credit group name used to deduct and inject keys." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
@@ -761,6 +762,7 @@ const ApiDetailPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Authorization Field Swap
|
||||
<InfoTooltip text="Map Authorization to a different header (e.g., X-Api-Key)." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
@@ -778,6 +780,7 @@ const ApiDetailPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Use Protobuf
|
||||
<InfoTooltip text="Frontend preference; enables proto-aware UI only." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
@@ -902,8 +905,9 @@ const ApiDetailPage = () => {
|
||||
|
||||
{/* Allowed Groups */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Allowed Groups</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">User must belong to any listed group.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
@@ -951,6 +955,7 @@ const ApiDetailPage = () => {
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Servers</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Used when no client routing or endpoint override is configured</p>
|
||||
<FormHelp docHref="/docs/using-fields.html#servers">Add base upstreams; include scheme and port.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
@@ -1067,8 +1072,9 @@ const ApiDetailPage = () => {
|
||||
|
||||
{/* Allowed Headers */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Allowed Headers</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#header-forwarding">Forward only selected upstream response headers.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
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'
|
||||
|
||||
@@ -153,8 +155,9 @@ const AddApiPage = () => {
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#apis">Fill API name/version; these form the base path clients call.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -194,7 +197,7 @@ const AddApiPage = () => {
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
API version (e.g., v1, v2, beta)
|
||||
API version (e.g., v1, v2)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,12 +246,16 @@ const AddApiPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Configuration</h3></div>
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Configuration</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#api-config">Set credits, auth header mapping, and validations.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credits Enabled
|
||||
<InfoTooltip text="When enabled, each request to this API deducts credits from the caller's assigned credit group before forwarding upstream." />
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@@ -269,6 +276,7 @@ const AddApiPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credit Group
|
||||
<InfoTooltip text="Name of a configured credit group (e.g., ai-basic). Determines where to deduct and which API key header to inject." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -283,16 +291,25 @@ const AddApiPage = () => {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authorization Field Swap</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Authorization Field Swap
|
||||
<InfoTooltip text="Map inbound Authorization header into a different header name expected by the upstream service. Example: X-Api-Key." />
|
||||
</label>
|
||||
<input type="text" name="api_authorization_field_swap" className="input" placeholder="backend-auth-header" value={formData.api_authorization_field_swap} onChange={handleChange} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Servers</h3></div>
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Servers</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#servers">Add one or more upstream base URLs used for proxying.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Servers</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Servers
|
||||
<InfoTooltip text="Base URLs for upstreams. Include scheme and port. Example: http://localhost:8080" />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" className="input flex-1" placeholder="e.g., http://localhost:8080" value={newServer} onChange={(e) => setNewServer(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addServer()} disabled={loading} />
|
||||
<button type="button" onClick={addServer} className="btn btn-secondary" disabled={loading}>Add</button>
|
||||
@@ -317,11 +334,15 @@ const AddApiPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Allowed Roles</h3></div>
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Allowed Roles</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Grant access by platform roles and groups.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Roles
|
||||
<InfoTooltip text="Only users with any of these platform roles can access this API." />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -351,6 +372,7 @@ const AddApiPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Use Protobuf
|
||||
<InfoTooltip text="Frontend preference enabling proto-aware features (e.g., proto editor). Does not affect gateway behavior." />
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@@ -372,11 +394,15 @@ const AddApiPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Allowed Groups</h3></div>
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Allowed Groups</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Restrict by user groups; use ALL to allow any group.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Groups
|
||||
<InfoTooltip text="User must belong to any of these groups to access this API." />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -407,10 +433,16 @@ const AddApiPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Allowed Headers</h3></div>
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Allowed Headers</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#header-forwarding">Choose which upstream response headers are forwarded.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Allowed Headers</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Headers
|
||||
<InfoTooltip text="Response headers from upstream that Doorman may forward back to the client. Use lowercase names; examples: x-rate-limit, retry-after." />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" className="input flex-1" placeholder="e.g., Authorization" value={newHeader} onChange={(e) => setNewHeader(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addHeader()} disabled={loading} />
|
||||
<button type="button" onClick={addHeader} className="btn btn-secondary" disabled={loading}>Add</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { getJson } from '@/utils/api'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
@@ -86,6 +87,7 @@ export default function AuthAdminPage() {
|
||||
|
||||
<div className="card">
|
||||
<div className="p-6 space-y-4">
|
||||
<FormHelp docHref="/docs/using-fields.html#auth-admin">Look up user status, revoke tokens, and enable/disable accounts.</FormHelp>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Username</label>
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { getJson, putJson, delJson } from '@/utils/api'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
@@ -79,23 +81,24 @@ export default function EditCreditDefPage() {
|
||||
|
||||
<div className="card">
|
||||
<div className="p-6 space-y-4">
|
||||
<FormHelp docHref="/docs/using-fields.html#credits">Edit key header, optional key, and tiers. Changes apply immediately.</FormHelp>
|
||||
{error && <div className="text-sm text-error-600">{error}</div>}
|
||||
{success && <div className="text-sm text-success-600">{success}</div>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">API Credit Group</label>
|
||||
<label className="block text-sm font-medium">API Credit Group <InfoTooltip text="Immutable group name used by APIs" /></label>
|
||||
<input className="input" value={api_credit_group} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">API Key Header</label>
|
||||
<label className="block text-sm font-medium">API Key Header <InfoTooltip text="Header name injected when proxying (e.g., x-api-key)" /></label>
|
||||
<input className="input" value={api_key_header} onChange={e => setHeader(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium">API Key (leave blank to keep existing)</label>
|
||||
<label className="block text-sm font-medium">API Key (leave blank to keep existing) <InfoTooltip text="Optional default key value; leave empty to keep current" /></label>
|
||||
<input className="input" value={api_key} onChange={e => setKey(e.target.value)} placeholder="sk_live_..." />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium">Credit Tiers (JSON)</label>
|
||||
<label className="block text-sm font-medium">Credit Tiers (JSON) <InfoTooltip text="Array of tiers with tier_name, credits, input_limit, output_limit, reset_frequency" /></label>
|
||||
<textarea className="input font-mono text-xs h-48" value={creditTiersText} onChange={e => setTiersText(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
@@ -44,23 +46,24 @@ export default function AddCreditDefPage() {
|
||||
|
||||
<div className="card">
|
||||
<div className="p-6 space-y-4">
|
||||
<FormHelp docHref="/docs/using-fields.html#credits">Define a credit group, key header, optional key, and tiers.</FormHelp>
|
||||
{error && <div className="text-sm text-error-600">{error}</div>}
|
||||
{success && <div className="text-sm text-success-600">{success}</div>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">API Credit Group</label>
|
||||
<label className="block text-sm font-medium">API Credit Group <InfoTooltip text="Reference name used by APIs to deduct credits and inject keys." /></label>
|
||||
<input className="input" value={api_credit_group} onChange={e => setGroup(e.target.value)} placeholder="ai-basic" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">API Key Header</label>
|
||||
<label className="block text-sm font-medium">API Key Header <InfoTooltip text="Header name injected when proxying requests (e.g., x-api-key)." /></label>
|
||||
<input className="input" value={api_key_header} onChange={e => setHeader(e.target.value)} placeholder="x-api-key" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium">API Key</label>
|
||||
<label className="block text-sm font-medium">API Key <InfoTooltip text="Default key used when proxying; users can also have per-user keys." /></label>
|
||||
<input className="input" value={api_key} onChange={e => setKey(e.target.value)} placeholder="sk_live_..." />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium">Credit Tiers (JSON)</label>
|
||||
<label className="block text-sm font-medium">Credit Tiers (JSON) <InfoTooltip text="Array of tiers with tier_name, credits, input_limit, output_limit, reset_frequency." /></label>
|
||||
<textarea className="input font-mono text-xs h-48" value={creditTiersText} onChange={e => setTiersText(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
@@ -20,6 +20,10 @@ export default function UserCreditsDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [addGroupName, setAddGroupName] = useState('')
|
||||
const [addGroupTier, setAddGroupTier] = useState('')
|
||||
const [addGroupCredits, setAddGroupCredits] = useState<number | ''>('')
|
||||
const [addResetDate, setAddResetDate] = useState('')
|
||||
|
||||
const loadDefs = async () => {
|
||||
try {
|
||||
@@ -68,20 +72,63 @@ export default function UserCreditsDetailPage() {
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const availableGroupsToAdd = useMemo(() => {
|
||||
const current = new Set(Object.keys(userCredits || {}))
|
||||
return Object.keys(defs || {}).filter(g => !current.has(g))
|
||||
}, [defs, userCredits])
|
||||
|
||||
// Inline validation: available must be within [0, total]
|
||||
const invalidGroups = useMemo(() => {
|
||||
const bad: string[] = []
|
||||
for (const [group, info] of Object.entries(userCredits || {})) {
|
||||
const tier = (info as any).tier_name
|
||||
const total = defs[group]?.[tier || '']?.credits || 0
|
||||
const available = Number((info as any).available_credits || 0)
|
||||
if (available < 0 || (total > 0 && available > total)) {
|
||||
bad.push(group)
|
||||
}
|
||||
}
|
||||
return bad
|
||||
}, [userCredits, defs])
|
||||
|
||||
const addGroupToUser = () => {
|
||||
if (!addGroupName) return
|
||||
const tier = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
|
||||
const total = defs[addGroupName]?.[tier || '']?.credits || 0
|
||||
let avail = typeof addGroupCredits === 'number' ? addGroupCredits : total
|
||||
if (avail < 0) avail = 0
|
||||
if (total > 0 && avail > total) avail = total
|
||||
setUserCredits(prev => ({
|
||||
...prev,
|
||||
[addGroupName]: {
|
||||
tier_name: tier,
|
||||
available_credits: avail,
|
||||
reset_date: addResetDate || '',
|
||||
}
|
||||
}))
|
||||
setAddGroupName(''); setAddGroupTier(''); setAddGroupCredits(''); setAddResetDate('')
|
||||
}
|
||||
|
||||
const removeGroupFromUser = (group: string) => {
|
||||
const next = { ...userCredits }
|
||||
delete next[group]
|
||||
setUserCredits(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredPermission="manage_credits">
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Credits for {uname}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">View and edit per-group credit allocations</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Credits for {uname}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">View and edit per-group credit allocations</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => router.push('/credits')} className="btn btn-secondary">Back to Credits</button>
|
||||
<button onClick={save} disabled={saving} className="btn btn-primary">{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button onClick={save} disabled={saving || invalidGroups.length > 0} className="btn btn-primary">{saving ? 'Saving…' : (invalidGroups.length>0 ? 'Fix Errors to Save' : 'Save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md bg-error-50 border border-error-200 p-3 text-error-700 text-sm">{error}</div>}
|
||||
{success && <div className="rounded-md bg-success-50 border border-success-200 p-3 text-success-700 text-sm">{success}</div>}
|
||||
@@ -89,69 +136,142 @@ export default function UserCreditsDetailPage() {
|
||||
{loading ? (
|
||||
<div className="card"><div className="p-6 text-gray-500">Loading…</div></div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="p-6 space-y-3">
|
||||
{Object.entries(userCredits).length === 0 && (
|
||||
<div className="text-gray-500">No credit groups for this user</div>
|
||||
)}
|
||||
{Object.entries(userCredits).map(([group, info]) => {
|
||||
const tierMap = defs[group] || {}
|
||||
const meta = tierMap[(info as any).tier_name] || { credits: 0, reset_frequency: undefined }
|
||||
const total = meta.credits || 0
|
||||
const available = Number((info as any).available_credits || 0)
|
||||
const used = total > 0 ? Math.max(total - available, 0) : 0
|
||||
return (
|
||||
<div key={group} className="grid grid-cols-1 md:grid-cols-12 items-center gap-2">
|
||||
<div className="md:col-span-2">
|
||||
<span className="badge badge-gray w-full justify-center">{group}</span>
|
||||
</div>
|
||||
<div className="md:col-span-2 text-sm">
|
||||
<div className="text-gray-600">Tier</div>
|
||||
<div className="font-medium">{(info as any).tier_name || '-'}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2 text-sm">
|
||||
<div className="text-gray-600">Total</div>
|
||||
<div className="font-medium">{total || 0}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2 text-sm">
|
||||
<div className="text-gray-600">Used</div>
|
||||
<div className="font-medium">{used}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2 text-sm">
|
||||
<div className="text-gray-600">Left</div>
|
||||
<div className="font-medium">{available}</div>
|
||||
</div>
|
||||
<div className="md:col-span-1 text-sm">
|
||||
<div className="text-gray-600">Reset Freq</div>
|
||||
<div className="font-medium">{meta.reset_frequency || '-'}</div>
|
||||
</div>
|
||||
<div className="md:col-span-1 text-sm">
|
||||
<div className="text-gray-600">Reset Date</div>
|
||||
<div className="font-medium">{(info as any).reset_date || '-'}</div>
|
||||
</div>
|
||||
<div className="md:col-span-12 flex items-center gap-2">
|
||||
<input
|
||||
className="input w-32"
|
||||
type="number"
|
||||
value={(info as any).available_credits}
|
||||
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Number(e.target.value || 0) } }))}
|
||||
/>
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="user API key (optional)"
|
||||
value={(info as any).user_api_key || ''}
|
||||
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], user_api_key: e.target.value } }))}
|
||||
/>
|
||||
<>
|
||||
{/* Add credit group */}
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Add Credit Group</h3></div>
|
||||
<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</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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tier</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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Available Credits</label>
|
||||
<input className="input" type="number" value={addGroupCredits as any}
|
||||
onChange={e => setAddGroupCredits(e.target.value === '' ? '' : Number(e.target.value))} placeholder="auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tier Total</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{addGroupName && (defs[addGroupName]?.[addGroupTier || Object.keys(defs[addGroupName]||{})[0]]?.credits || 0)}
|
||||
</div>
|
||||
<button className="btn btn-secondary" disabled={!addGroupName} onClick={() => {
|
||||
const t = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
|
||||
const total = defs[addGroupName]?.[t || '']?.credits || 0
|
||||
setAddGroupCredits(total)
|
||||
}}>Use Full</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{addGroupName && typeof addGroupCredits === 'number' && (() => {
|
||||
const t = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
|
||||
const total = defs[addGroupName]?.[t || '']?.credits || 0
|
||||
const bad = addGroupCredits < 0 || (total > 0 && addGroupCredits > total)
|
||||
return bad ? <div className="text-xs text-error-600 mt-1">Must be between 0 and {total}</div> : null
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Reset Date</label>
|
||||
<input className="input" type="date" value={addResetDate} onChange={e => setAddResetDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-primary w-full" onClick={addGroupToUser} disabled={!addGroupName || (()=>{
|
||||
if (addGroupName && typeof addGroupCredits === 'number'){
|
||||
const t = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
|
||||
const total = defs[addGroupName]?.[t || '']?.credits || 0
|
||||
return addGroupCredits < 0 || (total > 0 && addGroupCredits > total)
|
||||
}
|
||||
return false
|
||||
})()}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing allocations */}
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Allocations</h3></div>
|
||||
<div className="p-6 space-y-4">
|
||||
{Object.entries(userCredits).length === 0 && (
|
||||
<div className="text-gray-500">No credit groups for this user</div>
|
||||
)}
|
||||
{Object.entries(userCredits).map(([group, info]) => {
|
||||
const tierMap = defs[group] || {}
|
||||
const currentTier = (info as any).tier_name
|
||||
const meta = tierMap[currentTier] || { credits: 0, reset_frequency: undefined }
|
||||
const total = meta.credits || 0
|
||||
const available = Number((info as any).available_credits || 0)
|
||||
const used = total > 0 ? Math.max(total - available, 0) : 0
|
||||
const pct = total > 0 ? Math.min(100, Math.max(0, Math.round((used / total) * 100))) : 0
|
||||
return (
|
||||
<div key={group} className="border rounded-lg p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge badge-gray">{group}</span>
|
||||
<div className="text-xs text-gray-500">Reset: {meta.reset_frequency || '-'} | Date: {(info as any).reset_date || '-'}</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost text-error-600" onClick={() => removeGroupFromUser(group)}>Remove</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-800 rounded">
|
||||
<div className="h-2 bg-primary-500 rounded" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400">{used} used of {total} · {available} left</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tier</label>
|
||||
<select className="input" value={(info as any).tier_name || ''}
|
||||
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], tier_name: e.target.value } }))}>
|
||||
{Object.keys(tierMap).map(t => (<option key={t} value={t}>{t}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Available Credits</label>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input className={`input flex-1 ${invalidGroups.includes(group)?'border-error-500':''}`} type="number" value={(info as any).available_credits}
|
||||
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Number(e.target.value || 0) } }))} />
|
||||
<button className="btn btn-secondary" onClick={() => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Number((prev as any)[group].available_credits || 0) + 10 } }))}>+10</button>
|
||||
<button className="btn btn-secondary" onClick={() => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Math.max(0, Number((prev as any)[group].available_credits || 0) - 10) } }))}>-10</button>
|
||||
<button className="btn btn-secondary" onClick={() => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: total } }))}>Full</button>
|
||||
</div>
|
||||
{invalidGroups.includes(group) && (
|
||||
<div className="text-xs text-error-600 mt-1">Must be between 0 and {total}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Reset Date</label>
|
||||
<input className="input" type="date" value={(info as any).reset_date || ''}
|
||||
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], reset_date: e.target.value } }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">User API Key (optional)</label>
|
||||
<input className="input" placeholder="user API key"
|
||||
value={(info as any).user_api_key || ''}
|
||||
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], user_api_key: e.target.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import ConfirmModal from '@/components/ConfirmModal'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { fetchJson } from '@/utils/http'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
|
||||
@@ -287,13 +289,15 @@ const GroupDetailPage = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Groups gate API access alongside roles.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Group Name
|
||||
<InfoTooltip text="Unique name for the group. Users can belong to multiple groups." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
@@ -329,8 +333,9 @@ const GroupDetailPage = () => {
|
||||
|
||||
{/* API Access */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">API Access</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Grant access to API name/version pairs (e.g., users/v1).</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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'
|
||||
|
||||
@@ -101,9 +103,11 @@ const AddGroupPage = () => {
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Groups gate API access alongside roles. Use clear names.</FormHelp>
|
||||
<div>
|
||||
<label htmlFor="group_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Group Name *
|
||||
<InfoTooltip text="Unique name for the group. Users can belong to multiple groups." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -140,6 +144,7 @@ const AddGroupPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Access
|
||||
<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">
|
||||
|
||||
163
web-client/src/app/import-export/page.tsx
Normal file
163
web-client/src/app/import-export/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { postJson, delJson } from '@/utils/api'
|
||||
import ConfirmModal from '@/components/ConfirmModal'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
export default function ImportExportPage() {
|
||||
const toast = useToast()
|
||||
const [exportWorking, setExportWorking] = useState<string | null>(null)
|
||||
const [importWorking, setImportWorking] = useState(false)
|
||||
const [conflictQueue, setConflictQueue] = useState<{ api_name: string; api_version: string }[]>([])
|
||||
const [currentConflict, setCurrentConflict] = useState<{ api_name: string; api_version: string } | null>(null)
|
||||
const [pendingImport, setPendingImport] = useState<any>(null)
|
||||
const [showConflictModal, setShowConflictModal] = useState(false)
|
||||
|
||||
const startExport = async (key: string, path: string) => {
|
||||
try {
|
||||
setExportWorking(key)
|
||||
const res = await fetch(`${SERVER_URL}${path}`, { credentials: 'include' })
|
||||
const data = await res.json()
|
||||
const payload = data?.response || data
|
||||
// Ensure no sensitive fields (none are included by server for these types)
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `doorman-${key}-export.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e:any) {
|
||||
toast.error(e?.message || 'Export failed')
|
||||
} finally {
|
||||
setExportWorking(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onImportFile = async (file?: File | null) => {
|
||||
if (!file) return
|
||||
try {
|
||||
setImportWorking(true)
|
||||
const text = await file.text()
|
||||
const obj = JSON.parse(text)
|
||||
// Detect API conflicts only (roles/groups/routings/endpoints are upserted safely)
|
||||
const apis: { api_name: string; api_version: string }[] = (obj?.apis || []).map((a:any) => ({ api_name: a.api_name, api_version: a.api_version }))
|
||||
const conflicts: { api_name: string; api_version: string }[] = []
|
||||
for (const a of apis) {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/platform/api/${encodeURIComponent(a.api_name)}/${encodeURIComponent(a.api_version)}`, { credentials: 'include' })
|
||||
if (res.ok) conflicts.push(a)
|
||||
} catch {}
|
||||
}
|
||||
setPendingImport(obj)
|
||||
if (conflicts.length > 0) {
|
||||
setConflictQueue(conflicts)
|
||||
setCurrentConflict(conflicts[0])
|
||||
setShowConflictModal(true)
|
||||
} else {
|
||||
await postJson(`${SERVER_URL}/platform/config/import`, obj)
|
||||
toast.success('Import completed')
|
||||
}
|
||||
} catch (e:any) {
|
||||
toast.error(e?.message || 'Invalid import file')
|
||||
} finally {
|
||||
setImportWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const advanceConflict = async () => {
|
||||
const queue = [...conflictQueue]
|
||||
queue.shift()
|
||||
if (queue.length === 0) {
|
||||
setShowConflictModal(false)
|
||||
try {
|
||||
if (pendingImport) {
|
||||
await postJson(`${SERVER_URL}/platform/config/import`, pendingImport)
|
||||
toast.success('Import completed')
|
||||
}
|
||||
} catch (e:any) {
|
||||
toast.error(e?.message || 'Import failed')
|
||||
} finally {
|
||||
setPendingImport(null)
|
||||
setCurrentConflict(null)
|
||||
setConflictQueue([])
|
||||
}
|
||||
return
|
||||
}
|
||||
setConflictQueue(queue)
|
||||
setCurrentConflict(queue[0])
|
||||
setShowConflictModal(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Import / Export</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Bulk import or export platform configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Export</h3></div>
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('all', '/platform/config/export/all')}>{exportWorking==='all'?'Exporting…':'Export All'}</button>
|
||||
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('apis', '/platform/config/export/apis')}>{exportWorking==='apis'?'Exporting…':'Export APIs'}</button>
|
||||
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('endpoints', '/platform/config/export/endpoints')}>{exportWorking==='endpoints'?'Exporting…':'Export Endpoints'}</button>
|
||||
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('roles', '/platform/config/export/roles')}>{exportWorking==='roles'?'Exporting…':'Export Roles'}</button>
|
||||
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('groups', '/platform/config/export/groups')}>{exportWorking==='groups'?'Exporting…':'Export Groups'}</button>
|
||||
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('routings', '/platform/config/export/routings')}>{exportWorking==='routings'?'Exporting…':'Export Routings'}</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Exports never include passwords, tokens, or secrets.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Import</h3></div>
|
||||
<div className="p-6 space-y-3">
|
||||
<input type="file" accept="application/json,.json" onChange={(e)=> onImportFile(e.target.files?.[0])} />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Upload a JSON containing any of: apis, endpoints, roles, groups, routings. Conflicting APIs will prompt you to keep system or replace with your upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={showConflictModal}
|
||||
title="API Conflict Detected"
|
||||
message={<div>
|
||||
<p className="mb-2">API already exists: <code className="font-mono">{currentConflict?.api_name}/{currentConflict?.api_version}</code></p>
|
||||
<p className="text-sm">Choose which version to keep:</p>
|
||||
<ul className="list-disc ml-6 mt-2 text-sm">
|
||||
<li><b>Keep System</b>: keep the existing API; skip this API from import.</li>
|
||||
<li><b>Use Upload</b>: permanently delete the existing API, then import the uploaded version.</li>
|
||||
</ul>
|
||||
</div>}
|
||||
confirmLabel="Use Upload (Delete System)"
|
||||
cancelLabel="Keep System"
|
||||
onCancel={async () => {
|
||||
if (!currentConflict || !pendingImport) { setShowConflictModal(false); return }
|
||||
const { api_name, api_version } = currentConflict
|
||||
const next = { ...pendingImport, apis: (pendingImport.apis || []).filter((a:any) => !(a.api_name === api_name && a.api_version === api_version)) }
|
||||
setPendingImport(next)
|
||||
advanceConflict()
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
if (currentConflict) {
|
||||
await delJson(`${SERVER_URL}/platform/api/${encodeURIComponent(currentConflict.api_name)}/${encodeURIComponent(currentConflict.api_version)}`)
|
||||
}
|
||||
} catch (e:any) {
|
||||
toast.error(e?.message || 'Failed to delete existing API')
|
||||
} finally {
|
||||
advanceConflict()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import ConfirmModal from '@/components/ConfirmModal'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { fetchJson } from '@/utils/http'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
|
||||
@@ -279,8 +280,9 @@ const RoleDetailPage = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#roles">Update role name/description used for platform permissions.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
@@ -332,8 +334,9 @@ const RoleDetailPage = () => {
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Permissions</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Grant least-privilege access to platform features.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { postJson } from '@/utils/api'
|
||||
|
||||
@@ -119,6 +120,7 @@ const AddRolePage = () => {
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<FormHelp docHref="/docs/using-fields.html#roles">Define a role and toggle platform permissions. Apply least privilege.</FormHelp>
|
||||
<div>
|
||||
<label htmlFor="role_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Role Name *
|
||||
|
||||
@@ -5,6 +5,8 @@ import ConfirmModal from '@/components/ConfirmModal'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { fetchJson } from '@/utils/http'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
|
||||
@@ -316,8 +318,9 @@ const RoutingDetailPage = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#routing">Update name, description, and fixed server index.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
@@ -366,6 +369,7 @@ const RoutingDetailPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server Index
|
||||
<InfoTooltip text="Optional fixed index into the server list; leave 0 for default selection." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
@@ -384,8 +388,9 @@ const RoutingDetailPage = () => {
|
||||
|
||||
{/* Servers Configuration */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Servers Configuration</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#routing">Ordered upstreams used for this client key.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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'
|
||||
|
||||
@@ -99,6 +101,7 @@ const AddRoutingPage = () => {
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<FormHelp docHref="/docs/using-fields.html#routing">Create a routing set with ordered upstreams and optional fixed index.</FormHelp>
|
||||
<div>
|
||||
<label htmlFor="routing_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Routing Name *
|
||||
@@ -138,6 +141,7 @@ const AddRoutingPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Servers *
|
||||
<InfoTooltip text="Ordered list of upstream base URLs (scheme + host + port). Selection defaults to round-robin." />
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { getJson, postJson, putJson, delJson } from '@/utils/api'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
@@ -293,16 +295,22 @@ const SecurityPage = () => {
|
||||
{/* Settings (always visible) */}
|
||||
<div className="card">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Memory & Security Settings</h3>
|
||||
{memoryOnly && (
|
||||
<span className="badge badge-gray">Memory Mode</span>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Memory & Security Settings</h3>
|
||||
{memoryOnly && (
|
||||
<span className="badge badge-gray">Memory Mode</span>
|
||||
)}
|
||||
</div>
|
||||
<FormHelp docHref="/docs/using-fields.html#security">Configure encrypted memory dumps and clear caches safely.</FormHelp>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className={`block text-sm font-medium ${memoryOnly ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>Enable Auto-save</label>
|
||||
<label className={`block text-sm font-medium ${memoryOnly ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
Enable Auto-save
|
||||
<InfoTooltip text="When enabled, Doorman periodically writes an encrypted memory dump to the configured path. Requires MEM_ENCRYPTION_KEY on the server. In memory-only mode this is always on." />
|
||||
</label>
|
||||
<div className={`flex items-center gap-3 ${memoryOnly ? 'opacity-60 cursor-not-allowed' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -321,7 +329,10 @@ const SecurityPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Auto-save Frequency (seconds)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Auto-save Frequency (seconds)
|
||||
<InfoTooltip text="Minimum 60s. Choose a value that balances RPO vs. IO overhead. Applies only when auto-save is enabled." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={60}
|
||||
@@ -335,7 +346,10 @@ const SecurityPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Dump Path</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Dump Path
|
||||
<InfoTooltip text="Filesystem path for the encrypted memory dump. Store on an encrypted volume if possible. Example: generated/memory_dump.bin" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.dump_path}
|
||||
@@ -356,7 +370,10 @@ const SecurityPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Restore From Path</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Restore From Path
|
||||
<InfoTooltip text="Points to a previously saved encrypted dump file. Requires MEM_ENCRYPTION_KEY to decrypt and restore." />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -122,6 +122,8 @@ const SettingsPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// no-op leftover cleanup (import UI moved to its own page)
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
@@ -327,6 +329,7 @@ const SettingsPage = () => {
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ import ConfirmModal from '@/components/ConfirmModal'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
import FormHelp from '@/components/FormHelp'
|
||||
import { fetchJson } from '@/utils/http'
|
||||
import { PROTECTED_USERS, SERVER_URL } from '@/utils/config'
|
||||
|
||||
@@ -385,8 +387,9 @@ const UserDetailPage = () => {
|
||||
)}
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#users">Update identity, role, status, and UI access.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
@@ -426,6 +429,7 @@ const UserDetailPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Role
|
||||
<InfoTooltip text="Platform role controls permissions (e.g., manage_apis, view_logs)." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
@@ -443,6 +447,7 @@ const UserDetailPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
<InfoTooltip text="Inactive users cannot authenticate until re-enabled." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
@@ -466,6 +471,7 @@ const UserDetailPage = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
UI Access
|
||||
<InfoTooltip text="Controls access to the admin UI; API access is separate." />
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
@@ -490,8 +496,9 @@ const UserDetailPage = () => {
|
||||
|
||||
{/* Groups */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Groups</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Groups are used in API access checks alongside roles.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
@@ -541,8 +548,9 @@ const UserDetailPage = () => {
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Rate Limiting</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#rate-limit">Limits requests per user over a time window.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
@@ -581,8 +589,9 @@ const UserDetailPage = () => {
|
||||
|
||||
{/* Throttling */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Throttling</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#throttle">Control bursts with duration, wait, and queue size.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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'
|
||||
|
||||
@@ -166,7 +168,10 @@ const AddUserPage = () => {
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Basic Information</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Basic Information</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#users">Create user credentials, set role and UI access.</FormHelp>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -203,6 +208,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password *
|
||||
<InfoTooltip text="Minimum 16 chars with upper, lower, digit, and symbol for strong security." />
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -225,6 +231,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Role *
|
||||
<InfoTooltip text="Platform role controls permissions (e.g., manage_apis, view_logs)." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -242,6 +249,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
<InfoTooltip text="Inactive users cannot authenticate until re-enabled." />
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
@@ -268,13 +276,17 @@ const AddUserPage = () => {
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Controls access to the admin UI; API access is separate.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Groups</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Groups</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#access-control">Groups are used for API access checks alongside roles.</FormHelp>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.groups.map((group, index) => (
|
||||
@@ -312,11 +324,15 @@ const AddUserPage = () => {
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Rate Limiting</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Rate Limiting</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#rate-limit">Limits requests per user over a time window.</FormHelp>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="rate_limit_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rate Limit Duration
|
||||
<InfoTooltip text="Numeric window size (e.g., 100) combined with type (minute/hour)." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -332,6 +348,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="rate_limit_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rate Limit Type
|
||||
<InfoTooltip text="Unit for the rate limit window. Example: minute." />
|
||||
</label>
|
||||
<select
|
||||
id="rate_limit_duration_type"
|
||||
@@ -352,11 +369,15 @@ const AddUserPage = () => {
|
||||
|
||||
{/* Throttling */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Throttling</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Throttling</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#throttle">Control burst behavior with wait, duration, and queue size.</FormHelp>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="throttle_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Throttle Duration
|
||||
<InfoTooltip text="How long a throttle period lasts before resetting." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -372,6 +393,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="throttle_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Throttle Type
|
||||
<InfoTooltip text="Unit for the throttle duration period." />
|
||||
</label>
|
||||
<select
|
||||
id="throttle_duration_type"
|
||||
@@ -390,6 +412,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="throttle_wait_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Wait Duration
|
||||
<InfoTooltip text="How long requests wait when throttled before retrying." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -405,6 +428,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="throttle_wait_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Wait Type
|
||||
<InfoTooltip text="Unit for the wait duration (seconds/minutes/etc)." />
|
||||
</label>
|
||||
<select
|
||||
id="throttle_wait_duration_type"
|
||||
@@ -423,6 +447,7 @@ const AddUserPage = () => {
|
||||
<div>
|
||||
<label htmlFor="throttle_queue_limit" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Queue Limit
|
||||
<InfoTooltip text="Maximum number of queued requests when throttled; null disables queueing." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
24
web-client/src/components/FormHelp.tsx
Normal file
24
web-client/src/components/FormHelp.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
docHref: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function FormHelp({ docHref, children, className }: Props) {
|
||||
return (
|
||||
<div className={`text-xs text-gray-600 dark:text-gray-400 flex items-center gap-2 ${className||''}`}>
|
||||
<svg className="h-4 w-4 text-gray-500 dark:text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
|
||||
<path fillRule="evenodd" d="M18 10A8 8 0 11.001 9.999 8 8 0 0118 10zM9 9a1 1 0 112 0v5a1 1 0 11-2 0V9zm1-6a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
<span>{children}</span>
|
||||
<a href={docHref} target="_blank" rel="noreferrer" className="text-primary-600 hover:text-primary-700 dark:text-primary-400 underline">
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
36
web-client/src/components/InfoTooltip.tsx
Normal file
36
web-client/src/components/InfoTooltip.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface InfoTooltipProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function InfoTooltip({ text, className }: InfoTooltipProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<span className={`relative inline-flex items-center ${className || ''}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Help"
|
||||
className="ml-1 inline-flex items-center justify-center h-4 w-4 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none"
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden>{/* info icon */}
|
||||
<path fillRule="evenodd" d="M18 10A8 8 0 11.001 9.999 8 8 0 0118 10zM9 9a1 1 0 112 0v5a1 1 0 11-2 0V9zm1-6a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-2 w-64 p-2 text-xs rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user