mirror of
https://github.com/apidoorman/doorman.git
synced 2026-02-11 12:08:33 -06:00
bug fixes
This commit is contained in:
@@ -144,6 +144,7 @@ async def cors_check(request: Request, body: CorsCheckRequest):
|
||||
"origin": origin,
|
||||
"method": method,
|
||||
"request_headers": requested_headers,
|
||||
"request_headers_normalized": requested_lower,
|
||||
"with_credentials": with_credentials,
|
||||
},
|
||||
"preflight": {
|
||||
|
||||
@@ -24,6 +24,16 @@ class ApiService:
|
||||
Onboard an API to the platform.
|
||||
"""
|
||||
logger.info(request_id + " | Creating API: " + data.api_name + " " + data.api_version)
|
||||
# Prevent unsafe combination: public API with credits enabled
|
||||
try:
|
||||
if getattr(data, 'api_public', False) and getattr(data, 'api_credits_enabled', False):
|
||||
return ResponseModel(
|
||||
status_code=400,
|
||||
error_code='API013',
|
||||
error_message='Public API cannot have credits enabled'
|
||||
).dict()
|
||||
except Exception:
|
||||
pass
|
||||
cache_key = f"{data.api_name}/{data.api_version}"
|
||||
existing = doorman_cache.get_cache('api_cache', cache_key)
|
||||
if not existing:
|
||||
@@ -100,6 +110,18 @@ class ApiService:
|
||||
doorman_cache.delete_cache('api_cache', doorman_cache.get_cache('api_id_cache', f"/{api_name}/{api_version}"))
|
||||
doorman_cache.delete_cache('api_id_cache', f"/{api_name}/{api_version}")
|
||||
not_null_data = {k: v for k, v in data.dict().items() if v is not None}
|
||||
# Validate unsafe combination on the desired state (existing + updates)
|
||||
try:
|
||||
desired_public = bool(not_null_data.get('api_public', api.get('api_public')))
|
||||
desired_credits = bool(not_null_data.get('api_credits_enabled', api.get('api_credits_enabled')))
|
||||
if desired_public and desired_credits:
|
||||
return ResponseModel(
|
||||
status_code=400,
|
||||
error_code='API013',
|
||||
error_message='Public API cannot have credits enabled'
|
||||
).dict()
|
||||
except Exception:
|
||||
pass
|
||||
if not_null_data:
|
||||
update_result = api_collection.update_one(
|
||||
{'api_name': api_name, 'api_version': api_version},
|
||||
|
||||
@@ -125,9 +125,13 @@ class GatewayService:
|
||||
current_time = backend_end_time = None
|
||||
try:
|
||||
if not url and not method:
|
||||
match = re.match(r"([^/]+/v\d+)", path)
|
||||
api_name_version = '/' + match.group(1) if match else ""
|
||||
endpoint_uri = re.sub(r"^[^/]+/v\d+/", "", path)
|
||||
# Parse API name/version without regex to avoid ReDoS and be consistent with preflight
|
||||
parts = [p for p in (path or '').split('/') if p]
|
||||
api_name_version = ''
|
||||
endpoint_uri = ''
|
||||
if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit():
|
||||
api_name_version = f"/{parts[0]}/{parts[1]}"
|
||||
endpoint_uri = '/'.join(parts[2:])
|
||||
api_key = doorman_cache.get_cache('api_id_cache', api_name_version)
|
||||
api = await api_util.get_api(api_key, api_name_version)
|
||||
if not api:
|
||||
@@ -150,8 +154,8 @@ class GatewayService:
|
||||
url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/')
|
||||
method = request.method.upper()
|
||||
retry = api.get('api_allowed_retry_count') or 0
|
||||
# Enforce credits only for non-public APIs
|
||||
if api.get('api_credits_enabled') and not bool(api.get('api_public')):
|
||||
# Enforce credits only for non-public APIs and when we have an authenticated user
|
||||
if api.get('api_credits_enabled') and username and not bool(api.get('api_public')):
|
||||
if not await credit_util.deduct_credit(api.get('api_credit_group'), username):
|
||||
return GatewayService.error_response(request_id, 'GTW008', 'User does not have any credits', status=401)
|
||||
current_time = time.time() * 1000
|
||||
@@ -162,8 +166,8 @@ class GatewayService:
|
||||
ai_token_headers = await credit_util.get_credit_api_header(api.get('api_credit_group'))
|
||||
if ai_token_headers:
|
||||
headers[ai_token_headers[0]] = ai_token_headers[1]
|
||||
# Skip user-specific key injection for public APIs
|
||||
if not bool(api.get('api_public')):
|
||||
# Skip user-specific key injection when public or no authenticated user
|
||||
if username and not bool(api.get('api_public')):
|
||||
user_specific_api_key = await credit_util.get_user_api_key(api.get('api_credit_group'), username)
|
||||
if user_specific_api_key:
|
||||
headers[ai_token_headers[0]] = user_specific_api_key
|
||||
@@ -262,9 +266,13 @@ class GatewayService:
|
||||
current_time = backend_end_time = None
|
||||
try:
|
||||
if not url:
|
||||
match = re.match(r"([^/]+/v\d+)", path)
|
||||
api_name_version = '/' + match.group(1) if match else ""
|
||||
endpoint_uri = re.sub(r"^[^/]+/v\d+/", "", path)
|
||||
# Parse API name/version without regex to avoid ReDoS
|
||||
parts = [p for p in (path or '').split('/') if p]
|
||||
api_name_version = ''
|
||||
endpoint_uri = ''
|
||||
if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit():
|
||||
api_name_version = f"/{parts[0]}/{parts[1]}"
|
||||
endpoint_uri = '/'.join(parts[2:])
|
||||
api_key = doorman_cache.get_cache('api_id_cache', api_name_version)
|
||||
api = await api_util.get_api(api_key, api_name_version)
|
||||
if not api:
|
||||
@@ -286,7 +294,7 @@ class GatewayService:
|
||||
url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/')
|
||||
logger.info(f"{request_id} | SOAP gateway to: {url}")
|
||||
retry = api.get('api_allowed_retry_count') or 0
|
||||
if api.get('api_credits_enabled') and not bool(api.get('api_public')):
|
||||
if api.get('api_credits_enabled') and username and not bool(api.get('api_public')):
|
||||
if not await credit_util.deduct_credit(api, username):
|
||||
return GatewayService.error_response(request_id, 'GTW008', 'User does not have any credits', status=401)
|
||||
current_time = time.time() * 1000
|
||||
@@ -385,7 +393,7 @@ class GatewayService:
|
||||
return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured')
|
||||
url = server.rstrip('/')
|
||||
retry = api.get('api_allowed_retry_count') or 0
|
||||
if api.get('api_credits_enabled') and not bool(api.get('api_public')):
|
||||
if api.get('api_credits_enabled') and username and not bool(api.get('api_public')):
|
||||
if not await credit_util.deduct_credit(api.get('api_credit_group'), username):
|
||||
return GatewayService.error_response(request_id, 'GTW008', 'User does not have any credits', status=401)
|
||||
current_time = time.time() * 1000
|
||||
@@ -397,9 +405,10 @@ class GatewayService:
|
||||
ai_token_headers = await credit_util.get_credit_api_header(api.get('api_credit_group'))
|
||||
if ai_token_headers:
|
||||
headers[ai_token_headers[0]] = ai_token_headers[1]
|
||||
user_specific_api_key = await credit_util.get_user_api_key(api.get('api_credit_group'), username)
|
||||
if user_specific_api_key:
|
||||
headers[ai_token_headers[0]] = user_specific_api_key
|
||||
if username and not bool(api.get('api_public')):
|
||||
user_specific_api_key = await credit_util.get_user_api_key(api.get('api_credit_group'), username)
|
||||
if user_specific_api_key:
|
||||
headers[ai_token_headers[0]] = user_specific_api_key
|
||||
if api.get('api_authorization_field_swap'):
|
||||
headers[api.get('Authorization')] = headers.get(api.get('api_authorization_field_swap'))
|
||||
body = await request.json()
|
||||
|
||||
@@ -64,6 +64,7 @@ async def test_graphql_public_api_allows_unauthenticated(client, authed_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_api_bypasses_credits_check(client, authed_client):
|
||||
name, ver = "pubcredits", "v1"
|
||||
# Creating Public + Credits should be rejected now
|
||||
cr = await authed_client.post(
|
||||
"/platform/api",
|
||||
json={
|
||||
@@ -77,7 +78,23 @@ async def test_public_api_bypasses_credits_check(client, authed_client):
|
||||
"api_credit_group": "any-group",
|
||||
},
|
||||
)
|
||||
assert cr.status_code in (200, 201), cr.text
|
||||
assert cr.status_code == 400
|
||||
body = cr.json()
|
||||
assert (body.get("error_code") or body.get("response", {}).get("error_code")) == "API013"
|
||||
|
||||
# Create instead as public without credits and verify unauthenticated access is not blocked by 401
|
||||
cr2 = await authed_client.post(
|
||||
"/platform/api",
|
||||
json={
|
||||
"api_name": name,
|
||||
"api_version": ver,
|
||||
"api_description": "Public REST",
|
||||
"api_servers": ["http://upstream.invalid"],
|
||||
"api_type": "REST",
|
||||
"api_public": True,
|
||||
},
|
||||
)
|
||||
assert cr2.status_code in (200, 201), cr2.text
|
||||
ce = await authed_client.post(
|
||||
"/platform/endpoint",
|
||||
json={
|
||||
@@ -89,9 +106,7 @@ async def test_public_api_bypasses_credits_check(client, authed_client):
|
||||
},
|
||||
)
|
||||
assert ce.status_code in (200, 201), ce.text
|
||||
|
||||
r = await client.get(f"/api/rest/{name}/{ver}/ping")
|
||||
# Should not 401 due to credits check since API is public
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function ApiEndpointsPage() {
|
||||
const loadEndpoints = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const attempt = async () => {
|
||||
if (!apiName || !apiVersion) {
|
||||
// Fallback: find API by id via listing
|
||||
const data = await getJson<any>(`${SERVER_URL}/platform/api/all`)
|
||||
@@ -219,8 +219,17 @@ export default function ApiEndpointsPage() {
|
||||
}
|
||||
setEndpoints(list)
|
||||
setAllEndpoints(list)
|
||||
}
|
||||
try {
|
||||
await attempt()
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to load endpoints')
|
||||
// Retry once on transient failures
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
await attempt()
|
||||
} catch (err:any) {
|
||||
setError(err?.message || 'Failed to load endpoints')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ const ApiDetailPage = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [publicConfirmOpen, setPublicConfirmOpen] = useState(false)
|
||||
const [pendingPublicValue, setPendingPublicValue] = useState<boolean | null>(null)
|
||||
const [pubCredsConfirmOpen, setPubCredsConfirmOpen] = useState(false)
|
||||
const [pendingPubCredsField, setPendingPubCredsField] = useState<null | { field: 'api_public' | 'api_credits_enabled'; value: boolean }>(null)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const toast = useToast()
|
||||
@@ -342,6 +344,18 @@ const ApiDetailPage = () => {
|
||||
setPublicConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
const currentPublic = (isEditing ? (editData as any)?.api_public : (api as any)?.api_public) ?? false
|
||||
const currentCredits = (isEditing ? (editData as any)?.api_credits_enabled : (api as any)?.api_credits_enabled) ?? false
|
||||
if (field === 'api_public' && value === true && currentCredits) {
|
||||
setPendingPubCredsField({ field: 'api_public', value: true })
|
||||
setPubCredsConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
if (field === 'api_credits_enabled' && value === true && (currentPublic || (field === 'api_public' && value === true))) {
|
||||
setPendingPubCredsField({ field: 'api_credits_enabled', value: true })
|
||||
setPubCredsConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
setEditData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
@@ -658,6 +672,20 @@ const ApiDetailPage = () => {
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{(((isEditing ? (editData as any)?.api_public : (api as any)?.api_public) ?? false) && ((isEditing ? (editData as any)?.api_credits_enabled : (api as any)?.api_credits_enabled) ?? false)) && (
|
||||
<div className="rounded-lg bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||
Public + Credits: Anyone can call this API and the group API key will be injected. Per-user deductions/keys are skipped.
|
||||
</div>
|
||||
)}
|
||||
{(
|
||||
((isEditing ? (editData as any)?.api_public : (api as any)?.api_public) ?? false) === false &&
|
||||
((isEditing ? (editData as any)?.api_auth_required : (api as any)?.api_auth_required) ?? true) === false &&
|
||||
((isEditing ? (editData as any)?.api_credits_enabled : (api as any)?.api_credits_enabled) ?? false) === true
|
||||
) && (
|
||||
<div className="rounded-lg bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||
No Auth + Credits: Unauthenticated requests are allowed and the group API key will be injected. Per-user deductions/keys are skipped. Consider enabling Auth Required or disabling Credits.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Name
|
||||
@@ -782,6 +810,7 @@ const ApiDetailPage = () => {
|
||||
checked={!!(editData as any).api_public}
|
||||
onChange={(e) => handleInputChange('api_public' as any, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
disabled={!!(editData as any).api_credits_enabled}
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Anyone with the URL can call this API</label>
|
||||
</div>
|
||||
@@ -823,6 +852,7 @@ const ApiDetailPage = () => {
|
||||
checked={editData.api_credits_enabled || false}
|
||||
onChange={(e) => handleInputChange('api_credits_enabled', e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
disabled={!!(editData as any).api_public}
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Enable API credits
|
||||
@@ -1252,8 +1282,36 @@ const ApiDetailPage = () => {
|
||||
setPendingPublicValue(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Public + Credits confirmation */}
|
||||
<ConfirmModal
|
||||
open={pubCredsConfirmOpen}
|
||||
title="Public API with Credits?"
|
||||
message={<div>
|
||||
<p className="mb-2">Enabling Credits on a Public API injects the group API key for anyone calling this API.</p>
|
||||
<p className="text-amber-600">User-level deductions/keys are skipped for public/no-auth calls.</p>
|
||||
</div>}
|
||||
confirmLabel="Proceed"
|
||||
onConfirm={() => {
|
||||
setPubCredsConfirmOpen(false)
|
||||
if (pendingPubCredsField) {
|
||||
setEditData(prev => ({ ...prev, [pendingPubCredsField.field]: pendingPubCredsField.value as any }))
|
||||
}
|
||||
setPendingPubCredsField(null)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setPubCredsConfirmOpen(false)
|
||||
setPendingPubCredsField(null)
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
{isEditing && (editData as any).api_credits_enabled && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Credits to change Public status.</p>
|
||||
)}
|
||||
export default ApiDetailPage
|
||||
{isEditing && (editData as any).api_public && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Public to enable Credits.</p>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,8 @@ const AddApiPage = () => {
|
||||
})
|
||||
const [publicConfirmOpen, setPublicConfirmOpen] = useState(false)
|
||||
const [pendingPublicValue, setPendingPublicValue] = useState<boolean | null>(null)
|
||||
const [pubCredsConfirmOpen, setPubCredsConfirmOpen] = useState(false)
|
||||
const [pendingPubCredsField, setPendingPubCredsField] = useState<null | { field: 'api_public' | 'api_credits_enabled'; value: boolean }>(null)
|
||||
const [newServer, setNewServer] = useState('')
|
||||
const [newRole, setNewRole] = useState('')
|
||||
const [newGroup, setNewGroup] = useState('')
|
||||
@@ -80,6 +82,17 @@ const AddApiPage = () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Guard: Public + Credits enabled risk confirmation
|
||||
if (name === 'api_public' && (e.target as HTMLInputElement).checked && (formData as any).api_credits_enabled) {
|
||||
setPendingPubCredsField({ field: 'api_public', value: true })
|
||||
setPubCredsConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
if (name === 'api_credits_enabled' && (e.target as HTMLInputElement).checked && ((formData as any).api_public || pendingPublicValue)) {
|
||||
setPendingPubCredsField({ field: 'api_credits_enabled', value: true })
|
||||
setPubCredsConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : (name === 'api_allowed_retry_count' ? Number(value || 0) : value)
|
||||
@@ -175,6 +188,11 @@ const AddApiPage = () => {
|
||||
<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">
|
||||
{((formData as any).api_public && (formData as any).api_credits_enabled) && (
|
||||
<div className="rounded-lg bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||
Public + Credits: Anyone can call this API and the group API key will be injected. Per-user deductions/keys are skipped.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Public API <InfoTooltip text="Anyone with the URL can call this API. Auth, subscription, and group checks are skipped." /></label>
|
||||
<div className="flex items-center">
|
||||
@@ -185,12 +203,15 @@ const AddApiPage = () => {
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
checked={(formData as any).api_public || false}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
disabled={loading || ((formData as any).api_credits_enabled === true)}
|
||||
/>
|
||||
<label htmlFor="api_public" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Anyone with the URL can call this API
|
||||
</label>
|
||||
</div>
|
||||
{((formData as any).api_credits_enabled === true) && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Credits to change Public status.</p>
|
||||
)}
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">Use with care. Authentication, subscriptions, and group checks are skipped.</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -333,12 +354,15 @@ const AddApiPage = () => {
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
checked={formData.api_credits_enabled}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
disabled={loading || ((formData as any).api_public === true)}
|
||||
/>
|
||||
<label htmlFor="api_credits_enabled" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Enable API credits
|
||||
</label>
|
||||
</div>
|
||||
{((formData as any).api_public === true) && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable Public to enable Credits.</p>
|
||||
)}
|
||||
</div>
|
||||
{formData.api_credits_enabled && (
|
||||
<div>
|
||||
@@ -573,6 +597,28 @@ const AddApiPage = () => {
|
||||
setPendingPublicValue(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Public + Credits confirmation */}
|
||||
<ConfirmModal
|
||||
open={pubCredsConfirmOpen}
|
||||
title="Public API with Credits?"
|
||||
message={<div>
|
||||
<p className="mb-2">Enabling Credits on a Public API injects the group API key for anyone calling this API.</p>
|
||||
<p className="text-amber-600">User-level deductions/keys are skipped for public/no-auth calls.</p>
|
||||
</div>}
|
||||
confirmLabel="Proceed"
|
||||
onConfirm={() => {
|
||||
setPubCredsConfirmOpen(false)
|
||||
if (pendingPubCredsField) {
|
||||
setFormData(prev => ({ ...prev, [pendingPubCredsField.field]: pendingPubCredsField.value as any }))
|
||||
}
|
||||
setPendingPubCredsField(null)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setPubCredsConfirmOpen(false)
|
||||
setPendingPubCredsField(null)
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,8 +105,14 @@ const GroupDetailPage = () => {
|
||||
|
||||
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`, editData)
|
||||
|
||||
// Refresh from server to get the latest canonical data
|
||||
const refreshedGroup = await fetchJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`)
|
||||
// Refresh from server to get the latest canonical data (retry once on transient failure)
|
||||
let refreshedGroup: any
|
||||
try {
|
||||
refreshedGroup = await fetchJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`)
|
||||
} catch (e) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
refreshedGroup = await fetchJson(`${SERVER_URL}/platform/group/${encodeURIComponent(groupName)}`)
|
||||
}
|
||||
setGroup(refreshedGroup)
|
||||
setEditData(refreshedGroup)
|
||||
// Keep sessionStorage in sync for back-navigation
|
||||
|
||||
@@ -111,8 +111,14 @@ const RoleDetailPage = () => {
|
||||
|
||||
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`, editData)
|
||||
|
||||
// Refresh from server to get the latest canonical data
|
||||
const rolePayload = await fetchJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`)
|
||||
// Refresh from server to get the latest canonical data (retry once on transient failure)
|
||||
let rolePayload: any
|
||||
try {
|
||||
rolePayload = await fetchJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`)
|
||||
} catch (e) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
rolePayload = await fetchJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`)
|
||||
}
|
||||
setRole(rolePayload)
|
||||
setEditData(rolePayload)
|
||||
// Keep sessionStorage in sync for back-navigation
|
||||
|
||||
@@ -107,23 +107,16 @@ const RoutingDetailPage = () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`${SERVER_URL}/platform/routing/${clientKey}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
})
|
||||
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/routing/${clientKey}`, editData)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to update routing')
|
||||
// Refresh from server to get the latest canonical data (retry once on transient failure)
|
||||
let refreshedRouting: any
|
||||
try {
|
||||
refreshedRouting = await fetchJson(`${SERVER_URL}/platform/routing/${encodeURIComponent(clientKey)}`)
|
||||
} catch (e) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
refreshedRouting = await fetchJson(`${SERVER_URL}/platform/routing/${encodeURIComponent(clientKey)}`)
|
||||
}
|
||||
|
||||
// Refresh from server to get the latest canonical data
|
||||
const refreshedRouting = await fetchJson(`${SERVER_URL}/platform/routing/${encodeURIComponent(clientKey)}`)
|
||||
setRouting(refreshedRouting)
|
||||
sessionStorage.setItem('selectedRouting', JSON.stringify(refreshedRouting))
|
||||
setIsEditing(false)
|
||||
|
||||
@@ -140,8 +140,14 @@ const UserDetailPage = () => {
|
||||
}
|
||||
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`, editData)
|
||||
|
||||
// Refresh from server to get the latest canonical data
|
||||
const refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
|
||||
// Refresh from server to get the latest canonical data (retry once on transient failure)
|
||||
let refreshedUser: any
|
||||
try {
|
||||
refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
|
||||
} catch (e) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
|
||||
}
|
||||
setUser(refreshedUser)
|
||||
// Keep sessionStorage in sync for back-navigation
|
||||
sessionStorage.setItem('selectedUser', JSON.stringify(refreshedUser))
|
||||
|
||||
@@ -5,37 +5,23 @@ export function getCookie(name: string): string | null {
|
||||
}
|
||||
|
||||
export async function fetchJson<T = any>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const attempt = async (): Promise<T> => {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(init.headers as any)
|
||||
}
|
||||
const csrf = getCookie('csrf_token')
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf
|
||||
|
||||
const resp = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? (data as any).response : data
|
||||
if (!resp.ok) {
|
||||
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
|
||||
throw new Error(msg)
|
||||
}
|
||||
return unwrapped as T
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(init.headers as any)
|
||||
}
|
||||
const csrf = getCookie('csrf_token')
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf
|
||||
|
||||
// Try once; on transient failure for GET, retry quickly once
|
||||
try {
|
||||
return await attempt()
|
||||
} catch (e) {
|
||||
const method = (init.method || 'GET').toString().toUpperCase()
|
||||
if (method === 'GET') {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
return await attempt()
|
||||
}
|
||||
throw e
|
||||
const resp = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? (data as any).response : data
|
||||
if (!resp.ok) {
|
||||
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
|
||||
throw new Error(msg)
|
||||
}
|
||||
return unwrapped as T
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user