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'),