fix(amas): show org dialog on connect, humanize errors, skip dashboard prefetch
- Always show "Select ƏMAS Organization" dialog after MyGovID re-auth in
the Employee → Load from AMAS flow. Previously auto-picked the first
cert, which on multi-cert users was the personal cert without rights
to the employee report — caused permission_error.
- humanize_amas_error() translates raw codes ('permission_error',
unknown) into user-facing messages. Permission failures return
permission_error: True so the frontend can offer a Reconnect button
that runs full re-auth + org selection.
- make_amas_request: drop the eager /core.dashboard fetch before every
call. Use the cached CSRF token + cookies (already updated from
Set-Cookie on each response). On real CSRF rejection (HTTP 419 or
response code CSRF/TOKENS_ARE_NOT_SAME/CSRF_TOKEN_MISMATCH), refresh
once and retry. Same simplification in get_amas_accounts.
- _process_bulk_employees_import: commit once after the loop instead of
per row, per CLAUDE.md guidance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6287f30409
commit
dd096dd795
|
|
@ -84,6 +84,35 @@ def parse_cookies_from_response(response):
|
|||
return cookies
|
||||
|
||||
|
||||
AMAS_GENERIC_UNAVAILABLE_MESSAGE = (
|
||||
"ƏMAS service is currently unavailable. Please try again in a few minutes."
|
||||
)
|
||||
|
||||
|
||||
def humanize_amas_error(code, message):
|
||||
"""Translate raw ƏMAS API error codes/messages into a user-friendly response dict.
|
||||
|
||||
ƏMAS returns codes like 'PERMISSION_ERROR' which are useless for end users.
|
||||
Returns a dict that callers can spread into their failure response — it always
|
||||
contains 'message', and sets 'permission_error': True for the permission case
|
||||
so the frontend can offer a Reconnect action.
|
||||
"""
|
||||
code_str = (str(code) if code else "").strip().lower()
|
||||
msg_str = (str(message) if message else "").strip().lower()
|
||||
|
||||
if "permission" in code_str or "permission" in msg_str:
|
||||
return {
|
||||
"message": (
|
||||
"The selected ƏMAS certificate doesn't have permission for this "
|
||||
"operation. You can reconnect to ƏMAS and choose a different "
|
||||
"organization (certificate)."
|
||||
),
|
||||
"permission_error": True,
|
||||
}
|
||||
|
||||
return {"message": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def connect_amas(asan_login_name):
|
||||
"""
|
||||
|
|
@ -423,17 +452,16 @@ def refresh_csrf_token_internal(asan_login_name):
|
|||
return session_data, doc.amas_csrf_token
|
||||
|
||||
|
||||
def make_amas_request(asan_login_name, endpoint, data):
|
||||
"""
|
||||
Make an authenticated request to EMAS API.
|
||||
def make_amas_request(asan_login_name, endpoint, data, _retried=False):
|
||||
"""Make an authenticated request to ƏMAS API.
|
||||
|
||||
Handles session cookies, CSRF token, and request headers.
|
||||
IMPORTANT: Refreshes CSRF token before EVERY request (EMAS requirement).
|
||||
Uses the cached CSRF token + session cookies (no upfront dashboard download).
|
||||
Cookies and tokens are refreshed from each response's Set-Cookie. Only when
|
||||
ƏMAS actually rejects the CSRF (HTTP 419 or response code in CSRF /
|
||||
TOKENS_ARE_NOT_SAME / CSRF_TOKEN_MISMATCH) we fall back to fetching a fresh
|
||||
token from /core.dashboard and retry the request once.
|
||||
"""
|
||||
# IMPORTANT: Refresh CSRF token before EVERY operation
|
||||
# EMAS changes CSRF token after each request
|
||||
session_data, csrf_token = refresh_csrf_token_internal(asan_login_name)
|
||||
|
||||
session_data = get_amas_session_data(asan_login_name)
|
||||
if not session_data:
|
||||
return {
|
||||
"success": False,
|
||||
|
|
@ -442,22 +470,20 @@ def make_amas_request(asan_login_name, endpoint, data):
|
|||
|
||||
doc = frappe.get_doc("Asan Login", asan_login_name)
|
||||
|
||||
# Build request headers with fresh CSRF token
|
||||
headers = AMAS_HEADERS.copy()
|
||||
csrf_token = csrf_token or doc.amas_csrf_token or session_data.get("xsrf_token", "")
|
||||
csrf_token = doc.amas_csrf_token or session_data.get("xsrf_token", "")
|
||||
headers["X-CSRF-TOKEN"] = csrf_token
|
||||
headers["MAGUS-REQUEST-NUMBER"] = generate_request_number()
|
||||
|
||||
# Build cookies (updated by refresh)
|
||||
cookies = session_data.get("all_cookies", {})
|
||||
|
||||
# Add required data fields with fresh token
|
||||
# Reset per-request fields (in case of retry, regenerate them)
|
||||
for k in ("requestNumber", "_token", "currentTabToken"):
|
||||
data.pop(k, None)
|
||||
data["requestNumber"] = headers["MAGUS-REQUEST-NUMBER"]
|
||||
data["_token"] = csrf_token
|
||||
|
||||
# Add currentTabToken if available
|
||||
# NOTE: The token from config is already in format "{uuid}.{accountOid}"
|
||||
# Do NOT append accountOid again!
|
||||
# The token from config is already in format "{uuid}.{accountOid}" — don't append accountOid again
|
||||
token = session_data.get("token")
|
||||
if token:
|
||||
data["currentTabToken"] = token
|
||||
|
|
@ -473,13 +499,11 @@ def make_amas_request(asan_login_name, endpoint, data):
|
|||
timeout=60
|
||||
)
|
||||
|
||||
# Update cookies from response
|
||||
new_cookies = parse_cookies_from_response(response)
|
||||
if new_cookies:
|
||||
cookies.update(new_cookies)
|
||||
session_data["all_cookies"] = cookies
|
||||
|
||||
# Update CSRF token if changed - IMPORTANT: decode URL-encoded token!
|
||||
if "XSRF-TOKEN" in new_cookies:
|
||||
decoded_token = unquote(new_cookies["XSRF-TOKEN"])
|
||||
doc.amas_csrf_token = decoded_token
|
||||
|
|
@ -490,59 +514,17 @@ def make_amas_request(asan_login_name, endpoint, data):
|
|||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
result = response.json()
|
||||
|
||||
# Check for token/CSRF errors in response
|
||||
response_info = result.get("response", {})
|
||||
error_code = response_info.get("code", "")
|
||||
error_msg = str(response_info.get("message", ""))
|
||||
|
||||
# Handle various token errors
|
||||
if error_code in ("CSRF", "TOKENS_ARE_NOT_SAME", "CSRF_TOKEN_MISMATCH"):
|
||||
frappe.log_error(
|
||||
f"Token error: {error_code}. CSRF: {csrf_token[:20]}..., TabToken: {data.get('currentTabToken', 'N/A')[:30]}...",
|
||||
"ƏMAS Token Error"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": f"Token error ({error_code}). Please reconnect to ƏMAS."
|
||||
}
|
||||
|
||||
if "CSRF" in error_msg or "TOKEN" in error_msg.upper():
|
||||
frappe.log_error(
|
||||
f"Token error in message: {error_msg}",
|
||||
"ƏMAS Token Error"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": f"Token error. Please reconnect to ƏMAS."
|
||||
}
|
||||
|
||||
# Update token if present in response
|
||||
if result.get("token"):
|
||||
session_data["token"] = result.get("token")
|
||||
doc.amas_session = json.dumps(session_data)
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
return {"success": True, "data": result}
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid response from ƏMAS"
|
||||
}
|
||||
elif response.status_code == 419:
|
||||
# Laravel CSRF token mismatch status code
|
||||
if response.status_code == 419:
|
||||
if not _retried:
|
||||
refresh_csrf_token_internal(asan_login_name)
|
||||
return make_amas_request(asan_login_name, endpoint, data, _retried=True)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": "CSRF token expired. Please reconnect to ƏMAS."
|
||||
}
|
||||
elif response.status_code == 401:
|
||||
|
||||
if response.status_code == 401:
|
||||
doc.amas_auth_status = "Error"
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
|
@ -551,20 +533,55 @@ def make_amas_request(asan_login_name, endpoint, data):
|
|||
"session_expired": True,
|
||||
"message": "ƏMAS session expired. Please reconnect."
|
||||
}
|
||||
else:
|
||||
# Log unexpected errors for debugging
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
result = response.json()
|
||||
|
||||
response_info = result.get("response", {}) if isinstance(result, dict) else {}
|
||||
error_code = response_info.get("code", "")
|
||||
error_msg = str(response_info.get("message", ""))
|
||||
|
||||
csrf_codes = ("CSRF", "TOKENS_ARE_NOT_SAME", "CSRF_TOKEN_MISMATCH")
|
||||
csrf_failed = (
|
||||
error_code in csrf_codes
|
||||
or "CSRF" in error_msg
|
||||
or "TOKEN" in error_msg.upper()
|
||||
)
|
||||
if csrf_failed:
|
||||
if not _retried:
|
||||
refresh_csrf_token_internal(asan_login_name)
|
||||
return make_amas_request(asan_login_name, endpoint, data, _retried=True)
|
||||
frappe.log_error(
|
||||
f"ƏMAS CSRF error after retry: code={error_code} msg={error_msg}",
|
||||
"ƏMAS Token Error"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": "Token error. Please reconnect to ƏMAS."
|
||||
}
|
||||
|
||||
if isinstance(result, dict) and result.get("token"):
|
||||
session_data["token"] = result.get("token")
|
||||
doc.amas_session = json.dumps(session_data)
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
return {"success": True, "data": result}
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "message": "Invalid response from ƏMAS"}
|
||||
|
||||
# Unexpected HTTP status
|
||||
frappe.log_error(
|
||||
f"ƏMAS API error: status {response.status_code}\nResponse: {response.text[:500]}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"ƏMAS API error: status {response.status_code}"
|
||||
}
|
||||
return {"success": False, "message": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
frappe.log_error(f"ƏMAS API network error: {str(e)}", "ƏMAS API Error")
|
||||
return {"success": False, "message": f"Network error: {str(e)}"}
|
||||
return {"success": False, "message": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -574,17 +591,14 @@ def get_amas_accounts(asan_login_name):
|
|||
Fetches from /request/accounts endpoint.
|
||||
"""
|
||||
try:
|
||||
# IMPORTANT: Refresh CSRF token before EVERY operation
|
||||
session_data, csrf_token = refresh_csrf_token_internal(asan_login_name)
|
||||
|
||||
session_data = get_amas_session_data(asan_login_name)
|
||||
if not session_data:
|
||||
return {"success": False, "accounts": [], "message": "No ƏMAS session"}
|
||||
|
||||
doc = frappe.get_doc("Asan Login", asan_login_name)
|
||||
|
||||
# Build request headers with fresh CSRF token
|
||||
headers = AMAS_HEADERS.copy()
|
||||
csrf_token = csrf_token or doc.amas_csrf_token or session_data.get("xsrf_token", "")
|
||||
csrf_token = doc.amas_csrf_token or session_data.get("xsrf_token", "")
|
||||
headers["X-CSRF-TOKEN"] = csrf_token
|
||||
headers["MAGUS-REQUEST-NUMBER"] = generate_request_number()
|
||||
|
||||
|
|
@ -678,10 +692,13 @@ def change_amas_account(asan_login_name, account_oid, account_name, account_numb
|
|||
# Check response code
|
||||
response_info = response_data.get("response", {})
|
||||
if response_info.get("code") != "0":
|
||||
return {
|
||||
"success": False,
|
||||
"message": response_info.get("message", "Failed to change account")
|
||||
}
|
||||
raw_code = response_info.get("code")
|
||||
raw_msg = response_info.get("message")
|
||||
frappe.log_error(
|
||||
f"changeAccount failed: code={raw_code}, message={raw_msg}",
|
||||
"ƏMAS Account Switch Error"
|
||||
)
|
||||
return {"success": False, **humanize_amas_error(raw_code, raw_msg)}
|
||||
|
||||
# Store selected account info
|
||||
doc = frappe.get_doc("Asan Login", asan_login_name)
|
||||
|
|
@ -747,10 +764,13 @@ def get_employees_report(asan_login_name, offset=0, limit=100):
|
|||
# Check response code
|
||||
response_info = response_data.get("response", {})
|
||||
if response_info.get("code") != "0":
|
||||
return {
|
||||
"success": False,
|
||||
"message": response_info.get("message", "Failed to get employees report")
|
||||
}
|
||||
raw_code = response_info.get("code")
|
||||
raw_msg = response_info.get("message")
|
||||
frappe.log_error(
|
||||
f"getEmployeesReport failed: code={raw_code}, message={raw_msg}",
|
||||
"ƏMAS Employees Report Error"
|
||||
)
|
||||
return {"success": False, **humanize_amas_error(raw_code, raw_msg)}
|
||||
|
||||
# Extract employees from result
|
||||
employees = response_data.get("result", [])
|
||||
|
|
@ -801,10 +821,13 @@ def get_contract_stats(asan_login_name):
|
|||
|
||||
response_info = response_data.get("response", {})
|
||||
if response_info.get("code") != "0":
|
||||
return {
|
||||
"success": False,
|
||||
"message": response_info.get("message", "Failed to get contract stats")
|
||||
}
|
||||
raw_code = response_info.get("code")
|
||||
raw_msg = response_info.get("message")
|
||||
frappe.log_error(
|
||||
f"getContractStats failed: code={raw_code}, message={raw_msg}",
|
||||
"ƏMAS Contract Stats Error"
|
||||
)
|
||||
return {"success": False, **humanize_amas_error(raw_code, raw_msg)}
|
||||
|
||||
stats = response_data.get("result", [])
|
||||
|
||||
|
|
@ -2671,14 +2694,14 @@ def _process_bulk_employees_import(asan_login_name, employees, company, create_d
|
|||
except Exception as e:
|
||||
errors.append({"employee": emp_name, "error": str(e)})
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
frappe.publish_realtime(
|
||||
"amas_import_progress",
|
||||
{"current": idx + 1, "total": total, "employee_name": emp_name},
|
||||
user=user,
|
||||
)
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
frappe.publish_realtime(
|
||||
"amas_import_complete",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -365,11 +365,10 @@ function start_amas_connection_for_import(listview) {
|
|||
primary_action: function() {
|
||||
d.hide();
|
||||
|
||||
// Start full ƏMAS connection process (auto-select first organization)
|
||||
start_mygovid_then_amas_for_employees(asan_login.name, function() {
|
||||
// After successful connection, restart import
|
||||
start_amas_employee_import(listview);
|
||||
}, false);
|
||||
}, true);
|
||||
},
|
||||
secondary_action_label: __('Cancel')
|
||||
});
|
||||
|
|
@ -406,6 +405,17 @@ function fetch_employees_from_amas(listview, asan_login_name, organization_name)
|
|||
return;
|
||||
}
|
||||
|
||||
if (r.message && r.message.permission_error) {
|
||||
show_amas_permission_error_dialog(
|
||||
asan_login_name,
|
||||
r.message.message,
|
||||
function() {
|
||||
fetch_employees_from_amas(listview, asan_login_name, organization_name);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.msgprint({
|
||||
title: __('Error'),
|
||||
indicator: 'red',
|
||||
|
|
@ -854,6 +864,22 @@ function show_import_summary(total, created_count, updated_count, error_count, e
|
|||
}
|
||||
}
|
||||
|
||||
// Show a confirm dialog for ƏMAS permission errors with a Reconnect action.
|
||||
// On confirm: runs full MyGovID + ƏMAS reauth and shows the organization
|
||||
// selection dialog so the user can pick a different certificate.
|
||||
function show_amas_permission_error_dialog(asan_login_name, message, retry_callback) {
|
||||
const body = (message || __("The selected ƏMAS certificate doesn't have permission for this operation."))
|
||||
+ '<br><br>'
|
||||
+ __('Would you like to reconnect to ƏMAS and choose a different organization?');
|
||||
|
||||
frappe.confirm(
|
||||
body,
|
||||
function() {
|
||||
start_mygovid_then_amas_for_employees(asan_login_name, retry_callback, true);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Show reconnection dialog for ƏMAS when token expires (CSRF error)
|
||||
function show_amas_reconnect_dialog(asan_login_name, retry_callback) {
|
||||
const d = new frappe.ui.Dialog({
|
||||
|
|
@ -1252,7 +1278,21 @@ function show_amas_accounts_dialog(asan_login_name, accounts, retry_callback) {
|
|||
} else {
|
||||
// Re-enable buttons on error
|
||||
d.$wrapper.find('.select-amas-account').prop('disabled', false);
|
||||
d.$wrapper.find('.select-amas-account').each(function() {
|
||||
if ($(this).find('.fa-spinner').length) {
|
||||
$(this).html('Select');
|
||||
}
|
||||
});
|
||||
|
||||
if (r.message && r.message.permission_error) {
|
||||
d.hide();
|
||||
show_amas_permission_error_dialog(
|
||||
asan_login_name,
|
||||
r.message.message,
|
||||
retry_callback
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.msgprint({
|
||||
title: __('Error'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue