From dd096dd795a40aa12cba0d6f79f0991052abf640 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Thu, 30 Apr 2026 15:52:25 +0000 Subject: [PATCH] fix(amas): show org dialog on connect, humanize errors, skip dashboard prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- invoice_az/amas_api.py | 215 +++++++++++++++++++--------------- invoice_az/client/employee.js | 46 +++++++- 2 files changed, 162 insertions(+), 99 deletions(-) diff --git a/invoice_az/amas_api.py b/invoice_az/amas_api.py index c554bbd..9bb441a 100644 --- a/invoice_az/amas_api.py +++ b/invoice_az/amas_api.py @@ -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 - 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}" - } + + 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": 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", { diff --git a/invoice_az/client/employee.js b/invoice_az/client/employee.js index 51ce6b7..99e7be3 100644 --- a/invoice_az/client/employee.js +++ b/invoice_az/client/employee.js @@ -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.")) + + '

' + + __('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); - $(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({ title: __('Error'),