From acdbab869fa178c350d15fca4484851a82d2abf0 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 12 Mar 2026 21:32:38 +0100 Subject: [PATCH 1/8] fix(oidc): surface clear error when IdP sends JWE-encrypted ID tokens (fixes #566) When the IdP returns an encrypted ID token (e.g. Authentik with Encryption Key set), Authlib raises UnsupportedAlgorithmError. Previously this was caught generically and users saw a misleading message about session/cookie/proxy. - Detect algorithm/JWE-related errors via exception type, module, or message - Log reason=unsupported_algorithm_or_jwe and a specific warning - Flash: disable ID token encryption (e.g. leave Authentik Encryption Key empty) - Keep existing message for real session/state/code failures --- app/routes/auth.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) 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( From 65f5b58638ee1effec6a910475713f04e43640fa Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 12 Mar 2026 21:32:40 +0100 Subject: [PATCH 2/8] docs(oidc): document JWE limitation and Authentik Encryption Key (fixes #566) - Add Authentik to provider notes: leave Encryption Key empty; JWE not supported - Add troubleshooting entry for 'SSO failed' with unsupported algorithm/encrypted token - Mention reason=unsupported_algorithm_or_jwe in callback redirect log examples --- docs/admin/configuration/OIDC_SETUP.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/admin/configuration/OIDC_SETUP.md b/docs/admin/configuration/OIDC_SETUP.md index 229391d6..ae858e85 100644 --- a/docs/admin/configuration/OIDC_SETUP.md +++ b/docs/admin/configuration/OIDC_SETUP.md @@ -96,6 +96,9 @@ Also ensure the standard app settings are configured (database, secret key, etc. - Issuer: `https://accounts.google.com` - Groups generally not available by default; prefer admin mapping via emails. +- Authentik + - TimeTracker does **not** support JWE-encrypted ID tokens. If using Authentik, leave the **Encryption Key** field **empty** on the OAuth2/OpenID provider. If an Encryption Key is set, Authentik sends the ID token as JWE and login will fail with an error about unsupported algorithm or encrypted tokens. + ### 4) Behavior and Mapping - When a user completes SSO: @@ -191,6 +194,9 @@ services: - “User is not admin” - Verify `OIDC_ADMIN_GROUP` matches the group claim value, or add the user’s email to `OIDC_ADMIN_EMAILS`. +- **“SSO failed” with “unsupported algorithm” or “encrypted ID token” in logs** + - The IdP is sending an encrypted ID token (JWE). TimeTracker does not support JWE-encrypted ID tokens. Disable ID token encryption at the provider. For Authentik, clear the **Encryption Key** on the OAuth2/OpenID provider so it returns a signed (e.g. RS256) JWT instead. + - "Logout keeps me signed in" or "Logout redirects to provider error page" - Not all IdPs support RP-Initiated Logout (end-session). If your provider doesn't support it (e.g., Authelia), **do not set** `OIDC_POST_LOGOUT_REDIRECT_URI`. TimeTracker will then perform local logout only and redirect to the login page. - If your provider supports end-session and you want to log out from the IdP too, set `OIDC_POST_LOGOUT_REDIRECT_URI` to your desired post-logout landing page. @@ -201,7 +207,7 @@ services: - **Cookie attributes**: Ensure `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAMESITE`, and domain match how users access the app (e.g. HTTPS, same domain). - **Proxy headers**: Behind a reverse proxy, ensure `X-Forwarded-Proto` and `X-Forwarded-Host` are set correctly so redirect URLs and cookies use the same host/scheme the user sees. - **Callback URL**: The callback URL must match exactly (no trailing slash, same scheme and host) between TimeTracker (`OIDC_REDIRECT_URI`) and the IdP client configuration. - - **Logs**: TimeTracker logs a line like `OIDC callback redirect to login: reason=...` before each redirect to login. Use this to see the exact cause (e.g. `reason=token_exchange_failed` for session/state issues, `reason=missing_issuer_sub` for claim issues). + - **Logs**: TimeTracker logs a line like `OIDC callback redirect to login: reason=...` before each redirect to login. Use this to see the exact cause (e.g. `reason=token_exchange_failed` for session/state issues, `reason=unsupported_algorithm_or_jwe` for encrypted/unsupported ID tokens, `reason=missing_issuer_sub` for claim issues). ### 9) Routes Reference From 9c0ac0f4ea673ff0ea61240d5d2bba098e0e0017 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 12 Mar 2026 21:33:11 +0100 Subject: [PATCH 3/8] Fix Xero integration for apps created after March 2026 (#567) OAuth: Replace deprecated accounting.transactions scope with accounting.invoices and accounting.payments so new Xero Developer apps (on or after 2026-03-02) complete the authorization flow. Expense sync: Use /api.xro/2.0/ExpenseClaims instead of non-existent /api.xro/2.0/Expenses; read ExpenseClaimID from response. API: Add optional json_body to _api_request and send invoice/expense payloads (Invoices and ExpenseClaims wrappers) to Xero. Docs: Add docs/integrations/XERO.md (setup, scopes, sync, troubleshooting) and CHANGELOG entry. --- CHANGELOG.md | 1 + app/integrations/xero.py | 21 +++++++++++------ docs/integrations/XERO.md | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 docs/integrations/XERO.md 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/docs/integrations/XERO.md b/docs/integrations/XERO.md new file mode 100644 index 00000000..3ef6c751 --- /dev/null +++ b/docs/integrations/XERO.md @@ -0,0 +1,49 @@ +# Xero Integration + +TimeTracker can sync invoices, expenses, and payments with [Xero](https://www.xero.com/) via the Xero Accounting API and OAuth 2.0. + +## Requirements + +- A [Xero Developer](https://developer.xero.com/) app (Client ID and Client Secret). +- For apps **created on or after March 2, 2026**, the integration uses the granular accounting scopes required by Xero; older apps continue to work until broad scopes are retired (see [Xero scope changes](https://developer.xero.com/documentation/guides/oauth2/scopes/)). + +## OAuth scopes + +The integration requests the following scopes: + +| Scope | Purpose | +|-------|---------| +| `accounting.invoices` | Invoices, credit notes, purchase orders, quotes, items | +| `accounting.payments` | Payments, batch payments, overpayments, prepayments | +| `accounting.contacts` | Contacts | +| `accounting.settings` | Organisation and account settings | +| `offline_access` | Refresh token for background sync | + +**Note:** The deprecated scope `accounting.transactions` is no longer used. For apps created on or after March 2, 2026, Xero requires the granular scopes above (see [Issue #567](https://github.com/DRYTRIX/TimeTracker/issues/567)). + +## Setup + +1. In [Xero Developer](https://developer.xero.com/app/manage), create an app (or use an existing one) and note the **Client ID** and **Client Secret**. +2. Set the app **Redirect URI** to your TimeTracker base URL plus `/integrations/xero/callback` (e.g. `https://your-timetracker.example.com/integrations/xero/callback`). +3. In TimeTracker: **Integrations** → **Xero** → complete the setup wizard with your Client ID and Client Secret. +4. Open **Connect** (or go to `/integrations/xero/connect`) to start the OAuth flow. Sign in to Xero, select the organisation, and authorise. You are redirected back with the connection stored. + +## Configuration + +- **Tenant ID** — Set automatically during OAuth; you can also enter it manually if needed. +- **Sync direction** — Xero → TimeTracker (import), TimeTracker → Xero (export), or bidirectional. +- **Items to sync** — Invoices, expenses, payments, contacts. +- **Data mapping** — Contact mappings (clients → Xero contacts), item mappings (invoice items → Xero items), account mappings (expense categories → Xero account codes), and default expense account code. + +## Sync behaviour + +- **Invoices** — Pushed to Xero as sales invoices via `POST /api.xro/2.0/Invoices`. Response `InvoiceID` is stored in invoice metadata as `xero_invoice_id`. +- **Expenses** — Pushed to Xero as expense claims via `POST /api.xro/2.0/ExpenseClaims`. Response `ExpenseClaimID` is stored in expense metadata as `xero_expense_id`. The Xero ExpenseClaims API expects a specific payload shape (e.g. User and Receipts); if you see validation errors, the payload may need to be adapted to your Xero app and workflow. +- **Manual sync** — Use **Sync Now** on the integration page. +- **Auto sync** — Enable in setup and choose a schedule (e.g. hourly, daily). + +## Troubleshooting + +- **"Invalid scope for client"** — Ensure you are using TimeTracker with the updated scopes (`accounting.invoices`, `accounting.payments`, etc.). Re-create your Xero app or use an app created before March 2, 2026 if you must use the old broad scope during the transition. +- **404 on expense sync** — The integration uses `/api.xro/2.0/ExpenseClaims`; the previous `/api.xro/2.0/Expenses` endpoint does not exist in the Xero API. +- **Connection test** — Use the integration’s connection test to verify tenant and token; fix any credential or tenant ID issues before syncing. From 39dd165efb517d2000f1843932f78feb4938f4ad Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 12 Mar 2026 21:38:18 +0100 Subject: [PATCH 4/8] feat(inventory): show devaluation requirements by stock item (#385) - Add data-devaluation-supported on stock item options (trackable + default cost). - When return/waste is selected and item cannot be devalued, disable 'Apply devaluation', show message: 'Devaluation requires a trackable item with a default cost.' - Same message for standalone devaluation type when item is unsupported. - Add en translation string for the new message. --- app/templates/inventory/movements/form.html | 37 +++++++++++++++++++-- translations/en/LC_MESSAGES/messages.po | 4 +++ 2 files changed, 38 insertions(+), 3 deletions(-) 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 @@