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:
Ali 2026-04-30 15:52:25 +00:00
parent 6287f30409
commit dd096dd795
2 changed files with 162 additions and 99 deletions

View File

@ -84,6 +84,35 @@ def parse_cookies_from_response(response):
return cookies 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() @frappe.whitelist()
def connect_amas(asan_login_name): 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 return session_data, doc.amas_csrf_token
def make_amas_request(asan_login_name, endpoint, data): def make_amas_request(asan_login_name, endpoint, data, _retried=False):
""" """Make an authenticated request to ƏMAS API.
Make an authenticated request to EMAS API.
Handles session cookies, CSRF token, and request headers. Uses the cached CSRF token + session cookies (no upfront dashboard download).
IMPORTANT: Refreshes CSRF token before EVERY request (EMAS requirement). 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 session_data = get_amas_session_data(asan_login_name)
# EMAS changes CSRF token after each request
session_data, csrf_token = refresh_csrf_token_internal(asan_login_name)
if not session_data: if not session_data:
return { return {
"success": False, "success": False,
@ -442,22 +470,20 @@ def make_amas_request(asan_login_name, endpoint, data):
doc = frappe.get_doc("Asan Login", asan_login_name) doc = frappe.get_doc("Asan Login", asan_login_name)
# Build request headers with fresh CSRF token
headers = AMAS_HEADERS.copy() 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["X-CSRF-TOKEN"] = csrf_token
headers["MAGUS-REQUEST-NUMBER"] = generate_request_number() headers["MAGUS-REQUEST-NUMBER"] = generate_request_number()
# Build cookies (updated by refresh)
cookies = session_data.get("all_cookies", {}) 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["requestNumber"] = headers["MAGUS-REQUEST-NUMBER"]
data["_token"] = csrf_token data["_token"] = csrf_token
# Add currentTabToken if available # The token from config is already in format "{uuid}.{accountOid}" — don't append accountOid again
# NOTE: The token from config is already in format "{uuid}.{accountOid}"
# Do NOT append accountOid again!
token = session_data.get("token") token = session_data.get("token")
if token: if token:
data["currentTabToken"] = token data["currentTabToken"] = token
@ -473,13 +499,11 @@ def make_amas_request(asan_login_name, endpoint, data):
timeout=60 timeout=60
) )
# Update cookies from response
new_cookies = parse_cookies_from_response(response) new_cookies = parse_cookies_from_response(response)
if new_cookies: if new_cookies:
cookies.update(new_cookies) cookies.update(new_cookies)
session_data["all_cookies"] = cookies session_data["all_cookies"] = cookies
# Update CSRF token if changed - IMPORTANT: decode URL-encoded token!
if "XSRF-TOKEN" in new_cookies: if "XSRF-TOKEN" in new_cookies:
decoded_token = unquote(new_cookies["XSRF-TOKEN"]) decoded_token = unquote(new_cookies["XSRF-TOKEN"])
doc.amas_csrf_token = decoded_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) doc.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
if response.status_code == 200: if response.status_code == 419:
try: if not _retried:
result = response.json() refresh_csrf_token_internal(asan_login_name)
return make_amas_request(asan_login_name, endpoint, data, _retried=True)
# 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
return { return {
"success": False, "success": False,
"csrf_error": True, "csrf_error": True,
"message": "CSRF token expired. Please reconnect to ƏMAS." "message": "CSRF token expired. Please reconnect to ƏMAS."
} }
elif response.status_code == 401:
if response.status_code == 401:
doc.amas_auth_status = "Error" doc.amas_auth_status = "Error"
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
@ -551,20 +533,55 @@ def make_amas_request(asan_login_name, endpoint, data):
"session_expired": True, "session_expired": True,
"message": "ƏMAS session expired. Please reconnect." "message": "ƏMAS session expired. Please reconnect."
} }
else:
# Log unexpected errors for debugging if response.status_code == 200:
frappe.log_error( try:
f"ƏMAS API error: status {response.status_code}\nResponse: {response.text[:500]}", result = response.json()
"ƏMAS API Error"
) response_info = result.get("response", {}) if isinstance(result, dict) else {}
return { error_code = response_info.get("code", "")
"success": False, error_msg = str(response_info.get("message", ""))
"message": f"ƏMAS API error: status {response.status_code}"
} 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": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
frappe.log_error(f"ƏMAS API network error: {str(e)}", "ƏMAS API Error") 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() @frappe.whitelist()
@ -574,17 +591,14 @@ def get_amas_accounts(asan_login_name):
Fetches from /request/accounts endpoint. Fetches from /request/accounts endpoint.
""" """
try: try:
# IMPORTANT: Refresh CSRF token before EVERY operation session_data = get_amas_session_data(asan_login_name)
session_data, csrf_token = refresh_csrf_token_internal(asan_login_name)
if not session_data: if not session_data:
return {"success": False, "accounts": [], "message": "No ƏMAS session"} return {"success": False, "accounts": [], "message": "No ƏMAS session"}
doc = frappe.get_doc("Asan Login", asan_login_name) doc = frappe.get_doc("Asan Login", asan_login_name)
# Build request headers with fresh CSRF token
headers = AMAS_HEADERS.copy() 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["X-CSRF-TOKEN"] = csrf_token
headers["MAGUS-REQUEST-NUMBER"] = generate_request_number() 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 # Check response code
response_info = response_data.get("response", {}) response_info = response_data.get("response", {})
if response_info.get("code") != "0": if response_info.get("code") != "0":
return { raw_code = response_info.get("code")
"success": False, raw_msg = response_info.get("message")
"message": response_info.get("message", "Failed to change account") 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 # Store selected account info
doc = frappe.get_doc("Asan Login", asan_login_name) 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 # Check response code
response_info = response_data.get("response", {}) response_info = response_data.get("response", {})
if response_info.get("code") != "0": if response_info.get("code") != "0":
return { raw_code = response_info.get("code")
"success": False, raw_msg = response_info.get("message")
"message": response_info.get("message", "Failed to get employees report") 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 # Extract employees from result
employees = response_data.get("result", []) employees = response_data.get("result", [])
@ -801,10 +821,13 @@ def get_contract_stats(asan_login_name):
response_info = response_data.get("response", {}) response_info = response_data.get("response", {})
if response_info.get("code") != "0": if response_info.get("code") != "0":
return { raw_code = response_info.get("code")
"success": False, raw_msg = response_info.get("message")
"message": response_info.get("message", "Failed to get contract stats") 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", []) 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: except Exception as e:
errors.append({"employee": emp_name, "error": str(e)}) errors.append({"employee": emp_name, "error": str(e)})
frappe.db.commit()
frappe.publish_realtime( frappe.publish_realtime(
"amas_import_progress", "amas_import_progress",
{"current": idx + 1, "total": total, "employee_name": emp_name}, {"current": idx + 1, "total": total, "employee_name": emp_name},
user=user, user=user,
) )
frappe.db.commit()
frappe.publish_realtime( frappe.publish_realtime(
"amas_import_complete", "amas_import_complete",
{ {

View File

@ -365,11 +365,10 @@ function start_amas_connection_for_import(listview) {
primary_action: function() { primary_action: function() {
d.hide(); d.hide();
// Start full ƏMAS connection process (auto-select first organization)
start_mygovid_then_amas_for_employees(asan_login.name, function() { start_mygovid_then_amas_for_employees(asan_login.name, function() {
// After successful connection, restart import // After successful connection, restart import
start_amas_employee_import(listview); start_amas_employee_import(listview);
}, false); }, true);
}, },
secondary_action_label: __('Cancel') secondary_action_label: __('Cancel')
}); });
@ -406,6 +405,17 @@ function fetch_employees_from_amas(listview, asan_login_name, organization_name)
return; 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({ frappe.msgprint({
title: __('Error'), title: __('Error'),
indicator: 'red', 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) // Show reconnection dialog for ƏMAS when token expires (CSRF error)
function show_amas_reconnect_dialog(asan_login_name, retry_callback) { function show_amas_reconnect_dialog(asan_login_name, retry_callback) {
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
@ -1252,7 +1278,21 @@ function show_amas_accounts_dialog(asan_login_name, accounts, retry_callback) {
} else { } else {
// Re-enable buttons on error // Re-enable buttons on error
d.$wrapper.find('.select-amas-account').prop('disabled', false); d.$wrapper.find('.select-amas-account').prop('disabled', false);
$(this).html('Select'); 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({ frappe.msgprint({
title: __('Error'), title: __('Error'),