diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f59a8d..a65a17cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. ### Fixed +- **Xero integration for apps created after March 2026 (Issue #567)** — OAuth no longer fails with "Invalid scope for client" for Xero Developer apps created on or after March 2, 2026. Replaced deprecated `accounting.transactions` scope with granular `accounting.invoices` and `accounting.payments`. Expense sync now uses the correct `/api.xro/2.0/ExpenseClaims` endpoint (replacing the non-existent `/api.xro/2.0/Expenses`) and reads `ExpenseClaimID` from the response. `_api_request` now accepts an optional request body so invoice and expense payloads are sent to the Xero API. See [docs/integrations/XERO.md](docs/integrations/XERO.md). - **Time Entries date filter and export (Issue #555)** — Start/End date filters were hard to discover and exports ignored them. The Time Entries overview now has a visible **Apply filters** button in the filter header (next to Clear Filters and Export) so users can apply date and other filters without scrolling. CSV and PDF export links always use the current filter parameters: export href is set from the page URL on load and updated whenever filter form values change, so left-click export, right-click "Open in new tab", and "Save link as" all produce filtered exports. The in-form Apply filters button and the header button both trigger the same filter logic; clicking the header button expands the filter panel if it is collapsed. - **Log Time / Edit Time Entry on mobile (Issue #557)** — Opening the manual time entry ("Log Time") or edit time entry page on mobile could freeze or crash the browser. The Toast UI Editor (WYSIWYG markdown editor) for the notes field is heavy and causes freezes on mobile Safari/Chrome. On viewports ≤767px we now skip loading the editor and show a plain textarea for notes instead; desktop behavior is unchanged. Manual entry and edit timer templates load Toast UI only when not in mobile view. - **Stop & Save error (Issue #563)** — Fixed error after clicking "Stop & Save" on the dashboard. The post-timer toast was building the "View time entries" URL with the wrong route name (`timer.time_entries`); the correct endpoint is `timer.time_entries_overview`. Time entries were already saved; the error occurred when rendering the dashboard redirect. diff --git a/app/integrations/xero.py b/app/integrations/xero.py index 566e8958..66004694 100644 --- a/app/integrations/xero.py +++ b/app/integrations/xero.py @@ -38,7 +38,7 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: if not client_id: raise ValueError("XERO_CLIENT_ID not configured") - scopes = ["accounting.transactions", "accounting.contacts", "accounting.settings", "offline_access"] + scopes = ["accounting.invoices", "accounting.payments", "accounting.contacts", "accounting.settings", "offline_access"] auth_url = "https://login.xero.com/identity/connect/authorize" params = { @@ -180,7 +180,14 @@ def test_connection(self) -> Dict[str, Any]: except Exception as e: return {"success": False, "message": f"Connection test failed: {str(e)}"} - def _api_request(self, method: str, endpoint: str, access_token: str, tenant_id: str) -> Optional[Dict]: + def _api_request( + self, + method: str, + endpoint: str, + access_token: str, + tenant_id: str, + json_body: Optional[Dict] = None, + ) -> Optional[Dict]: """Make API request to Xero""" url = f"{self.BASE_URL}{endpoint}" @@ -195,7 +202,7 @@ def _api_request(self, method: str, endpoint: str, access_token: str, tenant_id: if method.upper() == "GET": response = requests.get(url, headers=headers, timeout=10) elif method.upper() == "POST": - response = requests.post(url, headers=headers, timeout=10, json={}) + response = requests.post(url, headers=headers, timeout=10, json=json_body or {}) else: response = requests.request(method, url, headers=headers, timeout=10) @@ -251,7 +258,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if xero_expense: if not hasattr(expense, "metadata") or not expense.metadata: expense.metadata = {} - expense.metadata["xero_expense_id"] = xero_expense.get("Expenses", [{}])[0].get("ExpenseID") + expense.metadata["xero_expense_id"] = xero_expense.get("ExpenseClaims", [{}])[0].get("ExpenseClaimID") synced_count += 1 except Exception as e: errors.append(f"Error syncing expense {expense.id}: {str(e)}") @@ -316,7 +323,7 @@ def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Op xero_invoice["LineItems"].append(line_item) endpoint = "/api.xro/2.0/Invoices" - return self._api_request("POST", endpoint, access_token, tenant_id) + return self._api_request("POST", endpoint, access_token, tenant_id, json_body={"Invoices": [xero_invoice]}) def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Optional[Dict]: """Create expense in Xero""" @@ -346,8 +353,8 @@ def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Op ], } - endpoint = "/api.xro/2.0/Expenses" - return self._api_request("POST", endpoint, access_token, tenant_id) + endpoint = "/api.xro/2.0/ExpenseClaims" + return self._api_request("POST", endpoint, access_token, tenant_id, json_body={"ExpenseClaims": [xero_expense]}) def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" diff --git a/app/routes/auth.py b/app/routes/auth.py index ce7f32d0..931db3d0 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -758,15 +758,38 @@ def oidc_callback(): try: token = client.authorize_access_token() except Exception as token_err: - current_app.logger.warning( - "OIDC token exchange failed (state/code_verifier mismatch or invalid code). " - "Session may have been lost between redirect and callback – check cookie size, domain, Secure, SameSite and proxy headers: %s", - token_err, - ) - current_app.logger.info("OIDC callback redirect to login: reason=token_exchange_failed") - flash( - _("SSO failed. If this repeats, check session cookie and proxy configuration."), "error" + err_str = str(token_err).lower() + err_type_name = type(token_err).__name__ + is_algorithm_or_jwe = ( + "unsupported_algorithm" in err_str + or "unsupportedalgorithmerror" in err_type_name.lower() + or "jwe" in err_str + or "authlib.jose" in (getattr(token_err, "__module__", "") or "") ) + if is_algorithm_or_jwe: + current_app.logger.warning( + "OIDC token exchange failed: unsupported token algorithm or encrypted ID token (JWE). " + "IdP may have ID token encryption enabled: %s", + token_err, + ) + current_app.logger.info("OIDC callback redirect to login: reason=unsupported_algorithm_or_jwe") + flash( + _( + "SSO failed: encrypted or unsupported ID tokens. " + "Disable ID token encryption on your provider (e.g. in Authentik, leave the Encryption Key empty)." + ), + "error", + ) + else: + current_app.logger.warning( + "OIDC token exchange failed (state/code_verifier mismatch or invalid code). " + "Session may have been lost between redirect and callback – check cookie size, domain, Secure, SameSite and proxy headers: %s", + token_err, + ) + current_app.logger.info("OIDC callback redirect to login: reason=token_exchange_failed") + flash( + _("SSO failed. If this repeats, check session cookie and proxy configuration."), "error" + ) return redirect(url_for("auth.login")) current_app.logger.info( diff --git a/app/templates/inventory/movements/form.html b/app/templates/inventory/movements/form.html index 35e1567c..c858dde8 100644 --- a/app/templates/inventory/movements/form.html +++ b/app/templates/inventory/movements/form.html @@ -22,7 +22,8 @@ data-hint-devaluation="{{ _('Quantity to revalue (positive)') }}" data-hint-default="{{ _('Use positive values for additions, negative for removals') }}" data-msg-return-positive="{{ _('Return requires a positive quantity.') }}" - data-msg-waste-negative="{{ _('Waste requires a negative quantity.') }}"> + data-msg-waste-negative="{{ _('Waste requires a negative quantity.') }}" + data-msg-devaluation-unsupported="{{ _('Devaluation requires a trackable item with a default cost.') }}">
@@ -44,7 +45,7 @@
@@ -66,6 +67,7 @@