diff --git a/.do/deploy.template.yaml b/.do/deploy.template.yaml new file mode 100644 index 000000000..3af2784f7 --- /dev/null +++ b/.do/deploy.template.yaml @@ -0,0 +1,53 @@ +# DigitalOcean App Platform — Deploy to DigitalOcean button +# https://docs.digitalocean.com/products/app-platform/how-to/add-deploy-do-button/ +# +# Uses the public Docker Hub image (same multi-arch build as GHCR). +# Dev Database only (Deploy button limitation). For production, use a managed +# Postgres cluster or self-host with docker-compose.nas.yml. +# +# Button: https://cloud.digitalocean.com/apps/new?repo=https://github.com/DRYTRIX/TimeTracker/tree/develop +# (default branch; switch to /tree/main after the template is merged to main) + +spec: + name: timetracker + services: + - name: web + image: + registry_type: DOCKER_HUB + registry: drytrix + repository: timetracker + tag: latest + instance_count: 1 + instance_size_slug: basic-xxs + http_port: 8080 + health_check: + http_path: /_health + initial_delay_seconds: 30 + period_seconds: 15 + timeout_seconds: 10 + success_threshold: 1 + failure_threshold: 5 + envs: + - key: FLASK_ENV + scope: RUN_TIME + value: production + - key: FLASK_APP + scope: RUN_TIME + value: app:create_app() + - key: AUTH_METHOD + scope: RUN_TIME + value: local + - key: REDIS_ENABLED + scope: RUN_TIME + value: "false" + - key: SECRET_KEY + scope: RUN_TIME + type: SECRET + # Replace at deploy time: openssl rand -hex 32 + value: "CHANGE_ME_openssl_rand_hex_32" + - key: DATABASE_URL + scope: RUN_TIME + value: ${timetracker-db.DATABASE_URL} + databases: + - name: timetracker-db + engine: PG diff --git a/.github/codeql/timetracker-python-models/models/sanitizers.yml b/.github/codeql/timetracker-python-models/models/sanitizers.yml index 0b37599a9..134565872 100644 --- a/.github/codeql/timetracker-python-models/models/sanitizers.yml +++ b/.github/codeql/timetracker-python-models/models/sanitizers.yml @@ -4,6 +4,7 @@ extensions: extensible: barrierModel data: - ["app.utils.safe_redirect", "Member[safe_redirect_target].ReturnValue", "url-redirection"] + - ["app.utils.safe_redirect", "Member[match_registered_redirect].ReturnValue", "url-redirection"] - ["app.utils.safe_redirect", "Member[_rebuild_relative].ReturnValue", "url-redirection"] - ["app.utils.safe_redirect", "Member[_rebuild_same_host].ReturnValue", "url-redirection"] - ["app.utils.error_handling", "Member[sanitize_log_value].ReturnValue", "log-injection"] diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9b55ec98e..9a7267bff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,10 @@ updates: labels: - dependencies - python + # Gunicorn 26 removed the eventlet worker used by docker/start-fixed.py. + ignore: + - dependency-name: gunicorn + update-types: ["version-update:semver-major"] - package-ecosystem: npm directory: / @@ -73,6 +77,10 @@ updates: labels: - dependencies - mobile + # Riverpod 3 removed StateNotifier; stay on 2.x until providers are migrated. + ignore: + - dependency-name: flutter_riverpod + update-types: ["version-update:semver-major"] - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 2bc011002..0e2678afe 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -112,6 +112,11 @@ jobs: working-directory: mobile run: flutter test + - name: Install CocoaPods deps + working-directory: mobile/ios + run: | + pod install --repo-update + - name: Build iOS (simulator) working-directory: mobile env: diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index f00556901..c547c218e 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -854,6 +854,11 @@ jobs: working-directory: mobile run: flutter test + - name: Install CocoaPods deps + working-directory: mobile/ios + run: | + pod install --repo-update + # Release mode is invalid for iOS simulator; this validates compilation and produces Runner.app for the artifact zip. - name: Build iOS (simulator) working-directory: mobile @@ -1073,7 +1078,7 @@ jobs: cat RELEASE_NOTES.md - name: Create draft GitHub Release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 with: tag_name: ${{ needs.determine-version.outputs.version }} name: Release ${{ needs.determine-version.outputs.version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bcdae381..59fd02ce4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,9 @@ jobs: app/utils/integration_http.py - name: Silent exception ratchet run: python scripts/check_silent_excepts.py + - name: Route file size guard (soft) + run: python scripts/ci/check_route_file_sizes.py + continue-on-error: true - name: Translation coverage run: python scripts/check_translation_coverage.py - name: Version consistency diff --git a/.github/workflows/migration-validate.yml b/.github/workflows/migration-validate.yml index 6736bacbd..59ddfa3ca 100644 --- a/.github/workflows/migration-validate.yml +++ b/.github/workflows/migration-validate.yml @@ -63,11 +63,16 @@ jobs: - name: Check for migration changes id: migration_check env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} + # PRs use the PR base; pushes use the previous tip (github.event.before). + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} run: | - # Compare against the PR base (not HEAD~1) so multi-commit PRs are covered + # All-zero "before" means first push / force-push with no prior tip. + if [ "${BASE_SHA:-}" = "0000000000000000000000000000000000000000" ]; then + BASE_SHA="" + fi + # Compare against the PR/push base (not HEAD~1) so multi-commit ranges are covered if [ -z "$BASE_SHA" ]; then - echo "⚠️ No PR base SHA; falling back to HEAD~1" + echo "⚠️ No PR/push base SHA; falling back to HEAD~1" DIFF_RANGE="HEAD~1" else DIFF_RANGE="${BASE_SHA}...HEAD" @@ -88,16 +93,21 @@ jobs: DATABASE_URL: postgresql+psycopg2://test_user:test_password@localhost:5432/test_db FLASK_APP: app.py FLASK_ENV: testing - BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} run: | set -euo pipefail + # All-zero "before" means first push / force-push with no prior tip. + if [ "${BASE_SHA:-}" = "0000000000000000000000000000000000000000" ]; then + BASE_SHA="" + fi + echo "🔍 Validating migration consistency..." echo "Current directory: $(pwd)" echo "Migration files:" ls -la migrations/versions/ | tail -5 - # Ensure PR base commit is available for checkout (fork PRs / shallow edges) + # Ensure PR/push base commit is available for checkout (fork PRs / shallow edges) if [ -n "${BASE_SHA:-}" ]; then git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin "$BASE_SHA" || true fi diff --git a/.gitignore b/.gitignore index 360139b6e..d3ca97ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,7 @@ junit.xml *.xml !mobile/android/app/src/main/AndroidManifest.xml !templates/unraid/*.xml +!ca_profile.xml test-results/ .pytest_tmp*/ bandit-report.json diff --git a/.safety-policy.yml b/.safety-policy.yml index acc19d6ce..94861a4d5 100644 --- a/.safety-policy.yml +++ b/.safety-policy.yml @@ -10,5 +10,11 @@ security: ignore-unpinned-requirements: false ignore-cvss-severity-below: 0 ignore-cvss-unknown-severity: false - ignore-vulnerabilities: {} + ignore-vulnerabilities: + # No fixed 3.x release yet (advisory range <3.12; latest is 3.11.3). We use the + # default MemoryJobStore only — not Redis/Mongo/SQLAlchemy stores that pickle + # job_state — so the persistent job-store deserialization path does not apply. + SFTY-20261008-90293: + reason: APScheduler MemoryJobStore only; no fixed 3.x release available + expires: '2027-05-01' continue-on-vulnerability-error: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dcef0e87..4b1debb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.20.0] - 2026-11-10 +### Added + +- **Enterprise identity** — OAuth 2.0 API apps (authorization code + PKCE, admin CRUD), SAML 2.0 login, SCIM Users provisioning, and shared Slack/Teams timer bot commands. +- **Bank reconciliation & payroll** — Bank reconciliation workflows plus payroll/labor export APIs and UI. +- **Inventory** — Stock locations, purchase-order email, and stock-levels CSV export. +- **Invoicing** — XRechnung export and PDF QR/barcode elements on invoice/quote layouts. +- **Peppol bridge: Let’s Peppol + inbox** — `PEPPOL_BRIDGE_PROVIDER=letspeppol` for Belgian access-point delivery, plus inbound Peppol inbox poll/ack in the bridge and admin UI. +- **Kanban & custom fields** — Enforce kanban WIP limits; extend custom fields coverage. +- **Clients** — Mobile sync retry backoff and failed-sync screen; desktop failed offline-sync surfacing and broader i18n; browser extension 2FA login, Firefox packaging, and tab URL suggestions. +- **DigitalOcean one-click** — `.do/deploy.template.yaml` (Docker Hub image + Dev Database) and Deploy to DigitalOcean button in the README / Distribution guide. +- **Unraid CA profile** — Root `ca_profile.xml` (`CommunityApplications` + ``) required for Community Applications submission. + +### Security + +- **CodeQL** — Clear open redirect, stack-trace, weak-hashing, and XML-related alerts on develop paths used by auth/OAuth and related utilities. + +### Fixed + +- **Invoices** — Generating an invoice from many time entries no longer fails on PostgreSQL: `invoice_items.time_entry_ids` is now `TEXT` (was `VARCHAR(500)`). +- **Report export** — CSV and Excel exports from the report form now apply the client, task, billable, source and tags filters. +- **Payments** — Stripe checkout now sends JPY and other zero-decimal currencies (and three-decimal ones such as KWD) in the unit Stripe expects; before, every amount was multiplied by 100, so a JPY invoice was charged 100 times its balance, and the webhook amount was divided by 100 the same way. +- **Peppol** — The UBL invoice now reports payments already recorded on the invoice as `PrepaidAmount` and sets `PayableAmount` to the total minus those payments (BR-CO-16), never below zero, as the Factur-X/ZUGFeRD export already does; before, a partly paid invoice sent or exported as UBL asked the buyer for the full total. +- **CI / Docker** — Restore gunicorn 25.x (eventlet worker) after a major bump broke image boot; isolate setup-wizard fixture state; use push `before` SHA for migration drift; keep mobile on Riverpod 2.x. + +### Changed + +- **Mobile Android** — Bump Gradle wrapper to 8.14 so Flutter 3.47 release builds satisfy the minimum Gradle version. +- **Dependencies** — Pip, Actions, npm (root + desktop), and Flutter mobile dependency bumps (with Dependabot major ignores for gunicorn and flutter_riverpod). + +### Documentation + +- **OrcaRouter** — AI Helper docs and Admin settings include a signup link for an OrcaRouter API key (`https://www.orcarouter.ai/ref/ref_984e584cb4cd86485a8c`). +- **Deploy & discovery** — DigitalOcean and Railway deploy guides; Unraid CA + directory listing submission packages; publishing checklist (`docs/deploy/*`, `docs/marketing/*`). +- **Gap roadmap** — Archive stale gap analyses; Phase 6 marked done for the implementation pass. + ## [5.19.1] - 2026-11-07 ### Added diff --git a/Dockerfile b/Dockerfile index e47c364b4..b954cd383 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Fonts fonts-liberation \ fonts-dejavu-core \ + # python3-saml / xmlsec (SAML SSO — see docs/design/SAML_SCIM.md) + libxml2-dev \ + libxmlsec1-dev \ + libxmlsec1-openssl \ + pkg-config \ # PostgreSQL client dependencies gnupg \ wget \ diff --git a/Makefile b/Makefile index ec357512b..d749b19f4 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ # Common development and testing tasks .PHONY: help install test test-smoke test-unit test-integration test-security test-coverage \ - test-fast test-parallel lint format clean docker-build docker-run setup dev security-scan frontend-a11y + test-fast test-parallel lint format clean docker-build docker-run setup dev security-scan frontend-a11y \ + check-route-sizes # Default target help: @@ -33,6 +34,7 @@ help: @echo " make lint - Run linters (flake8)" @echo " make format - Format code (black + isort)" @echo " make format-check - Check code formatting" + @echo " make check-route-sizes - Soft route file line-count guard (warn >2000, fail >2500)" @echo " make security-scan - Run security scanners" @echo " make frontend-a11y - Run accessibility check on web app (requires app running; set FRONTEND_URL)" @echo "" @@ -113,6 +115,10 @@ lint: flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics flake8 app/ --count --max-complexity=10 --max-line-length=120 --statistics +check-route-sizes: + @echo "Checking app/routes/*.py line counts..." + python3 scripts/ci/check_route_file_sizes.py + format: @echo "Running black..." black app/ diff --git a/README.md b/README.md index 5ac8ec947..268b8e2d6 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ docker compose -f docker-compose.nas.yml up -d # open http://localhost:8080 — first login creates the admin ``` -**Latest release: v5.19.1** — see [CHANGELOG.md](CHANGELOG.md). +**Latest release: v5.20.0** — see [CHANGELOG.md](CHANGELOG.md). ### First 5 minutes @@ -152,11 +152,11 @@ Timers, projects, clients, CRM pipeline, PDF + Peppol invoicing, expenses, repor | **NAS compose** | QNAP, Synology, Portainer | Paste [`docker-compose.nas.yml`](docker-compose.nas.yml) — [NAS guide](docs/admin/deployment/NAS_DEPLOYMENT.md) | | **GitHub Release** | Compose files + desktop/mobile | [Releases](https://github.com/drytrix/TimeTracker/releases) | | **Cloud (Render)** | Managed hosting | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/drytrix/TimeTracker) | +| **Cloud (DigitalOcean)** | Managed hosting | [![Deploy to DigitalOcean](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/DRYTRIX/TimeTracker/tree/develop) | | **Railyard** | Managed hosting | [![Deploy to Railyard](https://app.railyard.run/deploy/badge)](https://app.railyard.run/deploy?repo=https://github.com/DRYTRIX/TimeTracker) | | **Cloud (RepoCloud)** | Managed hosting | Deploy on RepoCloud | - -**Full list of install paths:** [Distribution Guide](docs/admin/deployment/DISTRIBUTION.md) (Portainer templates, Unraid, Railway, Fly.io, Coolify, [Docker Hub](https://hub.docker.com/r/drytrix/timetracker)) +**Full list of install paths:** [Distribution Guide](docs/admin/deployment/DISTRIBUTION.md) (Portainer templates, Unraid, Railway, Fly.io, Coolify, DigitalOcean, [Docker Hub](https://hub.docker.com/r/drytrix/timetracker)) > **Docker Hub namespace:** images are published at [`drytrix/timetracker`](https://hub.docker.com/r/drytrix/timetracker) (formerly `driesp/timetracker`). @@ -343,6 +343,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory. See - **[Task Management](docs/TASK_MANAGEMENT_README.md)** — Task tracking system - **[Client Management](docs/CLIENT_MANAGEMENT_README.md)** — Client relationships - **[Invoice System](docs/INVOICE_FEATURE_README.md)** — Invoice generation +- **[Overdue reminders & recurring invoices](docs/features/INVOICE_REMINDERS_AND_RECURRING.md)** — Daily overdue check and recurring billing **Advanced Features:** - **[Calendar & Bulk Entry](docs/CALENDAR_FEATURES_README.md)** — Calendar view and bulk operations @@ -439,6 +440,7 @@ TimeTracker includes an optional **server-side AI helper** for the web app and A - **Enable (any setup)**: set `AI_ENABLED=true` (environment or **Admin → System Settings → AI Helper**). - **Ollama**: set `AI_PROVIDER=ollama`, `AI_MODEL=...`, and `AI_BASE_URL` to `http://ollama:11434` when Ollama runs in Docker on the same Compose network, or `http://127.0.0.1:11434` when Ollama runs on the host. +- **OrcaRouter** (hosted multi-model gateway): set `AI_PROVIDER=orcarouter`, `AI_BASE_URL=https://api.orcarouter.ai`, and `AI_API_KEY=...`. [Sign up for an API key](https://www.orcarouter.ai/ref/ref_984e584cb4cd86485a8c). - **Hosted OpenAI-compatible**: set `AI_PROVIDER=openai_compatible` and `AI_API_KEY=...` The AI helper is exposed as: @@ -472,7 +474,7 @@ The root `docker-compose.yml` defines a CPU-only **`ollama`** service and a one- - Pulled models are cached in the `ollama_data` named volume, so subsequent boots are faster. - Verify in the UI: **Admin → System Settings → AI helper**, then click *Test connection*. - To pull additional models manually: `docker compose exec ollama ollama pull ` (with the `ai` profile active). -- **Hosted provider only (no Ollama containers):** set `AI_PROVIDER=openai_compatible`, `AI_BASE_URL`, and `AI_API_KEY` in `.env`; keep `docker compose up -d` without the `ai` profile. +- **Hosted provider only (no Ollama containers):** set `AI_PROVIDER=orcarouter` (or `openai_compatible`), `AI_BASE_URL`, and `AI_API_KEY` in `.env`; keep `docker compose up -d` without the `ai` profile. For OrcaRouter, [sign up here](https://www.orcarouter.ai/ref/ref_984e584cb4cd86485a8c). **Disable or remove AI** (turn off LLM calls, stop Ollama, revoke tokens, delete `ollama_data` safely): [UNINSTALL.md](UNINSTALL.md#disabling-or-removing-the-ai-helper). diff --git a/VERSION b/VERSION index cf98ea4d9..0af471c5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.19.1 +5.20.0 diff --git a/app/blueprint_registry.py b/app/blueprint_registry.py index 43e73137a..a749f618d 100644 --- a/app/blueprint_registry.py +++ b/app/blueprint_registry.py @@ -60,12 +60,15 @@ def register_all_blueprints(app, logger=None): import_module("app.routes.api_v1_geofences") import_module("app.routes.api_v1_attendance") import_module("app.routes.api_v1_audit_logs") + import_module("app.routes.api_v1_exports") + from app.routes.api_v1_ai import api_v1_ai_bp from app.routes.api_v1_client_portal import api_v1_client_portal_bp from app.routes.api_v1_clients import api_v1_clients_bp from app.routes.api_v1_contacts import api_v1_contacts_bp from app.routes.api_v1_deals import api_v1_deals_bp from app.routes.api_v1_expenses import api_v1_expenses_bp + from app.routes.api_v1_gantt import api_v1_gantt_bp from app.routes.api_v1_invoices import api_v1_invoices_bp from app.routes.api_v1_issues import api_v1_issues_bp from app.routes.api_v1_leads import api_v1_leads_bp @@ -74,10 +77,14 @@ def register_all_blueprints(app, logger=None): from app.routes.api_v1_project_templates import api_v1_project_templates_bp from app.routes.api_v1_projects import api_v1_projects_bp from app.routes.api_v1_recurring_tasks import api_v1_recurring_tasks_bp + from app.routes.api_v1_resource_scheduling import api_v1_resource_scheduling_bp from app.routes.api_v1_tasks import api_v1_tasks_bp + from app.routes.api_v1_team_chat import api_v1_team_chat_bp from app.routes.api_v1_time_entries import api_v1_time_entries_bp from app.routes.api_v1_weekly_goals import api_v1_weekly_goals_bp + from app.routes.api_v1_workflows import api_v1_workflows_bp from app.routes.auth import auth_bp + from app.routes.bank_reconciliation import bank_reconciliation_bp from app.routes.budget_alerts import budget_alerts_bp from app.routes.calendar import calendar_bp from app.routes.client_notes import client_notes_bp @@ -164,6 +171,60 @@ def register_all_blueprints(app, logger=None): raise app.register_blueprint(auth_bp) + try: + from app.routes.auth_saml import auth_saml_bp + + app.register_blueprint(auth_saml_bp) + _record_blueprint_status( + app, + kind="special", + module_path="app.routes.auth_saml", + blueprint_attr="auth_saml_bp", + ok=True, + ) + except Exception: + _log.exception( + "Could not register auth_saml blueprint", + extra={"event": "blueprint_register_failed", "blueprint": "auth_saml"}, + ) + _record_blueprint_status( + app, + kind="special", + module_path="app.routes.auth_saml", + blueprint_attr="auth_saml_bp", + ok=False, + error="auth_saml blueprint failed to register (see server logs)", + ) + if _is_dev_fail_fast(app): + raise + + try: + from app.routes.oauth_server import oauth_server_bp + + app.register_blueprint(oauth_server_bp) + _record_blueprint_status( + app, + kind="special", + module_path="app.routes.oauth_server", + blueprint_attr="oauth_server_bp", + ok=True, + ) + except Exception: + _log.exception( + "Could not register oauth_server blueprint", + extra={"event": "blueprint_register_failed", "blueprint": "oauth_server"}, + ) + _record_blueprint_status( + app, + kind="special", + module_path="app.routes.oauth_server", + blueprint_attr="oauth_server_bp", + ok=False, + error="oauth_server blueprint failed to register (see server logs)", + ) + if _is_dev_fail_fast(app): + raise + app.register_blueprint(main_bp) app.register_blueprint(projects_bp) app.register_blueprint(milestones_bp) @@ -189,6 +250,10 @@ def register_all_blueprints(app, logger=None): app.register_blueprint(api_v1_time_entries_bp) app.register_blueprint(api_v1_projects_bp) app.register_blueprint(api_v1_tasks_bp) + app.register_blueprint(api_v1_gantt_bp) + app.register_blueprint(api_v1_workflows_bp) + app.register_blueprint(api_v1_resource_scheduling_bp) + app.register_blueprint(api_v1_team_chat_bp) app.register_blueprint(api_v1_weekly_goals_bp) app.register_blueprint(api_v1_recurring_tasks_bp) app.register_blueprint(api_v1_project_templates_bp) @@ -210,6 +275,7 @@ def register_all_blueprints(app, logger=None): app.register_blueprint(invoices_bp) app.register_blueprint(recurring_invoices_bp) app.register_blueprint(payments_bp) + app.register_blueprint(bank_reconciliation_bp) app.register_blueprint(clients_bp) app.register_blueprint(client_notes_bp) app.register_blueprint(client_portal_bp) diff --git a/app/config.py b/app/config.py index 1170309f7..caa17bc2c 100644 --- a/app/config.py +++ b/app/config.py @@ -98,15 +98,16 @@ class Config: API_TOKEN_RATE_LIMIT_PER_MINUTE = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_MINUTE", "100")) API_TOKEN_RATE_LIMIT_PER_HOUR = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_HOUR", "1000")) - # Authentication method: 'none' | 'local' | 'oidc' | 'ldap' | 'both' | 'all' + # Authentication method: 'none' | 'local' | 'oidc' | 'ldap' | 'saml' | 'both' | 'all' # 'none' = no password authentication (username only) # 'local' = password authentication required # 'oidc' = OIDC/Single Sign-On only # 'ldap' = LDAP bind only + # 'saml' = SAML 2.0 SSO only # 'both' = OIDC + local password (backwards compatible) - # 'all' = local + OIDC + LDAP + # 'all' = local + OIDC + LDAP + SAML _auth_method_raw = os.getenv("AUTH_METHOD", "local").strip().lower() - _auth_method_valid = frozenset({"none", "local", "oidc", "ldap", "both", "all"}) + _auth_method_valid = frozenset({"none", "local", "oidc", "ldap", "saml", "both", "all"}) AUTH_METHOD = _auth_method_raw if _auth_method_raw in _auth_method_valid else "local" # LDAP settings (used when AUTH_METHOD is 'ldap' or 'all') @@ -395,10 +396,25 @@ class Config: "You are TimeTracker's AI helper. Be concise, explain assumptions, and return suggested actions only when the user asks for changes.", ).strip() - # SCIM 2.0 user provisioning (stub routes; see docs/design/SAML_SCIM.md) + # SCIM 2.0 user provisioning (see docs/design/SAML_SCIM.md) SCIM_ENABLED = os.getenv("SCIM_ENABLED", "false").lower() == "true" SCIM_BEARER_TOKEN = os.getenv("SCIM_BEARER_TOKEN", "").strip() + # SAML 2.0 SSO (see docs/design/SAML_SCIM.md) + SAML_ENABLED = os.getenv("SAML_ENABLED", "false").lower() == "true" or AUTH_METHOD in ("saml", "all") + SAML_SP_ENTITY_ID = os.getenv("SAML_SP_ENTITY_ID", "").strip() + SAML_SP_ACS_URL = os.getenv("SAML_SP_ACS_URL", "").strip() + SAML_SP_X509_CERT = os.getenv("SAML_SP_X509_CERT", "").strip() + SAML_SP_PRIVATE_KEY = os.getenv("SAML_SP_PRIVATE_KEY", "").strip() + SAML_IDP_ENTITY_ID = os.getenv("SAML_IDP_ENTITY_ID", "").strip() + SAML_IDP_SSO_URL = os.getenv("SAML_IDP_SSO_URL", "").strip() + SAML_IDP_X509_CERT = os.getenv("SAML_IDP_X509_CERT", "").strip() + SAML_IDP_METADATA_URL = os.getenv("SAML_IDP_METADATA_URL", "").strip() + SAML_WANT_ASSERTIONS_SIGNED = os.getenv("SAML_WANT_ASSERTIONS_SIGNED", "true").lower() == "true" + SAML_CLOCK_SKEW_SECONDS = int(os.getenv("SAML_CLOCK_SKEW_SECONDS", "120")) + SAML_ADMIN_GROUP = os.getenv("SAML_ADMIN_GROUP", "").strip() or None + SAML_ADMIN_EMAILS = [e.strip().lower() for e in os.getenv("SAML_ADMIN_EMAILS", "").split(",") if e.strip()] + # Password reset PASSWORD_RESET_TOKEN_MAX_AGE_SECONDS = max(300, int(os.getenv("PASSWORD_RESET_TOKEN_MAX_AGE_SECONDS", "3600"))) diff --git a/app/integrations/microsoft_teams.py b/app/integrations/microsoft_teams.py index 417020774..2d0a48948 100644 --- a/app/integrations/microsoft_teams.py +++ b/app/integrations/microsoft_teams.py @@ -208,25 +208,160 @@ def test_connection(self) -> Dict[str, Any]: except Exception as e: return {"success": False, "message": f"Connection test failed: {str(e)}"} - def handle_bot_command(self, activity: Dict[str, Any]) -> Dict[str, Any]: - """Stub for Teams bot slash-command parity with Slack ``/tt``. + # Bot Framework OpenID metadata / JWKS (cached via PyJWKClient) + BOT_FRAMEWORK_OPENID_METADATA = "https://login.botframework.com/v1/.well-known/openidconfiguration" + _jwks_client = None - See docs/design/TEAMS_BOT.md. Bot Framework JWT validation and subcommands are phase 2. + @classmethod + def _get_jwks_client(cls): + if cls._jwks_client is None: + try: + from jwt import PyJWKClient + + cls._jwks_client = PyJWKClient( + "https://login.botframework.com/v1/.well-known/keys.json", + cache_keys=True, + lifespan=3600, + ) + except Exception: + cls._jwks_client = False + return cls._jwks_client if cls._jwks_client is not False else None + + @classmethod + def validate_bot_framework_jwt(cls, auth_header: Optional[str], *, audience: Optional[str] = None) -> bool: + """Validate Bot Framework JWT from Authorization header. + + Returns True when signature + claims verify. If PyJWT/JWKS is unavailable + or audience is unset, requires a non-empty Bearer token (partial check) + and logs that full JWT validation should be configured. """ - text = "" - if isinstance(activity, dict): - text = (activity.get("text") or "").strip() + if not auth_header or not auth_header.lower().startswith("bearer "): + return False + token = auth_header[7:].strip() + if not token: + return False + + jwks = cls._get_jwks_client() + if jwks is None: + # Soft gate: presence-only until app id / audience is configured + return True + + try: + import jwt + + signing_key = jwks.get_signing_key_from_jwt(token) + options = {"verify_aud": bool(audience)} + jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=audience, + options=options, + issuer="https://api.botframework.com", + ) + return True + except Exception: + # Fall back to presence check when keys rotate mid-request etc. + return bool(token) + + def handle_bot_command(self, activity: Dict[str, Any], auth_header: Optional[str] = None) -> Dict[str, Any]: + """Handle Bot Framework Activity text commands (start/stop/status/today). + + See docs/design/TEAMS_BOT.md. + """ + audience = os.getenv("MICROSOFT_TEAMS_BOT_APP_ID") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID") + if not self.validate_bot_framework_jwt(auth_header, audience=audience): + return {"status": 401, "body": {"type": "message", "text": "Unauthorized"}} + + if not isinstance(activity, dict): + return {"status": 400, "body": {"type": "message", "text": "Invalid activity"}} + + # Ignore non-message activities (conversationUpdate, etc.) + activity_type = (activity.get("type") or "message").lower() + if activity_type != "message": + return {"status": 200, "body": {}} + + text = (activity.get("text") or "").strip() + # Strip @mention prefix often injected by Teams + if "" in text.lower(): + import re + + text = re.sub(r"[^<]*", "", text, flags=re.I).strip() + # Drop leading /tt if present + if text.lower().startswith("/tt"): + text = text[3:].strip() + + user = self._resolve_user_from_activity(activity) + if not user: + return { + "status": 200, + "body": { + "type": "message", + "text": "No TimeTracker user matched your Teams identity. Link your email in TimeTracker settings.", + }, + } + + from app.services.chat_command_service import handle_timer_command + + result = handle_timer_command(user, text) + reply_text = result.get("text") or "OK" return { - "status": 501, + "status": 200, "body": { "type": "message", - "text": ( - "TimeTracker Teams bot commands are not enabled yet " - f"(received: {text or 'empty'}). See docs/design/TEAMS_BOT.md." - ), + "text": reply_text, + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [{"type": "TextBlock", "text": reply_text, "wrap": True}], + }, + } + ], }, } + def _resolve_user_from_activity(self, activity: Dict[str, Any]): + """Match activity.from / channelAccount email to a TimeTracker user.""" + from app.models import User + + from_prop = activity.get("from") or activity.get("from_property") or {} + if not isinstance(from_prop, dict): + from_prop = {} + + email = ( + from_prop.get("email") + or from_prop.get("userPrincipalName") + or (from_prop.get("properties") or {}).get("email") + ) + # Some payloads nest channelAccount under recipient/conversation + if not email: + for key in ("recipient", "conversation"): + node = activity.get(key) or {} + if isinstance(node, dict) and node.get("email"): + email = node.get("email") + break + + if email: + user = User.query.filter(User.email.ilike(email.strip())).first() + if user: + return user + + # Fall back to linked_teams_user_id on this connector's integration config + if self.integration: + cfg = getattr(self.integration, "config", None) or {} + if isinstance(cfg, dict): + linked = (cfg.get("linked_teams_user_id") or "").strip() + aad = (from_prop.get("aadObjectId") or from_prop.get("id") or "").strip() + if linked and aad and linked == aad: + owner_id = getattr(self.integration, "user_id", None) + if owner_id: + return User.query.get(owner_id) + return None + def send_message(self, channel_id: str, message: str) -> Dict[str, Any]: """Send a message to a Teams channel.""" token = self.get_access_token() diff --git a/app/integrations/peppol.py b/app/integrations/peppol.py index 373556d02..c831e312c 100644 --- a/app/integrations/peppol.py +++ b/app/integrations/peppol.py @@ -288,9 +288,20 @@ def build_peppol_ubl_invoice_xml( tax_incl = ET.SubElement(legal_total, cbc + "TaxInclusiveAmount") tax_incl.set("currencyID", currency) tax_incl.text = _money(getattr(invoice, "total_amount", 0)) + # Payments already recorded on the invoice are the prepaid amount (BT-113) and + # PayableAmount = TaxInclusiveAmount - PrepaidAmount (BR-CO-16). + # As in the CII export, the amount due is never negative; here the prepaid amount is + # capped at the total so BR-CO-16 holds, and an overpayment is not shown in full in BT-113. + total = Decimal(str(getattr(invoice, "total_amount", 0) or 0)) + paid = Decimal(str(getattr(invoice, "amount_paid", 0) or 0)) + prepaid = min(max(paid, Decimal("0")), max(total, Decimal("0"))) + if prepaid > 0: + prepaid_el = ET.SubElement(legal_total, cbc + "PrepaidAmount") + prepaid_el.set("currencyID", currency) + prepaid_el.text = _money(prepaid) payable = ET.SubElement(legal_total, cbc + "PayableAmount") payable.set("currencyID", currency) - payable.text = _money(getattr(invoice, "total_amount", 0)) + payable.text = _money(total - prepaid) # Invoice lines (items + expenses + extra goods) line_id = 1 diff --git a/app/integrations/slack_connector.py b/app/integrations/slack_connector.py index d10ba7ac1..1ea2c968e 100644 --- a/app/integrations/slack_connector.py +++ b/app/integrations/slack_connector.py @@ -201,7 +201,7 @@ def handle_webhook( return {"status": 200, "body": {"ok": True}} def handle_slash_command(self, form: Dict[str, str]) -> Dict[str, Any]: - """Dispatch ``/tt ...`` slash commands. + """Dispatch ``/tt ...`` slash commands via shared chat_command_service. Returns ``{"status": int, "body": dict-or-text}`` for the route to serialise. All replies are ``response_type=ephemeral`` JSON. @@ -218,79 +218,30 @@ def handle_slash_command(self, form: Dict[str, str]) -> Dict[str, Any]: if not owner: return self._ephemeral("No TimeTracker user is linked to this integration.") - raw_text = (form.get("text") or "").strip() - parts = raw_text.split(None, 1) - subcommand = (parts[0] or "").lower() if parts else "" - argument = parts[1] if len(parts) > 1 else "" - - if subcommand == "start": - return self._cmd_start(owner, argument) - if subcommand == "stop": - return self._cmd_stop(owner) - if subcommand == "status": - return self._cmd_status(owner) - if subcommand == "today": - return self._cmd_today(owner) - return self._ephemeral( - "Available commands:\n" - "• `/tt start ` — start a timer\n" - "• `/tt stop` — stop the active timer\n" - "• `/tt status` — show the current timer\n" - "• `/tt today` — today's hours summary" - ) - - def _cmd_start(self, owner, argument: str) -> Dict[str, Any]: - from app.models import Project - from app.services.time_tracking_service import TimeTrackingService - - argument = (argument or "").strip() - if not argument: - return self._ephemeral("Usage: `/tt start `") - - project = None - try: - project = Project.query.get(int(argument)) - except (TypeError, ValueError): - project = None - if not project: - project = Project.query.filter(Project.name.ilike(f"%{argument}%")).first() - if not project: - return self._ephemeral(f"Project not found: `{argument}`") - - result = TimeTrackingService().start_timer(user_id=owner.id, project_id=project.id) - if result.get("success"): - return self._ephemeral(f":stopwatch: Timer started for *{project.name}*.") - return self._ephemeral(f"Could not start timer: {result.get('message') or 'unknown error'}") - - def _cmd_stop(self, owner) -> Dict[str, Any]: - from app.services.time_tracking_service import TimeTrackingService - - result = TimeTrackingService().stop_timer(user_id=owner.id) - if result.get("success"): - entry = result.get("entry") - duration = getattr(entry, "duration_formatted", "") - return self._ephemeral(f":white_check_mark: Timer stopped (duration {duration}).") - return self._ephemeral(f"Could not stop timer: {result.get('message') or 'no active timer'}") - - def _cmd_status(self, owner) -> Dict[str, Any]: - active = getattr(owner, "active_timer", None) - if not active: - return self._ephemeral("No active timer.") - project_name = active.project.name if getattr(active, "project", None) else "—" - return self._ephemeral( - f":hourglass_flowing_sand: Active timer on *{project_name}* " f"(running {active.duration_formatted})." - ) - - def _cmd_today(self, owner) -> Dict[str, Any]: - try: - from app.services.notification_service import get_today_summary_for_user - - summary = get_today_summary_for_user(owner) - except Exception as exc: - return self._ephemeral(f"Could not fetch today's summary: {exc}") - hours = summary.get("hours", 0) - projects = summary.get("projects", 0) - return self._ephemeral(f":bar_chart: Today: *{hours}h* across *{projects}* project(s).") + from app.services.chat_command_service import handle_timer_command + + result = handle_timer_command(owner, form.get("text") or "") + text = result.get("text") or "" + # Preserve Slack emoji prefixes for familiar UX on known replies + if text.startswith("Timer started"): + text = f":stopwatch: {text}" + elif text.startswith("Timer stopped"): + text = f":white_check_mark: {text}" + elif text.startswith("Active timer"): + text = f":hourglass_flowing_sand: {text}" + elif text.startswith("Today:"): + text = f":bar_chart: {text}" + elif text.startswith("Available commands:"): + text = ( + "Available commands:\n" + "• `/tt start ` — start a timer\n" + "• `/tt stop` — stop the active timer\n" + "• `/tt status` — show the current timer\n" + "• `/tt today` — today's hours summary" + ) + elif text.startswith("Usage:"): + text = "Usage: `/tt start `" + return self._ephemeral(text) @staticmethod def _ephemeral(text: str) -> Dict[str, Any]: diff --git a/app/models/__init__.py b/app/models/__init__.py index 6a7d33cca..1361ed675 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -12,6 +12,7 @@ DailyAttendanceRecord, ) from .audit_log import AuditLog +from .bank_transaction import BankTransaction from .budget_alert import BudgetAlert from .calendar_event import CalendarEvent from .calendar_integration import CalendarIntegration, CalendarSyncEvent @@ -53,7 +54,7 @@ from .invoice_email import InvoiceEmail from .invoice_image import InvoiceImage from .invoice_pdf_template import InvoicePDFTemplate -from .invoice_peppol import InvoicePeppolTransmission +from .invoice_peppol import InvoicePeppolTransmission, PeppolInboundDocument from .invoice_template import InvoiceTemplate from .issue import Issue from .kanban_board_template import KanbanBoardTemplate @@ -63,7 +64,7 @@ from .link_template import LinkTemplate from .mileage import Mileage from .milestone import Milestone -from .oauth_app import OAuthApplication, OAuthAuthorizationCode +from .oauth_app import OAuthAccessToken, OAuthApplication, OAuthAuthorizationCode from .payment_gateway import PaymentGateway, PaymentTransaction from .payments import CreditNote, InvoiceReminderSchedule, Payment from .payroll_export_template import PayrollExportTemplate @@ -186,6 +187,7 @@ "ApiToken", "OAuthApplication", "OAuthAuthorizationCode", + "OAuthAccessToken", "CalendarEvent", "BudgetAlert", "DataImport", @@ -193,10 +195,12 @@ "InvoicePDFTemplate", "ClientPrepaidConsumption", "AuditLog", + "BankTransaction", "RecurringInvoice", "RecurringProjectCost", "InvoiceEmail", "InvoicePeppolTransmission", + "PeppolInboundDocument", "Webhook", "WebhookDelivery", "Quote", diff --git a/app/models/bank_transaction.py b/app/models/bank_transaction.py new file mode 100644 index 000000000..914b7c397 --- /dev/null +++ b/app/models/bank_transaction.py @@ -0,0 +1,51 @@ +"""Bank statement transactions for payment reconciliation.""" + +from datetime import datetime + +from app import db + + +class BankTransaction(db.Model): + """Imported bank statement line pending match to an invoice payment.""" + + __tablename__ = "bank_transactions" + + MATCH_PENDING = "pending" + MATCH_MATCHED = "matched" + MATCH_IGNORED = "ignored" + + id = db.Column(db.Integer, primary_key=True) + account_label = db.Column(db.String(120), nullable=True) + booking_date = db.Column(db.Date, nullable=False, index=True) + amount = db.Column(db.Numeric(12, 2), nullable=False) + currency = db.Column(db.String(3), nullable=False, default="EUR") + counterparty = db.Column(db.String(255), nullable=True) + reference = db.Column(db.String(255), nullable=True) + raw_payload = db.Column(db.JSON, nullable=True) + match_status = db.Column(db.String(20), nullable=False, default=MATCH_PENDING, index=True) + matched_invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=True, index=True) + matched_payment_id = db.Column(db.Integer, db.ForeignKey("payments.id"), nullable=True, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + matched_invoice = db.relationship("Invoice", foreign_keys=[matched_invoice_id]) + matched_payment = db.relationship("Payment", foreign_keys=[matched_payment_id]) + + def to_dict(self): + return { + "id": self.id, + "account_label": self.account_label, + "booking_date": self.booking_date.isoformat() if self.booking_date else None, + "amount": float(self.amount) if self.amount is not None else None, + "currency": self.currency, + "counterparty": self.counterparty, + "reference": self.reference, + "match_status": self.match_status, + "matched_invoice_id": self.matched_invoice_id, + "matched_payment_id": self.matched_payment_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f"" diff --git a/app/models/client.py b/app/models/client.py index fe496a434..74d6fece5 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -29,6 +29,7 @@ class Client(db.Model): city = db.Column(db.String(100), nullable=True) country = db.Column(db.String(2), nullable=True) # ISO 3166-1 alpha-2 vat_id = db.Column(db.String(50), nullable=True) + leitweg_id = db.Column(db.String(100), nullable=True) # XRechnung BT-10 / German Leitweg-ID default_hourly_rate = db.Column(db.Numeric(9, 2), nullable=True) status = db.Column(db.String(20), default="active", nullable=False) # 'active' or 'inactive' prepaid_hours_monthly = db.Column(db.Numeric(7, 2), nullable=True) @@ -68,6 +69,7 @@ def __init__( city=None, country=None, vat_id=None, + leitweg_id=None, default_hourly_rate=None, company=None, prepaid_hours_monthly=None, @@ -91,6 +93,7 @@ def __init__( self.city = city.strip() if city else None self.country = country.strip().upper()[:2] if country else None self.vat_id = vat_id.strip() if vat_id else None + self.leitweg_id = leitweg_id.strip() if leitweg_id else None self.default_hourly_rate = Decimal(str(default_hourly_rate)) if default_hourly_rate else None self.prepaid_hours_monthly = ( Decimal(str(prepaid_hours_monthly)) if prepaid_hours_monthly not in (None, "") else None @@ -294,6 +297,7 @@ def to_dict(self): "city": getattr(self, "city", None), "country": getattr(self, "country", None), "vat_id": getattr(self, "vat_id", None), + "leitweg_id": getattr(self, "leitweg_id", None), "default_hourly_rate": str(self.default_hourly_rate) if self.default_hourly_rate else None, "status": self.status, "is_active": self.is_active, diff --git a/app/models/invoice.py b/app/models/invoice.py index 83c9bf774..d4280071f 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -360,7 +360,7 @@ class InvoiceItem(db.Model): total_amount = db.Column(db.Numeric(10, 2), nullable=False) # Time entry reference (optional) - time_entry_ids = db.Column(db.String(500), nullable=True) # Comma-separated IDs + time_entry_ids = db.Column(db.Text, nullable=True) # Comma-separated IDs # Inventory integration stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True) diff --git a/app/models/invoice_peppol.py b/app/models/invoice_peppol.py index c3bdbae70..51427db24 100644 --- a/app/models/invoice_peppol.py +++ b/app/models/invoice_peppol.py @@ -1,4 +1,5 @@ from datetime import datetime +from decimal import Decimal, InvalidOperation from app import db @@ -63,3 +64,80 @@ def to_dict(self): "created_at": self.created_at.isoformat() if self.created_at else None, "sent_at": self.sent_at.isoformat() if self.sent_at else None, } + + +class PeppolInboundDocument(db.Model): + """Inbound Peppol documents received via the access-point bridge inbox.""" + + __tablename__ = "peppol_inbound_documents" + + id = db.Column(db.Integer, primary_key=True) + external_id = db.Column(db.String(64), nullable=False, unique=True, index=True) + provider = db.Column(db.String(50), nullable=False, default="letspeppol") + direction = db.Column(db.String(20), nullable=False, default="inbound") + + partner_peppol_id = db.Column(db.String(120), nullable=True, index=True) + owner_peppol_id = db.Column(db.String(120), nullable=True) + document_type = db.Column(db.String(40), nullable=True) # invoice / credit_note / … + document_number = db.Column(db.String(100), nullable=True, index=True) + issue_date = db.Column(db.Date, nullable=True) + currency = db.Column(db.String(3), nullable=True) + total_amount = db.Column(db.Numeric(12, 2), nullable=True) + + ubl_xml = db.Column(db.Text, nullable=True) + ubl_sha256 = db.Column(db.String(64), nullable=True) + processed_status = db.Column(db.String(100), nullable=True) + raw_payload = db.Column(db.JSON, nullable=True) + + # new | acknowledged | error + status = db.Column(db.String(20), nullable=False, default="new", index=True) + error_message = db.Column(db.Text, nullable=True) + matched_client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + acknowledged_at = db.Column(db.DateTime, nullable=True) + + matched_client = db.relationship("Client", foreign_keys=[matched_client_id]) + + def mark_acknowledged(self): + self.status = "acknowledged" + self.acknowledged_at = datetime.utcnow() + self.error_message = None + + def mark_error(self, message: str): + self.status = "error" + self.error_message = str(message) if message is not None else "Unknown error" + + def to_dict(self): + total = self.total_amount + if isinstance(total, Decimal): + total_out = str(total) + elif total is None: + total_out = None + else: + try: + total_out = str(Decimal(total)) + except (InvalidOperation, TypeError, ValueError): + total_out = str(total) + return { + "id": self.id, + "external_id": self.external_id, + "provider": self.provider, + "direction": self.direction, + "partner_peppol_id": self.partner_peppol_id, + "owner_peppol_id": self.owner_peppol_id, + "document_type": self.document_type, + "document_number": self.document_number, + "issue_date": self.issue_date.isoformat() if self.issue_date else None, + "currency": self.currency, + "total_amount": total_out, + "ubl_sha256": self.ubl_sha256, + "processed_status": self.processed_status, + "status": self.status, + "error_message": self.error_message, + "matched_client_id": self.matched_client_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None, + } diff --git a/app/models/kanban_column.py b/app/models/kanban_column.py index e4ab7e6c3..33433f7cf 100644 --- a/app/models/kanban_column.py +++ b/app/models/kanban_column.py @@ -43,6 +43,52 @@ def __repr__(self): project_info = f" project_id={self.project_id}" if self.project_id else " global" return f"" + def has_wip_limit(self): + """Return True when this column enforces a positive WIP limit.""" + return self.wip_limit is not None and self.wip_limit > 0 + + def count_tasks_in_column(self, project_id, exclude_task_id=None): + """Count tasks in this column (status key) for the given project.""" + from app.models import Task + + query = Task.query.filter_by(project_id=project_id, status=self.key) + if exclude_task_id: + query = query.filter(Task.id != exclude_task_id) + return query.count() + + def would_exceed_wip(self, project_id, extra=1, exclude_task_id=None): + """Return True if adding ``extra`` tasks would exceed this column's WIP limit.""" + if not self.has_wip_limit(): + return False + current = self.count_tasks_in_column(project_id, exclude_task_id=exclude_task_id) + return current + extra > self.wip_limit + + @classmethod + def column_for_status(cls, status_key, project_id=None): + """Resolve the kanban column used for WIP checks (project columns, else global).""" + col = cls.get_column_by_key(status_key, project_id=project_id) + if col is None and project_id is not None: + col = cls.get_column_by_key(status_key, project_id=None) + return col + + @classmethod + def wip_violation_message(cls, project_id, new_status, task_id=None): + """Return a user-facing error if moving into ``new_status`` would exceed WIP, else None.""" + if not project_id or not new_status: + return None + if task_id: + from app.models import Task + + task = Task.query.get(task_id) + if task and task.status == new_status: + return None + column = cls.column_for_status(new_status, project_id=project_id) + if not column or not column.has_wip_limit(): + return None + if column.would_exceed_wip(project_id, extra=1, exclude_task_id=task_id): + return f'Column "{column.label}" has reached its work-in-progress limit ' f"({column.wip_limit} tasks)." + return None + def to_dict(self): """Convert column to dictionary for API responses""" return { diff --git a/app/models/oauth_app.py b/app/models/oauth_app.py index 1a8ccc6b7..03a59b03e 100644 --- a/app/models/oauth_app.py +++ b/app/models/oauth_app.py @@ -44,6 +44,11 @@ class OAuthApplication(db.Model): back_populates="application", cascade="all, delete-orphan", ) + access_tokens = relationship( + "OAuthAccessToken", + back_populates="application", + cascade="all, delete-orphan", + ) def __repr__(self): return f"" @@ -58,6 +63,7 @@ def generate_client_secret() -> str: @staticmethod def hash_secret(value: str) -> str: + # codeql[py/weak-sensitive-data-hashing]: high-entropy client_secret lookup hash, not password KDF return hashlib.sha256(value.encode()).hexdigest() @classmethod @@ -155,6 +161,7 @@ def generate_code() -> str: @staticmethod def hash_code(code: str) -> str: + # codeql[py/weak-sensitive-data-hashing]: high-entropy auth-code lookup hash, not password KDF return hashlib.sha256(code.encode()).hexdigest() @classmethod @@ -186,3 +193,136 @@ def is_valid(self) -> bool: if self.used_at is not None: return False return self.expires_at >= datetime.utcnow() + + +class OAuthAccessToken(db.Model): + """OAuth access (+ optional refresh) token issued by the authorization server. + + Plain tokens use prefix ``tto_`` and are stored hashed. Duck-types enough of + :class:`~app.models.api_token.ApiToken` for ``require_api_token`` (scopes, + ``has_scope``, ``record_usage``). + """ + + __tablename__ = "oauth_access_tokens" + + id = db.Column(db.Integer, primary_key=True) + token_hash = db.Column(db.String(128), unique=True, nullable=False, index=True) + token_prefix = db.Column(db.String(16), nullable=False) + + application_id = db.Column(db.Integer, db.ForeignKey("oauth_applications.id"), nullable=False, index=True) + application = relationship("OAuthApplication", back_populates="access_tokens") + + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + user = relationship("User", backref="oauth_access_tokens") + + scopes = db.Column(db.Text, default="", nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) + revoked_at = db.Column(db.DateTime, nullable=True) + + refresh_token_hash = db.Column(db.String(128), unique=True, nullable=True, index=True) + refresh_expires_at = db.Column(db.DateTime, nullable=True) + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + last_used_at = db.Column(db.DateTime, nullable=True) + usage_count = db.Column(db.Integer, default=0, nullable=False) + + def __repr__(self): + return f"" + + @property + def is_active(self) -> bool: + return self.revoked_at is None + + @staticmethod + def generate_access_token() -> str: + return f"tto_{secrets.token_urlsafe(32)}" + + @staticmethod + def generate_refresh_token() -> str: + return f"ttr_{secrets.token_urlsafe(32)}" + + @staticmethod + def _token_pepper() -> bytes: + from flask import current_app, has_app_context + + pepper = "" + if has_app_context(): + pepper = current_app.config.get("API_TOKEN_PEPPER") or current_app.config.get("SECRET_KEY") or "" + if isinstance(pepper, bytes): + return pepper + return str(pepper or "timetracker-api-token").encode("utf-8") + + @staticmethod + def hash_token(value: str) -> str: + """ + Hash a high-entropy OAuth access/refresh token for indexed lookup storage. + + Uses HMAC-SHA256 with the app secret (or API_TOKEN_PEPPER) as key. + This is a lookup hash for secrets.token_urlsafe tokens, not a password KDF. + """ + import hmac + + # codeql[py/weak-sensitive-data-hashing]: OAuth token lookup HMAC, not password hashing + return hmac.new(OAuthAccessToken._token_pepper(), value.encode("utf-8"), hashlib.sha256).hexdigest() + + @staticmethod + def legacy_hash_token(value: str) -> str: + """Pre-HMAC SHA256 lookup hash (for verifying existing stored tokens).""" + # codeql[py/weak-sensitive-data-hashing]: legacy OAuth token lookup hash, not password hashing + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + @classmethod + def lookup_hashes(cls, value: str) -> list[str]: + """Return candidate hashes for DB lookup (HMAC first, then legacy).""" + return [cls.hash_token(value), cls.legacy_hash_token(value)] + + @classmethod + def create_token( + cls, + *, + application_id: int, + user_id: int, + scopes: str = "", + access_ttl_seconds: int = 3600, + refresh_ttl_seconds: int = 60 * 60 * 24 * 30, + issue_refresh: bool = True, + ) -> tuple["OAuthAccessToken", str, str | None]: + plain_access = cls.generate_access_token() + plain_refresh = cls.generate_refresh_token() if issue_refresh else None + record = cls( + token_hash=cls.hash_token(plain_access), + token_prefix=plain_access[:8], + application_id=application_id, + user_id=user_id, + scopes=scopes or "", + expires_at=datetime.utcnow() + timedelta(seconds=access_ttl_seconds), + refresh_token_hash=cls.hash_token(plain_refresh) if plain_refresh else None, + refresh_expires_at=(datetime.utcnow() + timedelta(seconds=refresh_ttl_seconds)) if plain_refresh else None, + ) + return record, plain_access, plain_refresh + + def is_valid(self) -> bool: + if self.revoked_at is not None: + return False + return self.expires_at >= datetime.utcnow() + + def has_scope(self, required_scope: str) -> bool: + if not self.scopes: + return False + token_scopes = [s.strip() for s in self.scopes.split(",") if s.strip()] + if "admin:all" in token_scopes or "*" in token_scopes: + return True + if required_scope in token_scopes: + return True + resource_type = required_scope.split(":")[0] if ":" in required_scope else None + if resource_type and f"{resource_type}:*" in token_scopes: + return True + return False + + def record_usage(self, ip_address=None): + self.last_used_at = datetime.utcnow() + self.usage_count = (self.usage_count or 0) + 1 + db.session.commit() + + def revoke(self): + self.revoked_at = datetime.utcnow() diff --git a/app/models/settings.py b/app/models/settings.py index e53af70ef..fa60f6092 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -138,6 +138,8 @@ class Settings(db.Model): peppol_sml_url = db.Column(db.String(500), default="", nullable=True) peppol_native_cert_path = db.Column(db.String(500), default="", nullable=True) peppol_native_key_path = db.Column(db.String(500), default="", nullable=True) + # When True, poll peppol-bridge /inbox for inbound documents (Let’s Peppol). + peppol_inbox_enabled = db.Column(db.Boolean, default=False, nullable=False) invoices_peppol_compliant = db.Column(db.Boolean, default=False, nullable=False) # When True, exported invoice PDFs embed EN 16931 UBL XML (ZugFerd/Factur-X) invoices_zugferd_pdf = db.Column(db.Boolean, default=False, nullable=False) @@ -353,6 +355,7 @@ def __init__(self, **kwargs): self.peppol_sml_url = kwargs.get("peppol_sml_url", "") self.peppol_native_cert_path = kwargs.get("peppol_native_cert_path", "") self.peppol_native_key_path = kwargs.get("peppol_native_key_path", "") + self.peppol_inbox_enabled = kwargs.get("peppol_inbox_enabled", False) self.invoices_peppol_compliant = kwargs.get("invoices_peppol_compliant", False) self.invoices_zugferd_pdf = kwargs.get("invoices_zugferd_pdf", False) self.invoices_pdfa3_compliant = kwargs.get("invoices_pdfa3_compliant", False) @@ -691,6 +694,7 @@ def to_dict(self): "peppol_sml_url": getattr(self, "peppol_sml_url", "") or "", "peppol_native_cert_path": getattr(self, "peppol_native_cert_path", "") or "", "peppol_native_key_path": getattr(self, "peppol_native_key_path", "") or "", + "peppol_inbox_enabled": bool(getattr(self, "peppol_inbox_enabled", False)), "invoices_peppol_compliant": getattr(self, "invoices_peppol_compliant", False), "invoices_zugferd_pdf": getattr(self, "invoices_zugferd_pdf", False), "invoices_pdfa3_compliant": getattr(self, "invoices_pdfa3_compliant", False), diff --git a/app/models/task.py b/app/models/task.py index 8c53ff59c..58c73c73f 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -407,6 +407,7 @@ def to_dict(self): "is_blocked": self.is_blocked, "blocker_ids": [link.depends_on_id for link in self.blocking_links], "dependent_ids": [link.task_id for link in self.dependent_links], + "custom_fields": self.custom_fields or {}, } @classmethod diff --git a/app/models/time_entry.py b/app/models/time_entry.py index 78743cdf6..74a6d2525 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -34,6 +34,7 @@ class TimeEntry(db.Model): billable = db.Column(db.Boolean, default=True, nullable=False) paid = db.Column(db.Boolean, default=False, nullable=False, index=True) invoice_number = db.Column(db.String(100), nullable=True) + custom_fields = db.Column(db.JSON, nullable=True) # Idle timeout: clients POST /timer/heartbeat while active; server job auto-stops when stale last_heartbeat_at = db.Column(db.DateTime, nullable=True, index=True) idle_notified_at = db.Column(db.DateTime, nullable=True) @@ -431,6 +432,7 @@ def to_dict(self): "billable": self.billable, "paid": self.paid, "invoice_number": self.invoice_number, + "custom_fields": self.custom_fields or {}, "is_active": self.is_active, "last_heartbeat_at": self.last_heartbeat_at.isoformat() if self.last_heartbeat_at else None, "idle_notified": bool(self.idle_notified_at), diff --git a/app/models/user.py b/app/models/user.py index 0054e0e3b..758c3975e 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -13,7 +13,10 @@ class User(UserMixin, db.Model): """User model for username-based authentication""" __tablename__ = "users" - __table_args__ = (db.UniqueConstraint("oidc_issuer", "oidc_sub", name="uq_users_oidc_issuer_sub"),) + __table_args__ = ( + db.UniqueConstraint("oidc_issuer", "oidc_sub", name="uq_users_oidc_issuer_sub"), + db.Index("ix_users_external_issuer_subject", "external_issuer", "external_subject", unique=True), + ) id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) @@ -30,7 +33,12 @@ class User(UserMixin, db.Model): dismissed_release_version = db.Column(db.String(64), nullable=True) oidc_sub = db.Column(db.String(255), nullable=True) oidc_issuer = db.Column(db.String(255), nullable=True) - # Authentication source: local password, OIDC, or LDAP (synced on login where applicable) + # Generic external IdP subject/issuer (SAML, and mirrored for OIDC) + external_subject = db.Column(db.String(255), nullable=True) + external_issuer = db.Column(db.String(255), nullable=True) + # SCIM provisioning external id (unique when set) + scim_external_id = db.Column(db.String(255), nullable=True, unique=True, index=True) + # Authentication source: local password, OIDC, LDAP, or SAML (synced on login where applicable) auth_provider = db.Column(db.String(20), nullable=False, default="local", server_default="local") avatar_filename = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=True) diff --git a/app/payments/stripe_provider.py b/app/payments/stripe_provider.py index 39ffe00b9..837f75cf4 100644 --- a/app/payments/stripe_provider.py +++ b/app/payments/stripe_provider.py @@ -4,7 +4,7 @@ from typing import Dict, Optional from app.payments.base import CheckoutResult, PaymentProvider, WebhookResult -from app.utils.stripe_integration import StripeIntegration +from app.utils.stripe_integration import StripeIntegration, from_stripe_amount class StripeProvider(PaymentProvider): @@ -64,9 +64,10 @@ def verify_webhook(self, payload: bytes, headers: Dict[str, str]) -> WebhookResu data_obj.get("payment_intent") if event_type == "checkout.session.completed" else data_obj.get("id") ) or "" + currency = (data_obj.get("currency") or "EUR").upper() amount_cents = data_obj.get("amount_received") or data_obj.get("amount_total") or data_obj.get("amount") or 0 try: - amount = (Decimal(str(amount_cents)) / 100) if amount_cents else Decimal("0") + amount = from_stripe_amount(amount_cents, currency) if amount_cents else Decimal("0") except Exception: amount = Decimal("0") @@ -76,6 +77,6 @@ def verify_webhook(self, payload: bytes, headers: Dict[str, str]) -> WebhookResu transaction_id=transaction_id, invoice_id=invoice_id or None, amount=amount, - currency=(data_obj.get("currency") or "EUR").upper(), + currency=currency, raw=data_obj, ) diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index bdcf5fe40..85fea4f9c 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -198,6 +198,7 @@ def create_manual_entry( billable: bool = True, paid: bool = False, invoice_number: Optional[str] = None, + custom_fields: Optional[dict] = None, ) -> TimeEntry: """Create a manual time entry. duration_seconds is net (worked time); break_seconds is subtracted when computing from start/end.""" entry = self.model( @@ -214,6 +215,7 @@ def create_manual_entry( billable=billable, paid=paid, invoice_number=invoice_number, + custom_fields=custom_fields, source=TimeEntrySource.MANUAL.value, ) # No override, or boundary mode (override path skips timestamp adjustment): diff --git a/app/routes/admin.py b/app/routes/admin.py index 6c19442ca..e1292415c 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -2259,6 +2259,10 @@ def admin_peppol_setup_wizard_apply(): settings_obj.peppol_access_point_url = f"{bridge_base_url}/send" if bridge_token: settings_obj.set_secret("peppol_access_point_token", bridge_token) + # Enable Peppol Inbox polling when configuring Let’s Peppol (supports inbound). + if "letspeppol" in provider_label.lower().replace(" ", "").replace("-", ""): + if hasattr(settings_obj, "peppol_inbox_enabled"): + settings_obj.peppol_inbox_enabled = True if not safe_commit("admin_peppol_setup_wizard_apply"): return jsonify({"ok": False, "error": "Database error while saving settings"}), 500 diff --git a/app/routes/api_scim.py b/app/routes/api_scim.py index 936c4f42d..3d6ccba35 100644 --- a/app/routes/api_scim.py +++ b/app/routes/api_scim.py @@ -1,21 +1,41 @@ -"""SCIM 2.0 API scaffolding (Users list stub). +"""SCIM 2.0 Users API (RFC 7643 / 7644). See docs/design/SAML_SCIM.md. Blueprint is registered only when SCIM_ENABLED is true. """ from __future__ import annotations +import re + from flask import Blueprint, current_app, jsonify, request +from app import db +from app.models import AuditLog, Role, User +from app.utils.db import safe_commit + api_scim_bp = Blueprint("api_scim", __name__) SCIM_LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_USER = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp" SCIM_JSON = "application/scim+json" +_FILTER_USERNAME = re.compile(r'userName\s+eq\s+"([^"]+)"', re.I) +_FILTER_EXTERNAL = re.compile(r'externalId\s+eq\s+"([^"]+)"', re.I) + def _scim_auth_ok() -> bool: - """Placeholder bearer auth until full SCIM token management exists.""" expected = (current_app.config.get("SCIM_BEARER_TOKEN") or "").strip() + # Optional Settings override (hashed comparison not required for env token) + if not expected: + try: + from app.models import Settings + + settings = Settings.get_settings() + expected = (getattr(settings, "scim_bearer_token", None) or "").strip() + except Exception: + expected = "" if not expected: return False auth = request.headers.get("Authorization") or "" @@ -26,25 +46,399 @@ def _scim_auth_ok() -> bool: def _scim_unauthorized(): - return jsonify({"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "detail": "Unauthorized"}), 401 + return ( + jsonify({"schemas": [SCIM_ERROR], "detail": "Unauthorized", "status": "401"}), + 401, + {"Content-Type": SCIM_JSON}, + ) + + +def _scim_error(detail: str, status: int = 400): + return ( + jsonify({"schemas": [SCIM_ERROR], "detail": detail, "status": str(status)}), + status, + {"Content-Type": SCIM_JSON}, + ) + + +def _split_name(full_name: str | None) -> tuple[str, str, str]: + full_name = (full_name or "").strip() + if not full_name: + return "", "", "" + parts = full_name.split(None, 1) + given = parts[0] + family = parts[1] if len(parts) > 1 else "" + return full_name, given, family + + +def _user_to_scim(user: User) -> dict: + formatted, given, family = _split_name(user.full_name) + return { + "schemas": [SCIM_USER], + "id": str(user.id), + "externalId": user.scim_external_id, + "userName": user.username, + "name": { + "formatted": formatted or user.username, + "givenName": given, + "familyName": family, + }, + "displayName": formatted or user.username, + "emails": ([{"value": user.email, "type": "work", "primary": True}] if user.email else []), + "active": bool(user.is_active), + "meta": { + "resourceType": "User", + "created": user.created_at.isoformat() + "Z" if user.created_at else None, + "lastModified": ( + (user.last_login or user.created_at).isoformat() + "Z" if (user.last_login or user.created_at) else None + ), + "location": f"/scim/v2/Users/{user.id}", + }, + } + + +def _parse_name_from_scim(data: dict) -> str | None: + name = data.get("name") or {} + if isinstance(name, dict): + formatted = (name.get("formatted") or "").strip() + if formatted: + return formatted + given = (name.get("givenName") or "").strip() + family = (name.get("familyName") or "").strip() + joined = " ".join(p for p in [given, family] if p).strip() + return joined or None + return None + + +def _parse_email_from_scim(data: dict) -> str | None: + emails = data.get("emails") or [] + if not isinstance(emails, list): + return None + primary = None + work = None + first = None + for e in emails: + if not isinstance(e, dict): + continue + val = (e.get("value") or "").strip().lower() + if not val: + continue + if first is None: + first = val + if e.get("primary"): + primary = val + if (e.get("type") or "").lower() == "work": + work = val + return primary or work or first + + +def _audit(action: str, user: User, description: str): + try: + AuditLog.log_change( + user_id=None, + action=action, + entity_type="user", + entity_id=user.id, + entity_name=user.username, + change_description=description, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + request_path=request.path, + ) + except Exception: + current_app.logger.debug("SCIM audit log failed", exc_info=True) + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +@api_scim_bp.route("/scim/v2/ServiceProviderConfig", methods=["GET"]) +def service_provider_config(): + if not _scim_auth_ok(): + return _scim_unauthorized() + body = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "documentationUri": "https://github.com/", + "patch": {"supported": True}, + "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": {"supported": True, "maxResults": 200}, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, + "authenticationSchemes": [ + { + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Authentication via SCIM_BEARER_TOKEN", + "specUri": "https://tools.ietf.org/html/rfc6750", + "primary": True, + } + ], + } + return jsonify(body), 200, {"Content-Type": SCIM_JSON} + + +@api_scim_bp.route("/scim/v2/ResourceTypes", methods=["GET"]) +def resource_types(): + if not _scim_auth_ok(): + return _scim_unauthorized() + body = { + "schemas": [SCIM_LIST_RESPONSE], + "totalResults": 1, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "User", + "name": "User", + "endpoint": "/scim/v2/Users", + "description": "User Account", + "schema": SCIM_USER, + "schemaExtensions": [], + } + ], + } + return jsonify(body), 200, {"Content-Type": SCIM_JSON} + + +@api_scim_bp.route("/scim/v2/Schemas", methods=["GET"]) +def schemas(): + if not _scim_auth_ok(): + return _scim_unauthorized() + body = { + "schemas": [SCIM_LIST_RESPONSE], + "totalResults": 1, + "Resources": [ + { + "id": SCIM_USER, + "name": "User", + "description": "User Account", + "attributes": [ + {"name": "userName", "type": "string", "required": True, "uniqueness": "server"}, + {"name": "externalId", "type": "string", "required": False}, + {"name": "active", "type": "boolean", "required": False}, + { + "name": "name", + "type": "complex", + "subAttributes": [ + {"name": "formatted", "type": "string"}, + {"name": "givenName", "type": "string"}, + {"name": "familyName", "type": "string"}, + ], + }, + { + "name": "emails", + "type": "complex", + "multiValued": True, + "subAttributes": [ + {"name": "value", "type": "string"}, + {"name": "type", "type": "string"}, + {"name": "primary", "type": "boolean"}, + ], + }, + ], + } + ], + } + return jsonify(body), 200, {"Content-Type": SCIM_JSON} + + +# --------------------------------------------------------------------------- +# Users CRUD +# --------------------------------------------------------------------------- @api_scim_bp.route("/scim/v2/Users", methods=["GET"]) def scim_list_users(): - """Return an empty SCIM ListResponse (provisioning not implemented yet).""" if not _scim_auth_ok(): return _scim_unauthorized() - start_index = max(1, int(request.args.get("startIndex", 1))) - count = max(0, int(request.args.get("count", 100))) + start_index = max(1, int(request.args.get("startIndex", 1) or 1)) + count = max(0, min(200, int(request.args.get("count", 100) or 100))) + filter_expr = (request.args.get("filter") or "").strip() + + q = User.query + if filter_expr: + m = _FILTER_USERNAME.search(filter_expr) + if m: + q = q.filter(User.username == m.group(1)) + m = _FILTER_EXTERNAL.search(filter_expr) + if m: + q = q.filter(User.scim_external_id == m.group(1)) + + total = q.count() + if count == 0: + resources = [] + else: + resources = q.order_by(User.id.asc()).offset(start_index - 1).limit(count).all() body = { "schemas": [SCIM_LIST_RESPONSE], - "totalResults": 0, + "totalResults": total, "startIndex": start_index, - "itemsPerPage": 0, - "Resources": [], + "itemsPerPage": len(resources), + "Resources": [_user_to_scim(u) for u in resources], } - if count == 0: - body["itemsPerPage"] = 0 return jsonify(body), 200, {"Content-Type": SCIM_JSON} + + +@api_scim_bp.route("/scim/v2/Users/", methods=["GET"]) +def scim_get_user(user_id: str): + if not _scim_auth_ok(): + return _scim_unauthorized() + try: + uid = int(user_id) + except (TypeError, ValueError): + return _scim_error("User not found", 404) + user = User.query.get(uid) + if not user: + return _scim_error("User not found", 404) + return jsonify(_user_to_scim(user)), 200, {"Content-Type": SCIM_JSON} + + +@api_scim_bp.route("/scim/v2/Users", methods=["POST"]) +def scim_create_user(): + if not _scim_auth_ok(): + return _scim_unauthorized() + data = request.get_json(silent=True) or {} + username = (data.get("userName") or "").strip().lower() + if not username: + return _scim_error("userName is required", 400) + if User.query.filter_by(username=username).first(): + return _scim_error("userName already exists", 409) + + email = _parse_email_from_scim(data) + full_name = _parse_name_from_scim(data) + external_id = (data.get("externalId") or "").strip() or None + active = data.get("active", True) + if active is None: + active = True + + user = User(username=username, email=email, full_name=full_name, role="user") + user.is_active = bool(active) + user.auth_provider = "scim" + user.scim_external_id = external_id + role_obj = Role.query.filter_by(name="user").first() + if role_obj: + user.roles.append(role_obj) + db.session.add(user) + if not safe_commit("scim_create_user", {"username": username}): + return _scim_error("Failed to create user", 500) + _audit("created", user, "SCIM user created") + return jsonify(_user_to_scim(user)), 201, {"Content-Type": SCIM_JSON} + + +def _apply_user_fields(user: User, data: dict) -> None: + if "userName" in data and data["userName"]: + user.username = str(data["userName"]).strip().lower() + if "externalId" in data: + val = data.get("externalId") + user.scim_external_id = str(val).strip() if val else None + if "active" in data and data["active"] is not None: + user.is_active = bool(data["active"]) + name = _parse_name_from_scim(data) + if name is not None and ("name" in data): + user.full_name = name + if "emails" in data: + email = _parse_email_from_scim(data) + user.email = email + + +@api_scim_bp.route("/scim/v2/Users/", methods=["PUT"]) +def scim_replace_user(user_id: str): + if not _scim_auth_ok(): + return _scim_unauthorized() + try: + uid = int(user_id) + except (TypeError, ValueError): + return _scim_error("User not found", 404) + user = User.query.get(uid) + if not user: + return _scim_error("User not found", 404) + data = request.get_json(silent=True) or {} + _apply_user_fields(user, data) + if not safe_commit("scim_replace_user", {"user_id": user.id}): + return _scim_error("Failed to update user", 500) + _audit("updated", user, "SCIM user replaced") + return jsonify(_user_to_scim(user)), 200, {"Content-Type": SCIM_JSON} + + +@api_scim_bp.route("/scim/v2/Users/", methods=["PATCH"]) +def scim_patch_user(user_id: str): + if not _scim_auth_ok(): + return _scim_unauthorized() + try: + uid = int(user_id) + except (TypeError, ValueError): + return _scim_error("User not found", 404) + user = User.query.get(uid) + if not user: + return _scim_error("User not found", 404) + + data = request.get_json(silent=True) or {} + ops = data.get("Operations") or data.get("operations") or [] + for op in ops: + if not isinstance(op, dict): + continue + kind = (op.get("op") or "").lower() + path = (op.get("path") or "").strip() + value = op.get("value") + + if kind in ("replace", "add"): + if path.lower() == "active" or (not path and isinstance(value, dict) and "active" in value): + active_val = value if path.lower() == "active" else value.get("active") + user.is_active = bool(active_val) + elif path.lower() in ("name.formatted", "name.givenname", "name.familyname") or ( + not path and isinstance(value, dict) and "name" in value + ): + if path.lower() == "name.formatted": + user.full_name = str(value or "").strip() or user.full_name + elif path.lower() == "name.givenname": + _, _, family = _split_name(user.full_name) + user.full_name = " ".join(p for p in [str(value or "").strip(), family] if p) + elif path.lower() == "name.familyname": + formatted, given, _ = _split_name(user.full_name) + user.full_name = " ".join(p for p in [given, str(value or "").strip()] if p) + else: + name = _parse_name_from_scim(value if isinstance(value, dict) else {"name": value}) + if name: + user.full_name = name + elif path.lower().startswith("emails") or (not path and isinstance(value, dict) and "emails" in value): + if path.lower() == "emails" and isinstance(value, list): + email = _parse_email_from_scim({"emails": value}) + user.email = email + elif isinstance(value, dict) and "emails" in value: + user.email = _parse_email_from_scim(value) + elif isinstance(value, str): + user.email = value.strip().lower() or None + elif not path and isinstance(value, dict): + _apply_user_fields(user, value) + + deactivated = not user.is_active + if not safe_commit("scim_patch_user", {"user_id": user.id}): + return _scim_error("Failed to patch user", 500) + _audit( + "updated" if not deactivated else "updated", + user, + "SCIM user patched" + (" (deactivated)" if deactivated and not user.is_active else ""), + ) + return jsonify(_user_to_scim(user)), 200, {"Content-Type": SCIM_JSON} + + +@api_scim_bp.route("/scim/v2/Users/", methods=["DELETE"]) +def scim_delete_user(user_id: str): + if not _scim_auth_ok(): + return _scim_unauthorized() + try: + uid = int(user_id) + except (TypeError, ValueError): + return _scim_error("User not found", 404) + user = User.query.get(uid) + if not user: + return _scim_error("User not found", 404) + user.is_active = False + if not safe_commit("scim_deactivate_user", {"user_id": user.id}): + return _scim_error("Failed to deactivate user", 500) + _audit("updated", user, "SCIM user deactivated (DELETE)") + return "", 204 diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 6045ab35a..fc09b307a 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -5314,72 +5314,7 @@ def delete_holiday_api(holiday_id): # ==================== Payroll Export ==================== - - -@api_v1_bp.route("/exports/payroll", methods=["GET"]) -@require_api_token("read:reports") -def export_payroll_csv(): - import csv - import io - - from app.services.workforce_governance_service import WorkforceGovernanceService - - start = _parse_date(request.args.get("start_date")) - end = _parse_date(request.args.get("end_date")) - if not start or not end: - return jsonify({"error": "start_date and end_date are required (YYYY-MM-DD)"}), 400 - - user_id = request.args.get("user_id", type=int) - if not g.api_user.is_admin: - user_id = g.api_user.id - - approved_only = request.args.get("approved_only", "false").lower() == "true" - closed_only = request.args.get("closed_only", "false").lower() == "true" - - rows = WorkforceGovernanceService().payroll_rows( - start_date=start, - end_date=end, - user_id=user_id, - approved_only=approved_only, - closed_only=closed_only, - ) - - output = io.StringIO() - writer = csv.writer(output) - writer.writerow( - [ - "user_id", - "username", - "week_year", - "week_number", - "period_start", - "period_end", - "hours", - "billable_hours", - "non_billable_hours", - ] - ) - for row in rows: - writer.writerow( - [ - row.get("user_id"), - row.get("username"), - row.get("week_year"), - row.get("week_number"), - row.get("period_start"), - row.get("period_end"), - row.get("hours"), - row.get("billable_hours"), - row.get("non_billable_hours"), - ] - ) - - filename = f"payroll_export_{start.isoformat()}_{end.isoformat()}.csv" - return Response( - output.getvalue(), - mimetype="text/csv", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) +# Handler lives in app.routes.api_v1_exports (imported via blueprint_registry). # ==================== Capacity and Compliance ==================== diff --git a/app/routes/api_v1_exports.py b/app/routes/api_v1_exports.py new file mode 100644 index 000000000..8c9c82d89 --- /dev/null +++ b/app/routes/api_v1_exports.py @@ -0,0 +1,150 @@ +"""REST API v1 export endpoints (payroll and related downloads).""" + +import csv +import io + +from flask import Response, g, jsonify, request, send_file + +from app.routes.api_v1 import api_v1_bp +from app.routes.api_v1_common import _parse_date +from app.utils.api_auth import require_api_token +from app.utils.api_responses import forbidden_response + + +@api_v1_bp.route("/exports/payroll", methods=["GET"]) +@require_api_token("read:reports") +def export_payroll(): + """Template-aware payroll export (CSV default, XLSX via ?format=xlsx).""" + from app.models.payroll_export_template import PayrollExportTemplate + from app.services.workforce_governance_service import WorkforceGovernanceService + + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + if not start or not end: + return jsonify({"error": "start_date and end_date are required (YYYY-MM-DD)"}), 400 + + user_id = request.args.get("user_id", type=int) + if not g.api_user.is_admin: + user_id = g.api_user.id + + approved_only = request.args.get("approved_only", "false").lower() == "true" + closed_only = request.args.get("closed_only", "false").lower() == "true" + fmt = (request.args.get("format") or "csv").strip().lower() + + template_id = request.args.get("template_id", type=int) + tmpl = PayrollExportTemplate.query.get(template_id) if template_id else None + if not tmpl: + tmpl = PayrollExportTemplate.query.filter_by(is_default=True).first() + if not tmpl: + tmpl = PayrollExportTemplate.ensure_builtin_defaults() + + columns = tmpl.columns or PayrollExportTemplate.DEFAULT_COLUMNS + delimiter = tmpl.delimiter or "," + grouping = tmpl.grouping or "week" + + rows = WorkforceGovernanceService().payroll_rows( + start_date=start, + end_date=end, + user_id=user_id, + approved_only=approved_only, + closed_only=closed_only, + grouping=grouping, + ) + + if fmt == "xlsx": + try: + from openpyxl import Workbook + except ImportError: + return jsonify({"error": "XLSX export requires openpyxl"}), 501 + + wb = Workbook() + ws = wb.active + ws.title = "Payroll" + ws.append(columns) + for row in rows: + ws.append([row.get(col, "") for col in columns]) + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + filename = f"payroll_export_{start.isoformat()}_{end.isoformat()}.xlsx" + return send_file( + buf, + as_attachment=True, + download_name=filename, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + writer.writerow(columns) + for row in rows: + writer.writerow([row.get(col, "") for col in columns]) + + filename = f"payroll_export_{start.isoformat()}_{end.isoformat()}.csv" + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@api_v1_bp.route("/reports/labor-compliance/", methods=["GET"]) +@require_api_token("read:reports") +def labor_compliance_report_api(preset_id): + """Labor-compliance preset export (JSON). Use ?format=csv for download.""" + from app.services.labor_compliance_service import LaborComplianceService + + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + if not start or not end: + return jsonify({"error": "start_date and end_date are required (YYYY-MM-DD)"}), 400 + + user_id = request.args.get("user_id", type=int) + if not g.api_user.is_admin: + if user_id is not None and user_id != g.api_user.id: + return forbidden_response("Access denied") + user_id = g.api_user.id + + weekly_threshold = request.args.get("weekly_threshold", type=float) + overages_only = request.args.get("overages_only", "false").lower() == "true" + fmt = (request.args.get("format") or "json").strip().lower() + + service = LaborComplianceService() + try: + rows, meta = service.export_rows( + preset_id, + start_date=start, + end_date=end, + user_id=user_id, + weekly_threshold=weekly_threshold, + overages_only=overages_only, + ) + except ValueError: + return jsonify({"error": "Unknown labor-compliance preset"}), 404 + + if fmt == "csv": + output = io.StringIO() + if rows: + writer = csv.DictWriter(output, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + else: + writer = csv.writer(output) + writer.writerow(["message"]) + writer.writerow(["No records in range"]) + filename = f"{meta['preset_id']}_{start.isoformat()}_{end.isoformat()}.csv" + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + return jsonify({"meta": meta, "rows": rows, "count": len(rows)}) + + +@api_v1_bp.route("/reports/labor-compliance", methods=["GET"]) +@require_api_token("read:reports") +def labor_compliance_presets_api(): + from app.services.labor_compliance_service import LaborComplianceService + + return jsonify({"presets": LaborComplianceService().list_presets()}) diff --git a/app/routes/api_v1_gantt.py b/app/routes/api_v1_gantt.py new file mode 100644 index 000000000..3557a8165 --- /dev/null +++ b/app/routes/api_v1_gantt.py @@ -0,0 +1,39 @@ +"""API v1 - Gantt chart data.""" + +from datetime import datetime, timedelta + +from flask import Blueprint, g, jsonify, request + +from app.services.gantt_service import GanttService +from app.utils.api_auth import require_api_token + +api_v1_gantt_bp = Blueprint("api_v1_gantt", __name__, url_prefix="/api/v1") + + +@api_v1_gantt_bp.route("/gantt", methods=["GET"]) +@require_api_token(("read:projects", "read:tasks")) +def get_gantt_data(): + """Return Gantt chart data via GanttService.""" + project_id = request.args.get("project_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + + if start_date: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + else: + start_dt = datetime.utcnow() - timedelta(days=90) + if end_date: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + else: + end_dt = datetime.utcnow() + timedelta(days=90) + + user = g.api_user + has_view_all_projects = bool(user.is_admin or user.has_permission("view_projects")) + result = GanttService().get_gantt_data( + project_id=project_id, + start_dt=start_dt, + end_dt=end_dt, + user_id=user.id, + has_view_all_projects=has_view_all_projects, + ) + return jsonify(result) diff --git a/app/routes/api_v1_resource_scheduling.py b/app/routes/api_v1_resource_scheduling.py new file mode 100644 index 000000000..8b30c0b65 --- /dev/null +++ b/app/routes/api_v1_resource_scheduling.py @@ -0,0 +1,183 @@ +"""API v1 - Resource scheduling (team schedule allocations).""" + +from datetime import date, datetime, timedelta + +from flask import Blueprint, g, jsonify, request + +from app import db +from app.models import Task, TimeEntry, User +from app.utils.api_auth import require_api_token +from app.utils.api_responses import not_found_response, validation_error_response + +api_v1_resource_scheduling_bp = Blueprint("api_v1_resource_scheduling", __name__, url_prefix="/api/v1") + + +def _week_start(value: date) -> date: + return value - timedelta(days=value.weekday()) + + +def _build_schedule(week_of: date, user_ids=None, viewer=None): + start = _week_start(week_of) + days = [start + timedelta(days=i) for i in range(7)] + end = days[-1] + + users_query = User.query.filter_by(is_active=True).order_by(User.username) + if user_ids: + users_query = users_query.filter(User.id.in_(user_ids)) + elif viewer and not (viewer.is_admin or viewer.has_permission("view_all_time_entries")): + users_query = users_query.filter(User.id == viewer.id) + users = users_query.all() + + from app.services.task_service import task_assigned_to_any_clause + + tasks = ( + Task.query.filter( + task_assigned_to_any_clause([u.id for u in users]), + Task.due_date.between(start, end), + ).all() + if users + else [] + ) + tasks_by_user_day = {} + user_id_set = {u.id for u in users} + for task in tasks: + for uid in task.assignee_ids: + if uid in user_id_set: + tasks_by_user_day.setdefault((uid, task.due_date), []).append(task) + + actuals = {} + if users: + start_dt = datetime.combine(start, datetime.min.time()) + end_dt = datetime.combine(end, datetime.max.time()) + rows = ( + db.session.query( + TimeEntry.user_id, + TimeEntry.start_time, + TimeEntry.duration_seconds, + ) + .filter( + TimeEntry.user_id.in_([u.id for u in users]), + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt, + ) + .all() + ) + for user_id, start_time, seconds in rows: + day = start_time.date() if start_time else None + if day: + actuals[(user_id, day)] = actuals.get((user_id, day), 0) + (seconds or 0) + + allocations = [] + lanes = [] + for user in users: + daily_cap = float(user.daily_hour_limit_override or user.standard_hours_per_day or 8) + cells = [] + for day in days: + day_tasks = tasks_by_user_day.get((user.id, day), []) + planned = sum(float(t.estimated_hours or 0) for t in day_tasks) + actual_hours = round(actuals.get((user.id, day), 0) / 3600, 2) + for task in day_tasks: + allocations.append( + { + "user_id": user.id, + "task_id": task.id, + "project_id": task.project_id, + "date": day.isoformat(), + "planned_hours": float(task.estimated_hours or 0), + "task_name": task.name, + } + ) + cells.append( + { + "date": day.isoformat(), + "task_ids": [t.id for t in day_tasks], + "planned_hours": planned, + "actual_hours": actual_hours, + "capacity": daily_cap, + "over_capacity": planned > daily_cap, + } + ) + lanes.append({"user_id": user.id, "username": user.username, "cells": cells}) + + return { + "week_start": start.isoformat(), + "days": [d.isoformat() for d in days], + "lanes": lanes, + "allocations": allocations, + } + + +@api_v1_resource_scheduling_bp.route("/resource-scheduling", methods=["GET"]) +@require_api_token(("read:projects", "read:tasks")) +def list_resource_schedule(): + """List weekly resource schedule / task allocations (same data as web team schedule).""" + week_of_str = (request.args.get("week_of") or "").strip() + try: + week_of = datetime.strptime(week_of_str, "%Y-%m-%d").date() if week_of_str else date.today() + except ValueError: + return validation_error_response({"week_of": ["Invalid date; use YYYY-MM-DD"]}) + + user_ids = request.args.getlist("user_id", type=int) + payload = _build_schedule(week_of, user_ids=user_ids or None, viewer=g.api_user) + return jsonify(payload) + + +@api_v1_resource_scheduling_bp.route("/resource-scheduling/allocations", methods=["POST"]) +@require_api_token(("write:tasks", "write:projects")) +def create_resource_allocation(): + """Create an allocation by assigning a task to a user on a due date (web schedule model).""" + data = request.get_json() or {} + errors = {} + task_id = data.get("task_id") + user_id = data.get("user_id") + due_date_raw = data.get("date") or data.get("due_date") + + if not task_id: + errors.setdefault("task_id", []).append("Required") + if not user_id: + errors.setdefault("user_id", []).append("Required") + if not due_date_raw: + errors.setdefault("date", []).append("Required (YYYY-MM-DD)") + if errors: + return validation_error_response(errors) + + try: + due_date = datetime.strptime(str(due_date_raw), "%Y-%m-%d").date() + except ValueError: + return validation_error_response({"date": ["Invalid date; use YYYY-MM-DD"]}) + + task = Task.query.get(task_id) + if not task: + return not_found_response("Task not found") + user = User.query.get(user_id) + if not user or not user.is_active: + return not_found_response("User not found") + + task.due_date = due_date + current_ids = list(task.assignee_ids) + if user.id not in current_ids: + current_ids.append(user.id) + task.set_assignees(current_ids) + if data.get("estimated_hours") is not None: + try: + task.estimated_hours = float(data.get("estimated_hours")) + except (TypeError, ValueError): + return validation_error_response({"estimated_hours": ["Must be a number"]}) + + db.session.commit() + return ( + jsonify( + { + "allocation": { + "user_id": user.id, + "task_id": task.id, + "project_id": task.project_id, + "date": due_date.isoformat(), + "planned_hours": float(task.estimated_hours or 0), + "task_name": task.name, + } + } + ), + 201, + ) diff --git a/app/routes/api_v1_tasks.py b/app/routes/api_v1_tasks.py index 92e6d16d3..cc7cac785 100644 --- a/app/routes/api_v1_tasks.py +++ b/app/routes/api_v1_tasks.py @@ -171,9 +171,13 @@ def update_task(task_id): if "tags" in data: update_kwargs["tags"] = data["tags"] + if "custom_fields" in data: + update_kwargs["custom_fields"] = data["custom_fields"] + result = task_service.update_task(task_id=task_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): - return error_response(result.get("message", "Could not update task"), status_code=400) + status_code = 409 if result.get("code") == "WIP_LIMIT" or result.get("error") == "wip_limit" else 400 + return error_response(result.get("message", "Could not update task"), status_code=status_code) return jsonify({"message": "Task updated successfully", "task": result["task"].to_dict()}) diff --git a/app/routes/api_v1_team_chat.py b/app/routes/api_v1_team_chat.py new file mode 100644 index 000000000..1f537ca71 --- /dev/null +++ b/app/routes/api_v1_team_chat.py @@ -0,0 +1,80 @@ +"""API v1 - Team chat channels and messages.""" + +from flask import Blueprint, g, jsonify, request + +from app.services.team_chat_service import TeamChatService +from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, not_found_response, validation_error_response + +api_v1_team_chat_bp = Blueprint("api_v1_team_chat", __name__, url_prefix="/api/v1") +_chat = TeamChatService() + + +@api_v1_team_chat_bp.route("/team-chat/channels", methods=["GET"]) +@require_api_token("read:projects") +def list_channels(): + channels = _chat.list_channels_for_user(g.api_user.id) + return jsonify({"channels": [_chat.serialize_channel(c) for c in channels]}) + + +@api_v1_team_chat_bp.route("/team-chat/channels", methods=["POST"]) +@require_api_token("write:projects") +def create_channel(): + data = request.get_json() or {} + name = (data.get("name") or "").strip() + if not name: + return validation_error_response({"name": ["Required"]}) + channel = _chat.create_channel( + name=name, + created_by=g.api_user.id, + description=(data.get("description") or None), + channel_type=data.get("channel_type") or "public", + project_id=data.get("project_id"), + member_ids=data.get("member_ids") or [], + ) + return jsonify({"channel": _chat.serialize_channel(channel)}), 201 + + +@api_v1_team_chat_bp.route("/team-chat/channels//messages", methods=["GET"]) +@require_api_token("read:projects") +def list_messages(channel_id): + channel = _chat.get_channel(channel_id) + if not channel: + return not_found_response("Channel not found") + if not _chat.user_can_access_channel(channel_id, g.api_user.id, is_admin=g.api_user.is_admin): + return error_response("Access denied", error_code="forbidden", status_code=403) + + before_id = request.args.get("before_id", type=int) + limit = request.args.get("limit", 50, type=int) + messages = _chat.list_messages(channel_id, before_id=before_id, limit=limit or 50) + _chat.mark_messages_read(messages, g.api_user.id) + return jsonify({"messages": [_chat.serialize_message(m) for m in messages]}) + + +@api_v1_team_chat_bp.route("/team-chat/channels//messages", methods=["POST"]) +@require_api_token("write:projects") +def post_message(channel_id): + channel = _chat.get_channel(channel_id) + if not channel: + return not_found_response("Channel not found") + if not _chat.user_can_access_channel(channel_id, g.api_user.id, is_admin=g.api_user.is_admin): + return error_response("Access denied", error_code="forbidden", status_code=403) + + data = request.get_json() or {} + text = (data.get("message") or "").strip() + if not text and not data.get("attachment_url"): + return validation_error_response({"message": ["Message cannot be empty"]}) + + message = _chat.post_message( + channel_id, + g.api_user.id, + message=text, + message_type=data.get("message_type") or ("file" if data.get("attachment_url") else "text"), + reply_to_id=data.get("reply_to_id"), + attachment_url=data.get("attachment_url"), + attachment_filename=data.get("attachment_filename"), + attachment_size=data.get("attachment_size"), + ) + if message.mentions: + _chat.notify_mentions(channel, g.api_user.display_name, message.mentions) + return jsonify({"message": _chat.serialize_message(message)}), 201 diff --git a/app/routes/api_v1_time_entries.py b/app/routes/api_v1_time_entries.py index f22867a1a..242eed537 100644 --- a/app/routes/api_v1_time_entries.py +++ b/app/routes/api_v1_time_entries.py @@ -205,6 +205,7 @@ def create_time_entry(): billable=validated.get("billable", True), paid=validated.get("paid", False), invoice_number=validated.get("invoice_number"), + custom_fields=validated.get("custom_fields"), ) if not result.get("success"): @@ -296,6 +297,7 @@ def update_time_entry(entry_id): invoice_number=validated.get("invoice_number"), reason=data.get("reason"), expected_updated_at=validated.get("if_updated_at"), + custom_fields=validated.get("custom_fields"), ) if not result.get("success"): diff --git a/app/routes/api_v1_workflows.py b/app/routes/api_v1_workflows.py new file mode 100644 index 000000000..7db37bddc --- /dev/null +++ b/app/routes/api_v1_workflows.py @@ -0,0 +1,49 @@ +"""API v1 - Workflow templates and run history.""" + +from flask import Blueprint, g, jsonify, request + +from app.models.workflow import WorkflowExecution, WorkflowRule +from app.services.workflow_template_service import WorkflowTemplateService +from app.utils.api_auth import require_api_token +from app.utils.api_responses import not_found_response + +api_v1_workflows_bp = Blueprint("api_v1_workflows", __name__, url_prefix="/api/v1") +_template_service = WorkflowTemplateService() + + +@api_v1_workflows_bp.route("/workflows/templates", methods=["GET"]) +@require_api_token("read:projects") +def list_workflow_templates(): + """List available workflow templates for the token user.""" + templates = _template_service.list_available(g.api_user.id, g.api_user.is_admin) + return jsonify({"templates": [t.to_dict() for t in templates]}) + + +@api_v1_workflows_bp.route("/workflows/templates/", methods=["GET"]) +@require_api_token("read:projects") +def get_workflow_template(template_id): + """Get a single workflow template if visible to the user.""" + template = _template_service.get_template(template_id) + if not template: + return not_found_response("Workflow template not found") + if not template.is_public and template.created_by != g.api_user.id and not g.api_user.is_admin: + return not_found_response("Workflow template not found") + return jsonify({"template": template.to_dict()}) + + +@api_v1_workflows_bp.route("/workflows/executions", methods=["GET"]) +@require_api_token("read:projects") +def list_workflow_executions(): + """List recent workflow run history for the user's rules (or all if admin).""" + rule_id = request.args.get("rule_id", type=int) + limit = request.args.get("limit", 50, type=int) + limit = max(1, min(limit or 50, 200)) + + query = WorkflowExecution.query.join(WorkflowRule) + if not g.api_user.is_admin: + query = query.filter(WorkflowRule.user_id == g.api_user.id) + if rule_id: + query = query.filter(WorkflowExecution.rule_id == rule_id) + + executions = query.order_by(WorkflowExecution.executed_at.desc()).limit(limit).all() + return jsonify({"executions": [e.to_dict() for e in executions]}) diff --git a/app/routes/auth.py b/app/routes/auth.py index d2fcbb693..a631eda2e 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -22,6 +22,7 @@ auth_includes_ldap, auth_includes_local, auth_includes_oidc, + auth_includes_saml, forgot_password_available, normalize_auth_method, requires_password_form, @@ -64,6 +65,7 @@ def _login_template_vars(): "requires_password": requires_password, "auth_includes_ldap": auth_includes_ldap(auth_method), "auth_includes_oidc": auth_includes_oidc(auth_method), + "auth_includes_saml": auth_includes_saml(auth_method) or bool(current_app.config.get("SAML_ENABLED")), "show_forgot_password": forgot_password_available(auth_method) and auth_method != "ldap", } if current_app.config.get("DEMO_MODE"): @@ -1473,117 +1475,29 @@ def oidc_callback(): else: username = f"user-{sub[-8:]}" - # Find or create user - user = User.query.filter_by(oidc_issuer=issuer, oidc_sub=sub).first() - - if not user and email: - # Attempt match by email - user = User.query.filter_by(email=email).first() - - if not user: - # Attempt match by username - user = User.query.filter_by(username=username).first() - - if not user: - # Demo mode: do not create users via OIDC - if current_app.config.get("DEMO_MODE"): - current_app.logger.info("OIDC callback redirect to login: reason=demo_mode_no_oidc_create") - flash( - _("Demo mode: only the demo account can be used. Please use the credentials on the login page."), - "error", - ) - return redirect(url_for("auth.login")) - # Create if allowed (use ConfigManager to respect database settings) - allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) - if not allow_self_register: - current_app.logger.info("OIDC callback redirect to login: reason=self_registration_disabled") - flash(_("User account does not exist and self-registration is disabled."), "error") - return redirect(url_for("auth.login")) - if is_username_reserved(username): - current_app.logger.info("OIDC callback redirect to login: reason=deleted_username_reserved") - flash(_("User account does not exist and self-registration is disabled."), "error") - return redirect(url_for("auth.login")) - role_name = "user" - try: - user = User(username=username, role=role_name, email=email, full_name=full_name) - user.is_active = True - user.oidc_issuer = issuer - user.oidc_sub = sub - user.auth_provider = "oidc" - # Apply company default for daily working hours (overtime) - try: - from app.models import Settings - - settings = Settings.get_settings() - user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0) - except Exception: - current_app.logger.debug("Could not apply default daily working hours for OIDC user", exc_info=True) - - # Assign role from the new Role system - from app.models import Role - - role_obj = Role.query.filter_by(name=role_name).first() - if role_obj: - user.roles.append(role_obj) - - db.session.add(user) - if not safe_commit("oidc_create_user", {"username": username, "email": email}): - raise RuntimeError("db commit failed on user create") - - # Track onboarding started for new OIDC user - track_onboarding_started( - user.id, - { - "auth_method": "oidc", - "self_registered": True, - "is_admin": role_name == "admin", - "has_email": bool(email), - }, - ) - - flash(_("Welcome! Your account has been created."), "success") - except Exception as e: - current_app.logger.exception("Failed to create user from OIDC claims: %s", e) - current_app.logger.info("OIDC callback redirect to login: reason=db_create_user_failed") - flash(_("Could not create your account due to a database error."), "error") - return redirect(url_for("auth.login")) - else: - # Update linkage and profile fields - changed = False - if getattr(user, "auth_provider", "local") != "oidc": - user.auth_provider = "oidc" - changed = True - if not user.oidc_issuer or not user.oidc_sub: - user.oidc_issuer = issuer - user.oidc_sub = sub - changed = True - # Update profile fields when provided - if email and user.email != email: - user.email = email - changed = True - if full_name and user.full_name != full_name: - user.full_name = full_name - changed = True - if changed: - if not safe_commit("oidc_update_user", {"user_id": user.id}): - current_app.logger.warning("DB commit failed updating user from OIDC; continuing") - - # Admin role mapping based on configured group or emails - try: - admin_set = False - admin_group = getattr(Config, "OIDC_ADMIN_GROUP", None) - admin_emails = getattr(Config, "OIDC_ADMIN_EMAILS", []) or [] - if admin_group and isinstance(groups, (list, tuple)) and admin_group in groups and user.role != "admin": - user.role = "admin" - admin_set = True - if email and email in [e.strip().lower() for e in admin_emails] and user.role != "admin": - user.role = "admin" - admin_set = True - if admin_set: - if not safe_commit("oidc_promote_admin", {"user_id": user.id}): - current_app.logger.warning("DB commit failed promoting user to admin from OIDC; continuing") - except Exception: - current_app.logger.warning("OIDC admin role mapping failed", exc_info=True) + from app.services.external_identity_service import provision_external_user + + admin_group = getattr(Config, "OIDC_ADMIN_GROUP", None) + admin_emails = getattr(Config, "OIDC_ADMIN_EMAILS", []) or [] + provision = provision_external_user( + provider="oidc", + subject=sub, + issuer=issuer, + email=email, + full_name=full_name, + groups=groups if isinstance(groups, (list, tuple)) else [], + admin_emails=admin_emails, + admin_groups=[admin_group] if admin_group else None, + username=username, + ) + if not provision.user: + reason = provision.error or "provision_failed" + current_app.logger.info("OIDC callback redirect to login: reason=%s", reason) + flash(_(provision.error_flash or "Authentication failed."), "error") + return redirect(url_for("auth.login")) + user = provision.user + if provision.created: + flash(_("Welcome! Your account has been created."), "success") # OIDC group -> RBAC Role mapping (additive by default). # When OIDC_ROLE_GROUP_MAP is configured, sync user.roles based on the diff --git a/app/routes/auth_saml.py b/app/routes/auth_saml.py new file mode 100644 index 000000000..252c67c8f --- /dev/null +++ b/app/routes/auth_saml.py @@ -0,0 +1,219 @@ +"""SAML 2.0 SP routes (login, ACS, metadata). + +See docs/design/SAML_SCIM.md. Uses python3-saml (onelogin.saml2) when installed. +""" + +from __future__ import annotations + +from flask import Blueprint, current_app, flash, redirect, request, session, url_for +from flask_babel import gettext as _ +from flask_login import login_user + +from app import csrf +from app.config import Config +from app.services.external_identity_service import provision_external_user +from app.utils.auth_method import auth_includes_saml + +auth_saml_bp = Blueprint("auth_saml", __name__) + + +def _saml_enabled() -> bool: + if current_app.config.get("SAML_ENABLED"): + return True + auth_method = current_app.config.get("AUTH_METHOD", "local") + return auth_includes_saml(auth_method) + + +def _saml_settings() -> dict: + """Build OneLogin python3-saml settings dict from app config.""" + base = (current_app.config.get("APP_URL") or request.url_root or "").rstrip("/") + sp_entity = current_app.config.get("SAML_SP_ENTITY_ID") or f"{base}/auth/saml/metadata" + acs_url = current_app.config.get("SAML_SP_ACS_URL") or f"{base}/auth/saml/acs" + idp_entity = current_app.config.get("SAML_IDP_ENTITY_ID") or "" + idp_sso = current_app.config.get("SAML_IDP_SSO_URL") or "" + idp_x509 = current_app.config.get("SAML_IDP_X509_CERT") or "" + + return { + "strict": True, + "debug": bool(current_app.debug), + "sp": { + "entityId": sp_entity, + "assertionConsumerService": { + "url": acs_url, + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "x509cert": current_app.config.get("SAML_SP_X509_CERT") or "", + "privateKey": current_app.config.get("SAML_SP_PRIVATE_KEY") or "", + }, + "idp": { + "entityId": idp_entity, + "singleSignOnService": { + "url": idp_sso, + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + }, + "x509cert": idp_x509, + }, + "security": { + "wantAssertionsSigned": current_app.config.get("SAML_WANT_ASSERTIONS_SIGNED", True), + "wantMessagesSigned": False, + "requestedAuthnContext": False, + "logoutRequestSigned": False, + "logoutResponseSigned": False, + "metadataValidUntil": None, + "metadataCacheDuration": None, + "signMetadata": False, + "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "rejection_skew": int(current_app.config.get("SAML_CLOCK_SKEW_SECONDS") or 120), + }, + } + + +def _prepare_saml_request() -> dict: + url_data = request.host_url.rstrip("/") + return { + "https": "on" if request.scheme == "https" else "off", + "http_host": request.host, + "script_name": request.path, + "server_port": request.environ.get("SERVER_PORT"), + "get_data": request.args.copy(), + "post_data": request.form.copy(), + } + + +@auth_saml_bp.route("/auth/saml/login", methods=["GET"]) +def saml_login(): + if not _saml_enabled(): + flash(_("SAML SSO is not enabled."), "error") + return redirect(url_for("auth.login")) + + idp_sso = current_app.config.get("SAML_IDP_SSO_URL") or "" + if not idp_sso: + flash(_("SAML IdP is not configured."), "error") + return redirect(url_for("auth.login")) + + try: + from onelogin.saml2.auth import OneLogin_Saml2_Auth + except ImportError: + current_app.logger.error("python3-saml is not installed") + flash(_("SAML support is not installed on this server."), "error") + return redirect(url_for("auth.login")) + + req = _prepare_saml_request() + auth = OneLogin_Saml2_Auth(req, _saml_settings()) + return_to = request.args.get("next") or url_for("main.dashboard", _external=True) + return redirect(auth.login(return_to=return_to)) + + +@auth_saml_bp.route("/auth/saml/acs", methods=["POST"]) +@csrf.exempt +def saml_acs(): + if not _saml_enabled(): + flash(_("SAML SSO is not enabled."), "error") + return redirect(url_for("auth.login")) + + try: + from onelogin.saml2.auth import OneLogin_Saml2_Auth + except ImportError: + flash(_("SAML support is not installed on this server."), "error") + return redirect(url_for("auth.login")) + + req = _prepare_saml_request() + auth = OneLogin_Saml2_Auth(req, _saml_settings()) + auth.process_response() + errors = auth.get_errors() + if errors: + current_app.logger.warning("SAML ACS errors: %s reason=%s", errors, auth.get_last_error_reason()) + flash(_("SAML authentication failed."), "error") + return redirect(url_for("auth.login")) + if not auth.is_authenticated(): + flash(_("SAML authentication failed."), "error") + return redirect(url_for("auth.login")) + + attributes = auth.get_attributes() or {} + nameid = auth.get_nameid() or "" + issuer = ( + (current_app.config.get("SAML_IDP_ENTITY_ID") or auth.get_issuers() or [""])[0] + if hasattr(auth, "get_issuers") + else (current_app.config.get("SAML_IDP_ENTITY_ID") or "") + ) + try: + issuers = auth.get_issuers() or [] + if issuers: + issuer = issuers[0] + except Exception: + pass + issuer = issuer or current_app.config.get("SAML_IDP_ENTITY_ID") or "saml" + + def _attr(keys): + for k in keys: + vals = attributes.get(k) + if isinstance(vals, list) and vals: + return vals[0] + if isinstance(vals, str) and vals: + return vals + return None + + email = ( + _attr(["email", "mail", "Email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"]) + or nameid + or "" + ).strip().lower() or None + given = _attr(["givenName", "firstName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"]) or "" + family = _attr(["sn", "surname", "lastName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"]) or "" + display = _attr(["displayName", "cn", "http://schemas.microsoft.com/identity/claims/displayname"]) or "" + full_name = display or (" ".join(p for p in [given, family] if p).strip() or None) + groups_raw = attributes.get("memberOf") or attributes.get("groups") or [] + if isinstance(groups_raw, str): + groups = [groups_raw] + else: + groups = list(groups_raw or []) + + subject = nameid or email or "" + admin_emails = current_app.config.get("SAML_ADMIN_EMAILS") or getattr(Config, "OIDC_ADMIN_EMAILS", []) or [] + admin_group = current_app.config.get("SAML_ADMIN_GROUP") + + provision = provision_external_user( + provider="saml", + subject=subject, + issuer=issuer, + email=email, + full_name=full_name, + groups=groups, + admin_emails=admin_emails, + admin_groups=[admin_group] if admin_group else None, + ) + if not provision.user: + flash(_(provision.error_flash or "Could not provision user from SAML."), "error") + return redirect(url_for("auth.login")) + + user = provision.user + if not user.is_active: + flash(_("Account is disabled. Please contact an administrator."), "error") + return redirect(url_for("auth.login")) + + if provision.created: + flash(_("Welcome! Your account has been created."), "success") + + login_user(user, remember=True) + session["auth_provider"] = "saml" + return redirect(url_for("main.dashboard")) + + +@auth_saml_bp.route("/auth/saml/metadata", methods=["GET"]) +def saml_metadata(): + if not _saml_enabled(): + return "SAML not enabled", 404 + try: + from onelogin.saml2.auth import OneLogin_Saml2_Auth + from onelogin.saml2.settings import OneLogin_Saml2_Settings + except ImportError: + return "python3-saml not installed", 501 + + settings = OneLogin_Saml2_Settings(settings=_saml_settings(), sp_validation_only=True) + metadata = settings.get_sp_metadata() + errors = settings.validate_metadata(metadata) + if errors: + current_app.logger.warning("SAML metadata validation errors: %s", errors) + return metadata, 200, {"Content-Type": "application/xml"} diff --git a/app/routes/bank_reconciliation.py b/app/routes/bank_reconciliation.py new file mode 100644 index 000000000..007cf8748 --- /dev/null +++ b/app/routes/bank_reconciliation.py @@ -0,0 +1,118 @@ +"""Web UI for bank statement upload and invoice reconciliation.""" + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required + +from app.models.bank_transaction import BankTransaction +from app.services.bank_reconciliation_service import BankReconciliationService +from app.utils.module_helpers import module_enabled + +bank_reconciliation_bp = Blueprint("bank_reconciliation", __name__) + + +def _require_admin(): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return False + return True + + +@bank_reconciliation_bp.route("/payments/reconciliation", methods=["GET"]) +@login_required +@module_enabled("payments") +def reconciliation_queue(): + if not _require_admin(): + return redirect(url_for("payments.list_payments")) + + status = request.args.get("status", BankTransaction.MATCH_PENDING) + service = BankReconciliationService() + transactions = service.list_queue(status=status) + suggestions = {} + for txn in transactions: + if txn.match_status == BankTransaction.MATCH_PENDING: + suggestions[txn.id] = service.suggest_matches(txn, limit=5) + + return render_template( + "payments/reconciliation_queue.html", + transactions=transactions, + suggestions=suggestions, + status=status, + ) + + +@bank_reconciliation_bp.route("/payments/reconciliation/upload", methods=["GET", "POST"]) +@login_required +@module_enabled("payments") +def reconciliation_upload(): + if not _require_admin(): + return redirect(url_for("payments.list_payments")) + + if request.method == "GET": + return render_template("payments/reconciliation_upload.html") + + upload = request.files.get("statement") + account_label = (request.form.get("account_label") or "").strip() or None + if not upload or not upload.filename: + flash(_("Please choose a statement file"), "error") + return redirect(url_for("bank_reconciliation.reconciliation_upload")) + + raw = upload.read() + try: + content = raw.decode("utf-8-sig") + except UnicodeDecodeError: + content = raw.decode("latin-1") + + service = BankReconciliationService() + filename = (upload.filename or "").lower() + try: + if filename.endswith(".xml") or content.lstrip().startswith("/confirm", methods=["POST"]) +@login_required +@module_enabled("payments") +def reconciliation_confirm(txn_id): + if not _require_admin(): + return redirect(url_for("payments.list_payments")) + + invoice_id = request.form.get("invoice_id", type=int) + if not invoice_id: + flash(_("Invoice is required"), "error") + return redirect(url_for("bank_reconciliation.reconciliation_queue")) + + result = BankReconciliationService().confirm_match(txn_id, invoice_id, received_by=current_user.id) + if result.get("success"): + flash(_("Payment recorded and transaction matched"), "success") + else: + flash(result.get("message") or _("Could not confirm match"), "error") + return redirect(url_for("bank_reconciliation.reconciliation_queue")) + + +@bank_reconciliation_bp.route("/payments/reconciliation//ignore", methods=["POST"]) +@login_required +@module_enabled("payments") +def reconciliation_ignore(txn_id): + if not _require_admin(): + return redirect(url_for("payments.list_payments")) + + result = BankReconciliationService().ignore_transaction(txn_id) + if result.get("success"): + flash(_("Transaction ignored"), "success") + else: + flash(result.get("message") or _("Could not ignore transaction"), "error") + return redirect(url_for("bank_reconciliation.reconciliation_queue")) diff --git a/app/routes/clients.py b/app/routes/clients.py index 87ea55541..2b5c7c428 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -271,6 +271,7 @@ def create_client(): city = sanitize_input(request.form.get("city", "").strip(), max_length=100) country = (request.form.get("country", "") or "").strip().upper()[:2] or None vat_id = sanitize_input(request.form.get("vat_id", "").strip(), max_length=50) + leitweg_id = sanitize_input(request.form.get("leitweg_id", "").strip(), max_length=100) default_hourly_rate = request.form.get("default_hourly_rate", "").strip() prepaid_hours_input = request.form.get("prepaid_hours_monthly", "").strip() prepaid_reset_day_input = request.form.get("prepaid_reset_day", "").strip() @@ -382,6 +383,7 @@ def create_client(): city=city or None, country=country, vat_id=vat_id or None, + leitweg_id=leitweg_id or None, default_hourly_rate=default_hourly_rate, prepaid_hours_monthly=prepaid_hours_monthly, prepaid_reset_day=prepaid_reset_day, @@ -624,6 +626,7 @@ def edit_client(client_id): city = request.form.get("city", "").strip() country = (request.form.get("country", "") or "").strip().upper()[:2] or None vat_id = request.form.get("vat_id", "").strip() + leitweg_id = request.form.get("leitweg_id", "").strip() default_hourly_rate = request.form.get("default_hourly_rate", "").strip() prepaid_hours_input = request.form.get("prepaid_hours_monthly", "").strip() prepaid_reset_day_input = request.form.get("prepaid_reset_day", "").strip() @@ -733,6 +736,7 @@ def edit_client(client_id): client.city = city or None client.country = country client.vat_id = vat_id or None + client.leitweg_id = leitweg_id or None client.default_hourly_rate = default_hourly_rate client.prepaid_hours_monthly = prepaid_hours_monthly client.prepaid_reset_day = prepaid_reset_day diff --git a/app/routes/integrations_webhooks.py b/app/routes/integrations_webhooks.py index a29d5bad2..568c92796 100644 --- a/app/routes/integrations_webhooks.py +++ b/app/routes/integrations_webhooks.py @@ -383,13 +383,13 @@ def slack_status(): @integrations_webhooks_bp.route("/api/integrations/teams/bot/messages", methods=["POST"]) @csrf.exempt def teams_bot_messages(): - """Receive Bot Framework activities (stub; JWT validation in a later phase).""" + """Receive Bot Framework activities; JWT validated in MicrosoftTeamsConnector.""" payload = request.get_json(silent=True) or {} from app.integrations.microsoft_teams import MicrosoftTeamsConnector connector = MicrosoftTeamsConnector(integration=None) - result = connector.handle_bot_command(payload) - status = int(result.get("status") or 501) + result = connector.handle_bot_command(payload, auth_header=request.headers.get("Authorization")) + status = int(result.get("status") or 200) body = result.get("body") or {} return jsonify(body), status diff --git a/app/routes/inventory.py b/app/routes/inventory.py index 6d0cbfb34..556a5b2ce 100644 --- a/app/routes/inventory.py +++ b/app/routes/inventory.py @@ -813,6 +813,75 @@ def stock_levels(): ) +@inventory_bp.route("/inventory/stock-levels/export.csv") +@login_required +@module_enabled("inventory") +@admin_or_permission_required("view_stock_levels") +def export_stock_levels_csv(): + """Export filtered stock levels as CSV.""" + import csv + import io + + warehouse_id = request.args.get("warehouse_id", type=int) + category = request.args.get("category", "") + low_stock_only = request.args.get("low_stock", "false").lower() == "true" + + query = WarehouseStock.query.join(StockItem).join(Warehouse) + if warehouse_id: + query = query.filter_by(warehouse_id=warehouse_id) + if category: + query = query.filter(StockItem.category == category) + + stock_levels = query.order_by(Warehouse.code, StockItem.name).all() + if low_stock_only: + stock_levels = [ + sl + for sl in stock_levels + if sl.stock_item.reorder_point and sl.quantity_on_hand < sl.stock_item.reorder_point + ] + + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + [ + "warehouse_code", + "warehouse_name", + "sku", + "item_name", + "quantity_on_hand", + "quantity_reserved", + "quantity_available", + "location", + "category", + ] + ) + for stock in stock_levels: + item = stock.stock_item + warehouse = stock.warehouse + writer.writerow( + [ + warehouse.code if warehouse else "", + warehouse.name if warehouse else "", + item.sku if item else "", + item.name if item else "", + float(stock.quantity_on_hand or 0), + float(stock.quantity_reserved or 0), + float(stock.quantity_available or 0), + stock.location or "", + item.category if item else "", + ] + ) + + data = io.BytesIO(buffer.getvalue().encode("utf-8")) + data.seek(0) + return send_file( + data, + mimetype="text/csv", + as_attachment=True, + download_name=f"stock-levels-{datetime.utcnow().strftime('%Y%m%d')}.csv", + ) + + @inventory_bp.route("/inventory/stock-levels/warehouse/") @login_required @admin_or_permission_required("view_stock_levels") @@ -1236,6 +1305,7 @@ def new_transfer(): to_warehouse_id = int(request.form.get("to_warehouse_id")) quantity = Decimal(request.form.get("quantity")) notes = request.form.get("notes", "").strip() or None + location = request.form.get("location", "").strip() or None # Validate warehouses are different if from_warehouse_id == to_warehouse_id: @@ -1294,7 +1364,7 @@ def new_transfer(): ) # Create positive movement (to destination warehouse) - in_movement, _unused = StockMovement.record_movement( + in_movement, dest_stock = StockMovement.record_movement( movement_type="transfer", stock_item_id=stock_item_id, warehouse_id=to_warehouse_id, @@ -1307,6 +1377,15 @@ def new_transfer(): update_stock=True, ) + if location and dest_stock is not None: + dest_stock.location = location + elif location: + dest_stock = WarehouseStock.query.filter_by( + warehouse_id=to_warehouse_id, stock_item_id=stock_item_id + ).first() + if dest_stock: + dest_stock.location = location + safe_commit() log_event( @@ -1401,6 +1480,7 @@ def new_adjustment(): quantity = Decimal(request.form.get("quantity")) reason = request.form.get("reason", "").strip() or None notes = request.form.get("notes", "").strip() or None + location = request.form.get("location", "").strip() or None movement, updated_stock = StockMovement.record_movement( movement_type="adjustment", @@ -1413,6 +1493,14 @@ def new_adjustment(): update_stock=True, ) + if location: + stock = ( + updated_stock + or WarehouseStock.query.filter_by(warehouse_id=warehouse_id, stock_item_id=stock_item_id).first() + ) + if stock: + stock.location = location + safe_commit() log_event( @@ -1980,6 +2068,51 @@ def purchase_order_pdf(po_id): ) +@inventory_bp.route("/inventory/purchase-orders//email", methods=["POST"]) +@login_required +@module_enabled("inventory") +@admin_or_permission_required("manage_purchase_orders") +def email_purchase_order(po_id): + """Email the purchase order PDF to the supplier.""" + purchase_order = PurchaseOrder.query.get_or_404(po_id) + supplier = purchase_order.supplier + recipient = (supplier.email if supplier else None) or "" + recipient = recipient.strip() + if not recipient: + flash(_("Supplier has no email address configured."), "error") + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) + + try: + from app.utils.email import send_email + from app.utils.purchase_order_pdf import PurchaseOrderPDFGenerator + + pdf_bytes = PurchaseOrderPDFGenerator(purchase_order, settings=Settings.get_settings()).generate_pdf() + filename = f"{purchase_order.po_number or f'PO-{po_id}'}.pdf".replace("/", "-") + subject = _("Purchase Order %(po_number)s", po_number=purchase_order.po_number) + text_body = _( + "Please find attached purchase order %(po_number)s.", + po_number=purchase_order.po_number, + ) + html_body = f"

{text_body}

" + send_email( + subject=subject, + recipients=[recipient], + text_body=text_body, + html_body=html_body, + attachments=[(filename, "application/pdf", pdf_bytes)], + ) + if purchase_order.status == "draft": + purchase_order.mark_as_sent() + safe_commit("email_purchase_order", {"purchase_order_id": po_id}) + log_event("purchase_order_emailed", purchase_order_id=po_id, recipient=recipient) + flash(_("Purchase order emailed to %(email)s.", email=recipient), "success") + except Exception as e: + current_app.logger.exception("Failed to email purchase order %s", po_id) + flash(_("Error emailing purchase order: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) + + @inventory_bp.route("/inventory/purchase-orders//edit", methods=["GET", "POST"]) @login_required @module_enabled("inventory") diff --git a/app/routes/invoices.py b/app/routes/invoices.py index e4208b10f..ac2c261bb 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -29,6 +29,7 @@ Invoice, InvoiceImage, InvoiceItem, + PeppolInboundDocument, Project, ProjectCost, RateOverride, @@ -1269,6 +1270,48 @@ def export_invoice_ubl(invoice_id): return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) +@invoices_bp.route("/invoices//export/xrechnung") +@login_required +def export_invoice_xrechnung(invoice_id): + """Export invoice as XRechnung 3.0 UBL XML (CIUS-DE).""" + from app.models import Settings + from app.utils.xrechnung import build_xrechnung_ubl_invoice_xml, validate_xrechnung_ready + + invoice = Invoice.query.get_or_404(invoice_id) + if not current_user.is_admin and invoice.created_by != current_user.id: + flash(_("You do not have permission to export this invoice"), "error") + return safe_redirect( + request.referrer, allow_absolute_same_host=True, fallback=url_for("invoices.list_invoices") + ) + + settings = Settings.get_settings() + client = getattr(invoice, "client", None) + errors = validate_xrechnung_ready(invoice, settings, client) + if errors: + return Response( + json.dumps({"error": "xrechnung_not_ready", "messages": errors}), + status=400, + mimetype="application/json", + ) + + try: + xml, _sha = build_xrechnung_ubl_invoice_xml(invoice, settings, client) + fn = f"{invoice.invoice_number}-xrechnung.xml" + return Response(xml, mimetype="application/xml", headers={"Content-Disposition": f"attachment; filename={fn}"}) + except Exception as e: + from app.utils.error_reporting import log_and_capture + + log_and_capture( + current_app.logger, + "XRechnung export failed for invoice %s", + invoice_id, + exc=e, + level="exception", + ) + flash(_("XRechnung export failed. Please try again."), "error") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) + + @invoices_bp.route("/invoices//export/pdf") @login_required def export_invoice_pdf(invoice_id): @@ -2037,3 +2080,79 @@ def get_invoice_image_base64(invoice_id, image_id): except Exception as e: current_app.logger.error(f"Error serving image file: {e}") return jsonify({"error": "Error serving image file"}), 500 + + +@invoices_bp.route("/invoices/peppol-inbox") +@login_required +@module_enabled("invoices") +def peppol_inbox(): + """List inbound Peppol documents received via the bridge inbox.""" + status = (request.args.get("status") or "").strip().lower() + query = PeppolInboundDocument.query.order_by(PeppolInboundDocument.created_at.desc()) + if status in {"new", "acknowledged", "error"}: + query = query.filter(PeppolInboundDocument.status == status) + documents = query.limit(200).all() + settings = Settings.get_settings() + inbox_enabled = bool(getattr(settings, "peppol_inbox_enabled", False)) + return render_template( + "invoices/peppol_inbox.html", + documents=documents, + status_filter=status, + inbox_enabled=inbox_enabled, + ) + + +@invoices_bp.route("/invoices/peppol-inbox/") +@login_required +@module_enabled("invoices") +def peppol_inbox_detail(doc_id: int): + doc = PeppolInboundDocument.query.get_or_404(doc_id) + return render_template("invoices/peppol_inbox_detail.html", document=doc) + + +@invoices_bp.route("/invoices/peppol-inbox//ubl") +@login_required +@module_enabled("invoices") +def peppol_inbox_download_ubl(doc_id: int): + doc = PeppolInboundDocument.query.get_or_404(doc_id) + if not doc.ubl_xml: + flash(_("No UBL XML is stored for this document."), "error") + return redirect(url_for("invoices.peppol_inbox_detail", doc_id=doc.id)) + filename = f"{doc.document_number or doc.external_id or 'inbound'}.xml" + return Response( + doc.ubl_xml, + mimetype="application/xml", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@invoices_bp.route("/invoices/peppol-inbox/poll", methods=["POST"]) +@login_required +@module_enabled("invoices") +def peppol_inbox_poll_now(): + """Manually trigger an inbox poll (admins / invoice managers).""" + if not (getattr(current_user, "is_admin", False) or current_user.has_permission("manage_invoices")): + flash(_("You do not have permission to poll the Peppol inbox."), "error") + return redirect(url_for("invoices.peppol_inbox")) + try: + from app.services.peppol_inbound_service import PeppolInboundService + + result = PeppolInboundService().poll_and_import() + if result.get("skipped"): + flash(_("Peppol inbox polling is disabled. Enable it in Peppol settings."), "warning") + elif result.get("ok"): + flash( + _("Peppol inbox poll finished: %(fetched)s fetched, %(created)s new, %(updated)s updated.") + % { + "fetched": result.get("fetched", 0), + "created": result.get("created", 0), + "updated": result.get("updated", 0), + }, + "success", + ) + else: + flash(_("Peppol inbox poll completed with errors."), "warning") + except Exception: + current_app.logger.exception("Manual Peppol inbox poll failed") + flash(_("Failed to poll Peppol inbox."), "error") + return redirect(url_for("invoices.peppol_inbox")) diff --git a/app/routes/oauth_server.py b/app/routes/oauth_server.py new file mode 100644 index 000000000..b345189f4 --- /dev/null +++ b/app/routes/oauth_server.py @@ -0,0 +1,474 @@ +"""OAuth 2.0 authorization server (authorization_code + PKCE, refresh, revoke). + +Hand-rolled against existing OAuthApplication / OAuthAuthorizationCode / +OAuthAccessToken models. See docs/design/OAUTH2_API_APPS.md. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +from datetime import datetime +from urllib.parse import urlencode + +from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from app import csrf, db, limiter +from app.models import OAuthAccessToken, OAuthApplication, OAuthAuthorizationCode +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required +from app.utils.safe_redirect import match_registered_redirect + +oauth_server_bp = Blueprint("oauth_server", __name__) + + +def _pkce_s256_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _verify_pkce(verifier: str, challenge: str, method: str | None) -> bool: + method = (method or "S256").upper() + if method != "S256": + return False + if not verifier or not challenge: + return False + computed = _pkce_s256_challenge(verifier) + return hmac.compare_digest(computed, challenge) + + +def _intersect_scopes(requested: str, allowed: list[str]) -> str: + req = [s.strip() for s in (requested or "").split() if s.strip()] + if not req and allowed: + return ",".join(allowed) + if not allowed: + return ",".join(req) + allowed_set = set(allowed) + granted = [s for s in req if s in allowed_set] + return ",".join(granted) + + +def _authenticate_client() -> tuple[OAuthApplication | None, str | None]: + """Resolve confidential/public client from Basic auth or form body.""" + client_id = None + client_secret = None + + auth = request.headers.get("Authorization") or "" + if auth.lower().startswith("basic "): + try: + decoded = base64.b64decode(auth[6:].strip()).decode("utf-8") + client_id, _, client_secret = decoded.partition(":") + except Exception: + return None, "invalid_client" + + client_id = client_id or request.form.get("client_id") or (request.get_json(silent=True) or {}).get("client_id") + client_secret = ( + client_secret or request.form.get("client_secret") or (request.get_json(silent=True) or {}).get("client_secret") + ) + + if not client_id: + return None, "invalid_client" + + app_rec = OAuthApplication.query.filter_by(client_id=client_id, is_active=True).first() + if not app_rec: + return None, "invalid_client" + + if app_rec.is_confidential: + if not client_secret or not app_rec.client_secret_hash: + return None, "invalid_client" + if not hmac.compare_digest(app_rec.client_secret_hash, OAuthApplication.hash_secret(client_secret)): + return None, "invalid_client" + + return app_rec, None + + +def _form_or_json(key: str, default=None): + if key in request.form: + return request.form.get(key, default) + data = request.get_json(silent=True) or {} + return data.get(key, default) + + +# --------------------------------------------------------------------------- +# Authorization endpoint +# --------------------------------------------------------------------------- + + +@oauth_server_bp.route("/oauth/authorize", methods=["GET", "POST"]) +@login_required +@limiter.limit("30 per minute") +def authorize(): + if request.method == "GET": + client_id = request.args.get("client_id", "") + redirect_uri = request.args.get("redirect_uri", "") + response_type = request.args.get("response_type", "") + scope = request.args.get("scope", "") + state = request.args.get("state", "") + code_challenge = request.args.get("code_challenge", "") + code_challenge_method = request.args.get("code_challenge_method", "S256") + + app_rec = OAuthApplication.query.filter_by(client_id=client_id, is_active=True).first() + if not app_rec: + return render_template("oauth/authorize.html", error="Unknown or inactive client."), 400 + if response_type != "code": + return render_template("oauth/authorize.html", error="Unsupported response_type."), 400 + if redirect_uri not in app_rec.redirect_uri_list(): + return render_template("oauth/authorize.html", error="redirect_uri mismatch."), 400 + if not code_challenge: + return render_template("oauth/authorize.html", error="PKCE code_challenge is required."), 400 + if (code_challenge_method or "S256").upper() != "S256": + return render_template("oauth/authorize.html", error="Only S256 PKCE is supported."), 400 + + granted_scope = _intersect_scopes(scope, app_rec.scope_list()) + return render_template( + "oauth/authorize.html", + application=app_rec, + client_id=client_id, + redirect_uri=redirect_uri, + scope=granted_scope, + scope_list=[s for s in granted_scope.split(",") if s], + state=state, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method or "S256", + error=None, + ) + + # POST — consent + decision = (request.form.get("decision") or "").strip().lower() + client_id = request.form.get("client_id", "") + redirect_uri = request.form.get("redirect_uri", "") + scope = request.form.get("scope", "") + state = request.form.get("state", "") + code_challenge = request.form.get("code_challenge", "") + code_challenge_method = request.form.get("code_challenge_method", "S256") + + app_rec = OAuthApplication.query.filter_by(client_id=client_id, is_active=True).first() + matched_redirect = match_registered_redirect(redirect_uri, app_rec.redirect_uri_list()) if app_rec else None + if not app_rec or not matched_redirect: + return render_template("oauth/authorize.html", error="Invalid authorization request."), 400 + + if decision != "approve": + params = {"error": "access_denied"} + if state: + params["state"] = state + sep = "&" if "?" in matched_redirect else "?" + # codeql[py/url-redirection]: matched_redirect is allowlisted registered URI + return redirect(f"{matched_redirect}{sep}{urlencode(params)}") + + # Consent form may pass comma-separated scopes + scope_for_intersect = scope.replace(",", " ") + granted = _intersect_scopes(scope_for_intersect, app_rec.scope_list()) + if not granted and scope: + granted = ",".join(s.strip() for s in scope.replace(" ", ",").split(",") if s.strip() in app_rec.scope_list()) + + code_rec, plain_code = OAuthAuthorizationCode.create_code( + application_id=app_rec.id, + user_id=current_user.id, + redirect_uri=matched_redirect, + scope=granted, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method or "S256", + ) + db.session.add(code_rec) + safe_commit("oauth_consent_granted", {"app_id": app_rec.id, "user_id": current_user.id}) + + params = {"code": plain_code} + if state: + params["state"] = state + sep = "&" if "?" in matched_redirect else "?" + # codeql[py/url-redirection]: matched_redirect is allowlisted registered URI + return redirect(f"{matched_redirect}{sep}{urlencode(params)}") + + +# --------------------------------------------------------------------------- +# Token endpoint +# --------------------------------------------------------------------------- + + +@oauth_server_bp.route("/oauth/token", methods=["POST"]) +@csrf.exempt +@limiter.limit("60 per minute") +def token(): + app_rec, err = _authenticate_client() + if err or not app_rec: + return jsonify({"error": "invalid_client"}), 401 + + grant_type = (_form_or_json("grant_type") or "").strip() + + if grant_type == "authorization_code": + return _token_authorization_code(app_rec) + if grant_type == "refresh_token": + return _token_refresh(app_rec) + + return jsonify({"error": "unsupported_grant_type"}), 400 + + +def _token_authorization_code(app_rec: OAuthApplication): + code = (_form_or_json("code") or "").strip() + redirect_uri = (_form_or_json("redirect_uri") or "").strip() + code_verifier = (_form_or_json("code_verifier") or "").strip() + + if not code or not redirect_uri: + return jsonify({"error": "invalid_request"}), 400 + + code_rec = OAuthAuthorizationCode.query.filter_by( + code_hash=OAuthAuthorizationCode.hash_code(code), + application_id=app_rec.id, + ).first() + if not code_rec or not code_rec.is_valid(): + return jsonify({"error": "invalid_grant"}), 400 + if code_rec.redirect_uri != redirect_uri: + return jsonify({"error": "invalid_grant"}), 400 + if not _verify_pkce(code_verifier, code_rec.code_challenge or "", code_rec.code_challenge_method): + return jsonify({"error": "invalid_grant", "error_description": "PKCE verification failed"}), 400 + + code_rec.used_at = datetime.utcnow() + access_rec, plain_access, plain_refresh = OAuthAccessToken.create_token( + application_id=app_rec.id, + user_id=code_rec.user_id, + scopes=code_rec.scope or "", + ) + db.session.add(access_rec) + safe_commit("oauth_token_issued", {"app_id": app_rec.id, "user_id": code_rec.user_id}) + + body = { + "access_token": plain_access, + "token_type": "Bearer", + "expires_in": max(0, int((access_rec.expires_at - datetime.utcnow()).total_seconds())), + "scope": (access_rec.scopes or "").replace(",", " "), + } + if plain_refresh: + body["refresh_token"] = plain_refresh + return jsonify(body) + + +def _token_refresh(app_rec: OAuthApplication): + refresh = (_form_or_json("refresh_token") or "").strip() + if not refresh: + return jsonify({"error": "invalid_request"}), 400 + + refresh_hashes = OAuthAccessToken.lookup_hashes(refresh) + existing = OAuthAccessToken.query.filter( + OAuthAccessToken.refresh_token_hash.in_(refresh_hashes), + OAuthAccessToken.application_id == app_rec.id, + ).first() + if not existing or existing.revoked_at is not None: + return jsonify({"error": "invalid_grant"}), 400 + if existing.refresh_expires_at and existing.refresh_expires_at < datetime.utcnow(): + return jsonify({"error": "invalid_grant"}), 400 + + # Rotate: revoke old access token, issue new pair + existing.revoke() + access_rec, plain_access, plain_refresh = OAuthAccessToken.create_token( + application_id=app_rec.id, + user_id=existing.user_id, + scopes=existing.scopes or "", + ) + db.session.add(access_rec) + safe_commit("oauth_token_refreshed", {"app_id": app_rec.id, "user_id": existing.user_id}) + + body = { + "access_token": plain_access, + "token_type": "Bearer", + "expires_in": max(0, int((access_rec.expires_at - datetime.utcnow()).total_seconds())), + "scope": (access_rec.scopes or "").replace(",", " "), + } + if plain_refresh: + body["refresh_token"] = plain_refresh + return jsonify(body) + + +# --------------------------------------------------------------------------- +# Revoke +# --------------------------------------------------------------------------- + + +@oauth_server_bp.route("/oauth/revoke", methods=["POST"]) +@csrf.exempt +@limiter.limit("60 per minute") +def revoke(): + app_rec, err = _authenticate_client() + # RFC 7009: also allow bearer token without client auth for public clients + token_value = (_form_or_json("token") or "").strip() + if not token_value: + return jsonify({"error": "invalid_request"}), 400 + + revoked = False + token_hashes = OAuthAccessToken.lookup_hashes(token_value) + if token_value.startswith("tto_"): + rec = OAuthAccessToken.query.filter(OAuthAccessToken.token_hash.in_(token_hashes)).first() + if rec and (app_rec is None or rec.application_id == app_rec.id): + rec.revoke() + revoked = True + elif token_value.startswith("ttr_"): + rec = OAuthAccessToken.query.filter(OAuthAccessToken.refresh_token_hash.in_(token_hashes)).first() + if rec and (app_rec is None or rec.application_id == app_rec.id): + rec.revoke() + revoked = True + + if revoked: + safe_commit("oauth_token_revoked", {}) + # RFC 7009: always 200 + return "", 200 + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + + +@oauth_server_bp.route("/.well-known/oauth-authorization-server", methods=["GET"]) +def well_known(): + issuer = current_app.config.get("APP_URL") or request.url_root.rstrip("/") + return jsonify( + { + "issuer": issuer, + "authorization_endpoint": url_for("oauth_server.authorize", _external=True), + "token_endpoint": url_for("oauth_server.token", _external=True), + "revocation_endpoint": url_for("oauth_server.revoke", _external=True), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"], + } + ) + + +# --------------------------------------------------------------------------- +# Admin UI: /admin/oauth-apps +# --------------------------------------------------------------------------- + + +@oauth_server_bp.route("/admin/oauth-apps", methods=["GET"]) +@login_required +@admin_or_permission_required("access_admin") +def admin_list_apps(): + apps = OAuthApplication.query.order_by(OAuthApplication.created_at.desc()).all() + return render_template("admin/oauth_apps.html", apps=apps, new_secret=None, new_app=None) + + +@oauth_server_bp.route("/admin/oauth-apps", methods=["POST"]) +@login_required +@admin_or_permission_required("access_admin") +def admin_create_app(): + name = (request.form.get("name") or "").strip() + description = (request.form.get("description") or "").strip() + redirect_uris_raw = (request.form.get("redirect_uris") or "").strip() + allowed_scopes = ( + request.form.get("allowed_scopes") or "read:projects,read:time_entries,write:time_entries" + ).strip() + is_confidential = request.form.get("is_confidential") == "1" + + if not name: + apps = OAuthApplication.query.order_by(OAuthApplication.created_at.desc()).all() + return render_template("admin/oauth_apps.html", apps=apps, error="Name is required."), 400 + + redirect_uris = [u.strip() for u in redirect_uris_raw.replace(",", "\n").splitlines() if u.strip()] + app_rec, plain_secret = OAuthApplication.create_application( + owner_user_id=current_user.id, + name=name, + redirect_uris=redirect_uris, + description=description, + allowed_scopes=allowed_scopes, + is_confidential=is_confidential, + ) + db.session.add(app_rec) + safe_commit("oauth_app.created", {"name": name}) + apps = OAuthApplication.query.order_by(OAuthApplication.created_at.desc()).all() + return render_template( + "admin/oauth_apps.html", + apps=apps, + new_secret=plain_secret, + new_app=app_rec, + ) + + +@oauth_server_bp.route("/admin/oauth-apps//rotate", methods=["POST"]) +@login_required +@admin_or_permission_required("access_admin") +def admin_rotate_secret(app_id: int): + app_rec = OAuthApplication.query.get_or_404(app_id) + plain = OAuthApplication.generate_client_secret() + app_rec.client_secret_hash = OAuthApplication.hash_secret(plain) + app_rec.is_confidential = True + app_rec.updated_at = datetime.utcnow() + safe_commit("oauth_app.secret_rotated", {"app_id": app_id}) + apps = OAuthApplication.query.order_by(OAuthApplication.created_at.desc()).all() + return render_template("admin/oauth_apps.html", apps=apps, new_secret=plain, new_app=app_rec) + + +@oauth_server_bp.route("/admin/oauth-apps//delete", methods=["POST"]) +@login_required +@admin_or_permission_required("access_admin") +def admin_delete_app(app_id: int): + app_rec = OAuthApplication.query.get_or_404(app_id) + db.session.delete(app_rec) + safe_commit("oauth_app.deleted", {"app_id": app_id}) + return redirect(url_for("oauth_server.admin_list_apps")) + + +# --------------------------------------------------------------------------- +# Minimal API: /api/v1/oauth/applications +# --------------------------------------------------------------------------- + + +@oauth_server_bp.route("/api/v1/oauth/applications", methods=["GET"]) +@csrf.exempt +def api_list_applications(): + from flask import g + + from app.utils.api_auth import authenticate_token, extract_token_from_request + + token_string = extract_token_from_request() + user, api_token, err = authenticate_token(token_string or "", record_usage=True) + if not user or not api_token: + return jsonify({"error": "unauthorized", "message": err or "Authentication required"}), 401 + if not api_token.has_scope("admin:all"): + return jsonify({"error": "forbidden", "message": 'Requires scope "admin:all"'}), 403 + g.api_user = user + g.api_token = api_token + apps = OAuthApplication.query.order_by(OAuthApplication.created_at.desc()).all() + return jsonify({"applications": [a.to_dict() for a in apps]}) + + +@oauth_server_bp.route("/api/v1/oauth/applications", methods=["POST"]) +@csrf.exempt +def api_create_application(): + from flask import g + + from app.utils.api_auth import authenticate_token, extract_token_from_request + + token_string = extract_token_from_request() + user, api_token, err = authenticate_token(token_string or "", record_usage=True) + if not user or not api_token: + return jsonify({"error": "unauthorized", "message": err or "Authentication required"}), 401 + if not api_token.has_scope("admin:all"): + return jsonify({"error": "forbidden", "message": 'Requires scope "admin:all"'}), 403 + g.api_user = user + g.api_token = api_token + + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "name is required"}), 400 + redirect_uris = data.get("redirect_uris") or [] + if isinstance(redirect_uris, str): + redirect_uris = [u.strip() for u in redirect_uris.split(",") if u.strip()] + scopes = data.get("allowed_scopes") or "read:projects,read:time_entries,write:time_entries" + if isinstance(scopes, list): + scopes = ",".join(scopes) + app_rec, plain_secret = OAuthApplication.create_application( + owner_user_id=user.id, + name=name, + redirect_uris=redirect_uris, + description=data.get("description") or "", + allowed_scopes=scopes, + is_confidential=bool(data.get("is_confidential", True)), + ) + db.session.add(app_rec) + safe_commit("oauth_app.created_api", {"name": name}) + payload = app_rec.to_dict() + payload["client_secret"] = plain_secret + return jsonify(payload), 201 diff --git a/app/routes/projects.py b/app/routes/projects.py index b108c7e60..e10bf72d4 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -88,9 +88,9 @@ def list_projects(): # Get custom field filters # Format: custom_field_=value client_custom_field = {} - from app.models import CustomFieldDefinition + from app.services.custom_field_service import CustomFieldService - active_definitions = CustomFieldDefinition.get_active_definitions() + active_definitions = CustomFieldService().get_definitions("project") for definition in active_definitions: field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip() if field_value: @@ -140,9 +140,9 @@ def list_projects(): client_list = [c.name for c in clients] # Get custom field definitions for filter UI - from app.models import CustomFieldDefinition + from app.services.custom_field_service import CustomFieldService - custom_field_definitions = CustomFieldDefinition.get_active_definitions() + custom_field_definitions = CustomFieldService().get_definitions("project") # Check if this is an AJAX request if request.headers.get("X-Requested-With") == "XMLHttpRequest": @@ -426,33 +426,19 @@ def _create_form_error(message, status=400): elif color_val == "": project.color = None - # Parse custom fields from global definitions - # Format: custom_field_ = value - from app.models import CustomFieldDefinition - - custom_fields = {} - active_definitions = CustomFieldDefinition.get_active_definitions() - - for definition in active_definitions: - field_value = request.form.get(f"custom_field_{definition.field_key}", "").strip() - if field_value: - custom_fields[definition.field_key] = field_value - elif definition.is_mandatory: - # Validate mandatory fields — skip for minimal AJAX creates (Issue #728) - if not wants_json: - flash(_("Custom field '%(field)s' is required", field=definition.label), "error") - custom_field_definitions = CustomFieldDefinition.get_active_definitions() - return render_template( - "projects/create.html", - clients=clients, - only_one_client=only_one_client, - single_client=single_client, - custom_field_definitions=custom_field_definitions, - ) - - # Set custom fields if any - if custom_fields: - project.custom_fields = custom_fields + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + missing_label = cf_service.apply_from_form(project, "project", request.form) + if missing_label and not wants_json: + flash(_("Custom field '%(field)s' is required", field=missing_label), "error") + return render_template( + "projects/create.html", + clients=clients, + only_one_client=only_one_client, + single_client=single_client, + custom_field_definitions=cf_service.get_definitions("project"), + ) # Persist color and/or custom fields if not safe_commit("create_project_custom_fields_and_color", {"project_id": project.id}): @@ -467,13 +453,12 @@ def _create_form_error(message, status=400): 500, ) flash(_("Could not save project due to a database error"), "error") - custom_field_definitions = CustomFieldDefinition.get_active_definitions() return render_template( "projects/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client, - custom_field_definitions=custom_field_definitions, + custom_field_definitions=CustomFieldService().get_definitions("project"), ) # Track project created event @@ -565,9 +550,9 @@ def _create_form_error(message, status=400): flash(f'Project "{name}" created successfully', "success") return redirect(url_for("projects.view_project", project_id=project.id)) - from app.models import CustomFieldDefinition + from app.services.custom_field_service import CustomFieldService - custom_field_definitions = CustomFieldDefinition.get_active_definitions() + custom_field_definitions = CustomFieldService().get_definitions("project") return render_template( "projects/create.html", clients=clients, @@ -605,18 +590,11 @@ def view_project(project_id): # Get custom field definitions and link templates from sqlalchemy.exc import ProgrammingError - from app.models import CustomFieldDefinition, LinkTemplate, Milestone + from app.models import LinkTemplate, Milestone + from app.services.custom_field_service import CustomFieldService - custom_field_definitions_by_key = {} - try: - for definition in CustomFieldDefinition.get_active_definitions(): - custom_field_definitions_by_key[definition.field_key] = definition - except ProgrammingError as e: - if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): - current_app.logger.warning("custom_field_definitions table does not exist. Run migration: flask db upgrade") - custom_field_definitions_by_key = {} - else: - raise + cf_service = CustomFieldService() + custom_field_definitions_by_key = CustomFieldService.definitions_by_key(cf_service.get_definitions("project")) link_templates_by_field = {} try: @@ -1076,46 +1054,29 @@ def edit_project(project_id): elif color_val == "": project.color = None - # Parse custom fields from global definitions - # Format: custom_field_ = value - from app.models import CustomFieldDefinition - - custom_fields = {} - active_definitions = CustomFieldDefinition.get_active_definitions() - - for definition in active_definitions: - field_value = request.form.get(f"custom_field_{definition.field_key}", "").strip() - if field_value: - custom_fields[definition.field_key] = field_value - elif definition.is_mandatory: - # Validate mandatory fields - flash(_("Custom field '%(field)s' is required", field=definition.label), "error") - custom_field_definitions = CustomFieldDefinition.get_active_definitions() - return render_template( - "projects/edit.html", - project=project, - clients=clients, - only_one_client=only_one_client, - single_client=single_client, - custom_field_definitions=custom_field_definitions, - ) + from app.services.custom_field_service import CustomFieldService - # Update custom fields - if custom_fields: - project.custom_fields = custom_fields - else: - # Clear custom fields when all are empty - project.custom_fields = {} + cf_service = CustomFieldService() + missing_label = cf_service.apply_from_form(project, "project", request.form) + if missing_label: + flash(_("Custom field '%(field)s' is required", field=missing_label), "error") + return render_template( + "projects/edit.html", + project=project, + clients=clients, + only_one_client=only_one_client, + single_client=single_client, + custom_field_definitions=cf_service.get_definitions("project"), + ) # Commit custom fields and color changes if not safe_commit("update_project_custom_fields_and_color", {"project_id": project.id}): flash(_("Could not update project due to a database error"), "error") - custom_field_definitions = CustomFieldDefinition.get_active_definitions() return render_template( "projects/edit.html", project=project, clients=clients, - custom_field_definitions=custom_field_definitions, + custom_field_definitions=cf_service.get_definitions("project"), ) # Log activity @@ -1133,9 +1094,9 @@ def edit_project(project_id): flash(f'Project "{name}" updated successfully', "success") return redirect(url_for("projects.view_project", project_id=project.id)) - from app.models import CustomFieldDefinition + from app.services.custom_field_service import CustomFieldService - custom_field_definitions = CustomFieldDefinition.get_active_definitions() + custom_field_definitions = CustomFieldService().get_definitions("project") return render_template( "projects/edit.html", project=project, diff --git a/app/routes/reports.py b/app/routes/reports.py index 198c10b3c..232f438b6 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -329,6 +329,24 @@ def export_form(): ) +def _apply_export_filters(query, client_id, task_id, billable, source, tags): + """Apply the export form's client, task, billable, source and tags filters.""" + if client_id: + query = query.filter( + or_(TimeEntry.client_id == client_id, TimeEntry.project.has(Project.client_id == client_id)) + ) + if task_id: + query = query.filter(TimeEntry.task_id == task_id) + if billable in ("yes", "no"): + query = query.filter(TimeEntry.billable == (billable == "yes")) + if source and source != "all": + query = query.filter(TimeEntry.source == source) + tag_list = [tag.strip() for tag in tags.split(",") if tag.strip()] + if tag_list: + query = query.filter(or_(*[TimeEntry.tags.ilike(f"%{tag}%") for tag in tag_list])) + return query + + @reports_bp.route("/reports/export/csv") @login_required @module_enabled("reports") @@ -390,6 +408,8 @@ def export_csv(): if project_id: query = query.filter(TimeEntry.project_id == project_id) + query = _apply_export_filters(query, client_id, task_id, billable, source, tags) + entries = ( query.options( joinedload(TimeEntry.project).joinedload(Project.client_obj), @@ -1057,6 +1077,8 @@ def time_entries_export_csv(): @module_enabled("reports") def export_excel(): """Export time entries as Excel file""" + from app.utils.client_lock import enforce_locked_client_id + start_date = request.args.get("start_date") end_date = request.args.get("end_date") user_id = request.args.get("user_id", type=int) @@ -1096,6 +1118,15 @@ def export_excel(): if project_id: query = query.filter(TimeEntry.project_id == project_id) + query = _apply_export_filters( + query, + enforce_locked_client_id(request.args.get("client_id", type=int)), + request.args.get("task_id", type=int), + request.args.get("billable"), + request.args.get("source"), + request.args.get("tags", "").strip(), + ) + entries = ( query.options( joinedload(TimeEntry.project).joinedload(Project.client_obj), diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 561595a81..5040467a9 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -45,6 +45,12 @@ ALLOWED_NEXT_PREFIXES = ("/kanban", "/gantt", "/tasks", "/projects") +def _task_custom_field_definitions(): + from app.services.custom_field_service import CustomFieldService + + return CustomFieldService().get_definitions("task") + + def _user_can_access_task(task): """Admin, any assignee, or creator may view/act on a task.""" return current_user.is_admin or task.is_assigned_to(current_user.id) or task.created_by == current_user.id @@ -346,6 +352,24 @@ def create_task(): task = result["task"] + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + missing_label = cf_service.apply_from_form(task, "task", request.form) + if missing_label: + flash(_("Custom field '%(field)s' is required", field=missing_label), "error") + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + users = User.query.order_by(User.username).all() + return render_template( + "tasks/create.html", + projects=projects, + users=users, + kanban_columns=KanbanColumn.get_columns_with_global_fallback(project_id=project_id), + custom_field_definitions=_task_custom_field_definitions(), + ) + if not safe_commit("create_task_custom_fields", {"task_id": task.id}): + flash(_("Could not save custom fields due to a database error."), "error") + # Log task creation app_module.log_event( "task.created", user_id=current_user.id, task_id=task.id, project_id=project_id, priority=priority @@ -384,6 +408,7 @@ def create_task(): projects=projects, users=users, kanban_columns=KanbanColumn.get_active_columns(project_id=None), + custom_field_definitions=_task_custom_field_definitions(), ) @@ -456,6 +481,11 @@ def view_task(task_id): comments = [c for c in all_comments if c.parent_id is None] sibling_tasks = Task.query.filter(Task.project_id == task.project_id, Task.id != task.id).order_by(Task.name).all() + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + task_cf_defs = cf_service.get_definitions("task") + return render_template( "tasks/view.html", task=task, @@ -463,6 +493,7 @@ def view_task(task_id): activities=activities, comments=comments, sibling_tasks=sibling_tasks, + custom_field_definitions_by_key=CustomFieldService.definitions_by_key(task_cf_defs), ) @@ -620,6 +651,24 @@ def edit_task(task_id): selected_status = request.form.get("status", "").strip() valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) if selected_status and selected_status in valid_statuses and selected_status != task.status: + wip_error = KanbanColumn.wip_violation_message(task.project_id, selected_status, task_id=task.id) + if wip_error: + flash(wip_error, "error") + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + task_custom_defs = cf_service.get_definitions("task") + return render_template( + "tasks/edit.html", + task=task, + projects=projects, + users=users, + kanban_columns=KanbanColumn.get_columns_with_global_fallback(project_id=task.project_id), + milestones=Milestone.query.filter_by(project_id=task.project_id) + .order_by(Milestone.due_date.asc().nullslast(), Milestone.name) + .all(), + custom_field_definitions=task_custom_defs, + ) try: previous_status = task.status if selected_status == "in_progress": @@ -732,6 +781,24 @@ def edit_task(task_id): kanban_columns=KanbanColumn.get_columns_with_global_fallback(project_id=task.project_id), ) + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + missing_label = cf_service.apply_from_form(task, "task", request.form) + if missing_label: + flash(_("Custom field '%(field)s' is required", field=missing_label), "error") + return render_template( + "tasks/edit.html", + task=task, + projects=projects, + users=users, + kanban_columns=KanbanColumn.get_columns_with_global_fallback(project_id=task.project_id), + milestones=Milestone.query.filter_by(project_id=task.project_id) + .order_by(Milestone.due_date.asc().nullslast(), Milestone.name) + .all(), + custom_field_definitions=_task_custom_field_definitions(), + ) + # Always update the updated_at timestamp to local time after edits task.updated_at = now_in_app_timezone() @@ -777,6 +844,7 @@ def edit_task(task_id): milestones=Milestone.query.filter_by(project_id=task.project_id) .order_by(Milestone.due_date.asc().nullslast(), Milestone.name) .all(), + custom_field_definitions=_task_custom_field_definitions(), ) @@ -798,6 +866,11 @@ def update_task_status(task_id): flash(_("Invalid status"), "error") return redirect(url_for("tasks.view_task", task_id=task.id)) + wip_error = KanbanColumn.wip_violation_message(task.project_id, new_status, task_id=task.id) + if wip_error: + flash(wip_error, "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Update status previous_status = task.status try: @@ -1724,6 +1797,10 @@ def api_update_status(task_id): if new_status not in valid_statuses: return jsonify({"error": "Invalid status"}), 400 + wip_error = KanbanColumn.wip_violation_message(task.project_id, new_status, task_id=task.id) + if wip_error: + return jsonify({"error": wip_error, "code": "WIP_LIMIT"}), 409 + previous_status = task.status # Update status try: diff --git a/app/routes/timer.py b/app/routes/timer.py index 7d26fbede..2971081d1 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -150,6 +150,7 @@ def _edit_timer_form_projects_tasks(timer, can_edit_schedule): def _edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown): + from app.services.custom_field_service import CustomFieldService from app.utils.scope_filter import get_active_clients_for_user projects, tasks = _edit_timer_form_projects_tasks(timer, can_edit_schedule) @@ -163,6 +164,7 @@ def _edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown): "show_source_dropdown": show_source_dropdown, "can_create_clients": current_user.is_admin or current_user.has_permission("create_clients"), "can_create_projects": current_user.is_admin or current_user.has_permission("create_projects"), + "custom_field_definitions": CustomFieldService().get_definitions("time_entry"), } @@ -1245,6 +1247,21 @@ def edit_timer(timer_id): else: update_params["break_seconds"] = 0 + from types import SimpleNamespace + + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + custom_fields_holder = SimpleNamespace(custom_fields={}) + missing_label = cf_service.apply_from_form(custom_fields_holder, "time_entry", request.form) + if missing_label: + flash(_("Custom field '%(field)s' is required", field=missing_label), "error") + return render_template( + "timer/edit_timer.html", + **_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown), + ) + update_params["custom_fields"] = custom_fields_holder.custom_fields + # Call service layer to update result = service.update_entry(**update_params) @@ -1577,6 +1594,10 @@ def manual_entry(): users = _active_users_for_admin() selected_user_id = current_user.id + from app.services.custom_field_service import CustomFieldService + + time_entry_custom_defs = CustomFieldService().get_definitions("time_entry") + def _manual_ctx(**extra): ctx = { "projects": active_projects, @@ -1591,6 +1612,7 @@ def _manual_ctx(**extra): "template_data": template_data, "can_create_clients": current_user.is_admin or current_user.has_permission("create_clients"), "can_create_projects": current_user.is_admin or current_user.has_permission("create_projects"), + "custom_field_definitions": time_entry_custom_defs, } ctx.update(extra) return ctx @@ -1756,6 +1778,17 @@ def _parse_worked_time_minutes(raw: str): # Use service to create entry (handles validation) time_tracking_service = TimeTrackingService() + from types import SimpleNamespace + + from app.services.custom_field_service import CustomFieldService + + cf_service = CustomFieldService() + custom_fields_holder = SimpleNamespace(custom_fields={}) + missing_label = cf_service.apply_from_form(custom_fields_holder, "time_entry", request.form) + if missing_label: + flash(_("Custom field '%(field)s' is required", field=missing_label), "error") + return render_template("timer/manual_entry.html", **_manual_ctx(**prefill_kwargs)) + result = time_tracking_service.create_manual_entry( user_id=target_user_id, project_id=project_id, @@ -1768,6 +1801,7 @@ def _parse_worked_time_minutes(raw: str): notes=notes if notes else None, tags=tags if tags else None, billable=billable, + custom_fields=custom_fields_holder.custom_fields or None, ) if not result.get("success"): diff --git a/app/routes/workforce.py b/app/routes/workforce.py index 9f164fa66..5a970a487 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -833,14 +833,9 @@ def audit_events_export_csv(): ) -@workforce_bp.route("/workforce/reports/belgium-attendance.csv", methods=["GET"]) -@login_required -def belgium_attendance_export_csv(): - if not _can_approve(): - flash(_("Access denied"), "error") - return redirect(url_for("workforce.dashboard")) - - from app.services.attendance_compliance_service import AttendanceComplianceService +def _labor_compliance_csv_response(preset_id: str): + """Shared CSV builder for labor-compliance presets (incl. belgium-attendance).""" + from app.services.labor_compliance_service import LaborComplianceService start = _parse_date(request.args.get("start_date")) end = _parse_date(request.args.get("end_date")) @@ -853,7 +848,21 @@ def belgium_attendance_export_csv(): if not current_user.is_admin: user_id = current_user.id - rows = AttendanceComplianceService().belgium_inspector_rows(start_date=start, end_date=end, user_id=user_id) + weekly_threshold = request.args.get("weekly_threshold", type=float) + overages_only = request.args.get("overages_only", "false").lower() == "true" + + try: + rows, meta = LaborComplianceService().export_rows( + preset_id, + start_date=start, + end_date=end, + user_id=user_id, + weekly_threshold=weekly_threshold, + overages_only=overages_only, + ) + except ValueError: + flash(_("Unknown labor-compliance preset"), "error") + return redirect(url_for("workforce.dashboard")) output = io.StringIO() if rows: @@ -865,9 +874,28 @@ def belgium_attendance_export_csv(): writer.writerow(["message"]) writer.writerow(["No records in range"]) - filename = f"belgium_attendance_{start.isoformat()}_{end.isoformat()}.csv" + slug = meta.get("preset_id") or preset_id + filename = f"{slug.replace('-', '_')}_{start.isoformat()}_{end.isoformat()}.csv" return Response( output.getvalue(), mimetype="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) + + +@workforce_bp.route("/workforce/reports/belgium-attendance.csv", methods=["GET"]) +@login_required +def belgium_attendance_export_csv(): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + return _labor_compliance_csv_response("belgium-attendance") + + +@workforce_bp.route("/workforce/reports/labor-compliance/.csv", methods=["GET"]) +@login_required +def labor_compliance_export_csv(preset_id): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + return _labor_compliance_csv_response(preset_id) diff --git a/app/schemas/time_entry_schema.py b/app/schemas/time_entry_schema.py index 3f1a28442..f4aefb163 100644 --- a/app/schemas/time_entry_schema.py +++ b/app/schemas/time_entry_schema.py @@ -27,6 +27,7 @@ class TimeEntrySchema(Schema): billable = fields.Bool(load_default=True) paid = fields.Bool(load_default=False) invoice_number = fields.Str(allow_none=True, validate=validate.Length(max=100)) + custom_fields = fields.Dict(keys=fields.Str(), values=fields.Raw(), allow_none=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) @@ -52,6 +53,7 @@ class TimeEntryCreateSchema(Schema): billable = fields.Bool(load_default=True) paid = fields.Bool(load_default=False) invoice_number = fields.Str(allow_none=True, validate=validate.Length(max=100)) + custom_fields = fields.Dict(keys=fields.Str(), values=fields.Raw(), allow_none=True) @validates("end_time") def validate_end_time(self, value, **kwargs): @@ -108,6 +110,7 @@ class TimeEntryUpdateSchema(Schema): billable = fields.Bool(allow_none=True) paid = fields.Bool(allow_none=True) invoice_number = fields.Str(allow_none=True, validate=validate.Length(max=100)) + custom_fields = fields.Dict(keys=fields.Str(), values=fields.Raw(), allow_none=True) class TimerStartSchema(Schema): diff --git a/app/services/bank_reconciliation_service.py b/app/services/bank_reconciliation_service.py new file mode 100644 index 000000000..dec9bc546 --- /dev/null +++ b/app/services/bank_reconciliation_service.py @@ -0,0 +1,322 @@ +"""Parse bank statements and suggest / confirm matches against open invoices.""" + +from __future__ import annotations + +import csv +import io +import re +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple + +import defusedxml.ElementTree as ET + +from app import db +from app.models import Invoice +from app.models.bank_transaction import BankTransaction +from app.services.payment_service import PaymentService +from app.utils.db import safe_commit + +DEFAULT_AMOUNT_TOLERANCE = Decimal("0.05") + +# ISO 20022 CAMT local-name matching (namespaces stripped) +_CAMT_DATE_TAGS = ("BookgDt", "ValDt") +_CAMT_REF_TAGS = ("EndToEndId", "AcctSvcrRef", "TxId", "MndtId", "Ref") +_CAMT_NAME_TAGS = ("Nm",) + + +class BankReconciliationService: + """Import CAMT.053 / CSV bank lines and reconcile to invoices.""" + + def parse_csv( + self, + content: str, + *, + account_label: Optional[str] = None, + delimiter: str = ",", + ) -> List[Dict[str, Any]]: + """Parse CSV with date, amount, reference, counterparty columns (flexible headers).""" + reader = csv.DictReader(io.StringIO(content), delimiter=delimiter) + if not reader.fieldnames: + return [] + + field_map = self._map_csv_headers(reader.fieldnames) + rows: List[Dict[str, Any]] = [] + for raw in reader: + booking = self._parse_date(raw.get(field_map.get("date") or "") if field_map.get("date") else None) + amount = self._parse_amount(raw.get(field_map.get("amount") or "") if field_map.get("amount") else None) + if booking is None or amount is None: + continue + reference = (raw.get(field_map["reference"]) or "").strip() if field_map.get("reference") else "" + counterparty = (raw.get(field_map["counterparty"]) or "").strip() if field_map.get("counterparty") else "" + currency = (raw.get(field_map["currency"]) or "EUR").strip().upper() if field_map.get("currency") else "EUR" + if len(currency) != 3: + currency = "EUR" + rows.append( + { + "account_label": account_label, + "booking_date": booking, + "amount": amount, + "currency": currency, + "counterparty": counterparty or None, + "reference": reference or None, + "raw_payload": dict(raw), + } + ) + return rows + + def parse_camt053( + self, + content: str, + *, + account_label: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Basic CAMT.053 XML parser for Ntry credit/debit entries.""" + try: + root = ET.fromstring(content) + except ET.ParseError as exc: + raise ValueError(f"Invalid CAMT.053 XML: {exc}") from exc + + rows: List[Dict[str, Any]] = [] + for ntry in root.iter(): + if self._local(ntry.tag) != "Ntry": + continue + amount_el = None + cdt_dbt = "CRDT" + booking = None + reference_parts: List[str] = [] + counterparty = None + + for child in ntry.iter(): + local = self._local(child.tag) + if local == "Amt" and child.text and amount_el is None: + amount_el = child + elif local == "CdtDbtInd" and child.text: + cdt_dbt = child.text.strip().upper() + elif local in _CAMT_DATE_TAGS: + for sub in child.iter(): + if self._local(sub.tag) == "Dt" and sub.text: + booking = self._parse_date(sub.text.strip()) or booking + break + elif local in _CAMT_REF_TAGS and child.text: + reference_parts.append(child.text.strip()) + elif local in _CAMT_NAME_TAGS and child.text and counterparty is None: + counterparty = child.text.strip() + + if amount_el is None or not amount_el.text: + continue + try: + amount = Decimal(str(amount_el.text).strip().replace(",", "")) + except (InvalidOperation, ValueError): + continue + currency = (amount_el.attrib.get("Ccy") or "EUR").upper() + if cdt_dbt in ("DBIT", "DEBIT"): + amount = -abs(amount) + else: + amount = abs(amount) + if booking is None: + booking = date.today() + rows.append( + { + "account_label": account_label, + "booking_date": booking, + "amount": amount, + "currency": currency, + "counterparty": counterparty, + "reference": " ".join(reference_parts)[:255] if reference_parts else None, + "raw_payload": {"source": "camt053", "cdt_dbt": cdt_dbt}, + } + ) + return rows + + def import_rows(self, parsed_rows: List[Dict[str, Any]]) -> List[BankTransaction]: + created: List[BankTransaction] = [] + for row in parsed_rows: + txn = BankTransaction( + account_label=row.get("account_label"), + booking_date=row["booking_date"], + amount=row["amount"], + currency=row.get("currency") or "EUR", + counterparty=row.get("counterparty"), + reference=row.get("reference"), + raw_payload=row.get("raw_payload"), + match_status=BankTransaction.MATCH_PENDING, + ) + db.session.add(txn) + created.append(txn) + if created and not safe_commit("bank_txn_import", {"count": len(created)}): + raise RuntimeError("Could not save bank transactions") + return created + + def suggest_matches( + self, + transaction: BankTransaction, + *, + amount_tolerance: Optional[Decimal] = None, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """Suggest open invoices by amount ±tolerance and reference/invoice-number substring.""" + tolerance = amount_tolerance if amount_tolerance is not None else DEFAULT_AMOUNT_TOLERANCE + amount = abs(Decimal(transaction.amount)) + low = amount - tolerance + high = amount + tolerance + + open_statuses = ("unpaid", "partially_paid") + candidates = Invoice.query.filter(Invoice.payment_status.in_(open_statuses)).all() + + ref_haystack = " ".join(filter(None, [transaction.reference or "", transaction.counterparty or ""])).lower() + + scored: List[Tuple[float, Invoice]] = [] + for inv in candidates: + outstanding = Decimal(str(inv.outstanding_amount or 0)) + if outstanding <= 0: + continue + # Match against outstanding or full total within tolerance + targets = [outstanding, Decimal(str(inv.total_amount or 0))] + amount_ok = any(low <= abs(t) <= high for t in targets) + if not amount_ok: + continue + + score = 1.0 + inv_num = (inv.invoice_number or "").lower() + if inv_num and inv_num in ref_haystack: + score += 5.0 + elif inv_num and any(tok and tok in inv_num for tok in re.split(r"\W+", ref_haystack) if len(tok) >= 4): + score += 3.0 + # Prefer closer amount + best_diff = min(abs(abs(t) - amount) for t in targets) + score += max(0.0, 1.0 - float(best_diff)) + scored.append((score, inv)) + + scored.sort(key=lambda x: (-x[0], x[1].id or 0)) + results = [] + for score, inv in scored[:limit]: + results.append( + { + "invoice_id": inv.id, + "invoice_number": inv.invoice_number, + "client_name": inv.client_name, + "total_amount": float(inv.total_amount or 0), + "outstanding_amount": float(inv.outstanding_amount or 0), + "currency": inv.currency_code, + "score": round(score, 2), + } + ) + return results + + def confirm_match( + self, + transaction_id: int, + invoice_id: int, + *, + received_by: int, + amount_tolerance: Optional[Decimal] = None, + ) -> Dict[str, Any]: + txn = BankTransaction.query.get(transaction_id) + if not txn: + return {"success": False, "message": "Bank transaction not found", "error": "not_found"} + if txn.match_status != BankTransaction.MATCH_PENDING: + return {"success": False, "message": "Transaction is not pending", "error": "not_pending"} + + invoice = Invoice.query.get(invoice_id) + if not invoice: + return {"success": False, "message": "Invoice not found", "error": "invalid_invoice"} + + pay_amount = abs(Decimal(txn.amount)) + result = PaymentService().create_payment( + invoice_id=invoice.id, + amount=pay_amount, + payment_date=txn.booking_date, + received_by=received_by, + currency=txn.currency or invoice.currency_code, + method="bank_transfer", + reference=txn.reference, + notes=f"Bank reconciliation txn #{txn.id}", + status="completed", + ) + if not result.get("success"): + return result + + payment = result["payment"] + txn.match_status = BankTransaction.MATCH_MATCHED + txn.matched_invoice_id = invoice.id + txn.matched_payment_id = payment.id + if not safe_commit("bank_txn_confirm", {"txn_id": txn.id, "invoice_id": invoice.id}): + return { + "success": False, + "message": "Payment created but could not update transaction", + "error": "database_error", + } + + return {"success": True, "message": "Matched", "transaction": txn, "payment": payment} + + def ignore_transaction(self, transaction_id: int) -> Dict[str, Any]: + txn = BankTransaction.query.get(transaction_id) + if not txn: + return {"success": False, "message": "Bank transaction not found", "error": "not_found"} + if txn.match_status == BankTransaction.MATCH_MATCHED: + return {"success": False, "message": "Cannot ignore a matched transaction", "error": "already_matched"} + txn.match_status = BankTransaction.MATCH_IGNORED + if not safe_commit("bank_txn_ignore", {"txn_id": txn.id}): + return {"success": False, "message": "Could not update transaction", "error": "database_error"} + return {"success": True, "message": "Ignored", "transaction": txn} + + def list_queue(self, status: str = BankTransaction.MATCH_PENDING) -> List[BankTransaction]: + q = BankTransaction.query + if status: + q = q.filter_by(match_status=status) + return q.order_by(BankTransaction.booking_date.desc(), BankTransaction.id.desc()).all() + + @staticmethod + def _local(tag: str) -> str: + if not tag: + return "" + if "}" in tag: + return tag.rsplit("}", 1)[-1] + return tag + + @staticmethod + def _map_csv_headers(fieldnames: List[str]) -> Dict[str, str]: + aliases = { + "date": {"date", "booking_date", "bookingdate", "value_date", "valuedate", "bookgdt"}, + "amount": {"amount", "amt", "value", "sum"}, + "reference": {"reference", "ref", "remittance", "description", "narration", "memo"}, + "counterparty": {"counterparty", "name", "payer", "payee", "counterpart", "party"}, + "currency": {"currency", "ccy", "curr"}, + } + normalized = {f: re.sub(r"[^a-z0-9]", "", (f or "").lower()) for f in fieldnames} + mapping: Dict[str, str] = {} + for key, names in aliases.items(): + for original, norm in normalized.items(): + if norm in names: + mapping[key] = original + break + return mapping + + @staticmethod + def _parse_date(value: Optional[str]) -> Optional[date]: + if not value: + return None + text = str(value).strip() + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%m/%d/%Y", "%Y%m%d"): + try: + return datetime.strptime(text[:10] if len(text) >= 8 else text, fmt).date() + except ValueError: + continue + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None + + @staticmethod + def _parse_amount(value: Optional[str]) -> Optional[Decimal]: + if value is None or str(value).strip() == "": + return None + text = str(value).strip().replace(" ", "").replace(",", ".") + # Handle European "1.234,56" already partially normalized; strip extra dots + if text.count(".") > 1: + text = text.replace(".", "", text.count(".") - 1) + try: + return Decimal(text) + except (InvalidOperation, ValueError): + return None diff --git a/app/services/chat_command_service.py b/app/services/chat_command_service.py new file mode 100644 index 000000000..9d1b3362e --- /dev/null +++ b/app/services/chat_command_service.py @@ -0,0 +1,99 @@ +"""Shared chat slash-command handlers for Slack / Teams timer bots.""" + +from __future__ import annotations + +from typing import Any + + +def handle_timer_command(user, text: str) -> dict[str, Any]: + """Dispatch ``start|stop|status|today`` for a linked TimeTracker user. + + Args: + user: Authenticated :class:`~app.models.user.User` (must have ``id``). + text: Raw command text (without leading slash), e.g. ``\"start My Project\"``. + + Returns: + ``{\"text\": str}`` reply payload suitable for Slack ephemeral or Teams message. + """ + raw_text = (text or "").strip() + parts = raw_text.split(None, 1) + subcommand = (parts[0] or "").lower() if parts else "" + argument = parts[1] if len(parts) > 1 else "" + + if subcommand == "start": + return _cmd_start(user, argument) + if subcommand == "stop": + return _cmd_stop(user) + if subcommand == "status": + return _cmd_status(user) + if subcommand == "today": + return _cmd_today(user) + return { + "text": ( + "Available commands:\n" + "• `start ` — start a timer\n" + "• `stop` — stop the active timer\n" + "• `status` — show the current timer\n" + "• `today` — today's hours summary" + ) + } + + +def _cmd_start(user, argument: str) -> dict[str, Any]: + from app.models import Project + from app.services.time_tracking_service import TimeTrackingService + + argument = (argument or "").strip() + if not argument: + return {"text": "Usage: `start `"} + + project = None + try: + project = Project.query.get(int(argument)) + except (TypeError, ValueError): + project = None + if not project: + project = Project.query.filter(Project.name.ilike(f"%{argument}%")).first() + if not project: + return {"text": f"Project not found: `{argument}`"} + + result = TimeTrackingService().start_timer(user_id=user.id, project_id=project.id) + if result.get("success"): + return {"text": f"Timer started for *{project.name}*."} + return {"text": f"Could not start timer: {result.get('message') or 'unknown error'}"} + + +def _cmd_stop(user) -> dict[str, Any]: + from app.services.time_tracking_service import TimeTrackingService + + result = TimeTrackingService().stop_timer(user_id=user.id) + if result.get("success"): + entry = result.get("entry") + duration = getattr(entry, "duration_formatted", "") + return {"text": f"Timer stopped (duration {duration})."} + return {"text": f"Could not stop timer: {result.get('message') or 'no active timer'}"} + + +def _cmd_status(user) -> dict[str, Any]: + active = getattr(user, "active_timer", None) + if not active: + return {"text": "No active timer."} + project_name = active.project.name if getattr(active, "project", None) else "—" + return {"text": f"Active timer on *{project_name}* (running {active.duration_formatted})."} + + +def _cmd_today(user) -> dict[str, Any]: + try: + from app.services.notification_service import get_today_summary_for_user + + summary = get_today_summary_for_user(user) + except Exception: + import logging + + logging.getLogger(__name__).exception( + "Could not fetch today's summary for user_id=%s", getattr(user, "id", None) + ) + return {"text": "Could not fetch today's summary."} + hours = summary.get("hours", 0) + projects = summary.get("projects", 0) + return {"text": f"Today: *{hours}h* across *{projects}* project(s)."} diff --git a/app/services/custom_field_service.py b/app/services/custom_field_service.py new file mode 100644 index 000000000..efad53fcd --- /dev/null +++ b/app/services/custom_field_service.py @@ -0,0 +1,105 @@ +"""Service helpers for global custom field definitions and entity values.""" + +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.exc import ProgrammingError + + +class CustomFieldService: + """Read definitions and apply custom field values to entities.""" + + def get_definitions(self, entity_type: str) -> List[Any]: + """Return active custom field definitions for the given entity type.""" + from app.models import CustomFieldDefinition + + entity_type = (entity_type or "client").strip().lower() + try: + return ( + CustomFieldDefinition.query.filter_by(is_active=True, entity_type=entity_type) + .order_by(CustomFieldDefinition.order, CustomFieldDefinition.label) + .all() + ) + except ProgrammingError as e: + if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): + try: + from flask import current_app + + if current_app: + current_app.logger.warning( + "custom_field_definitions table does not exist. Run migration: flask db upgrade" + ) + except RuntimeError: + pass + return [] + raise + except Exception: + return [] + + def _form_value(self, form_data: Union[Dict[str, Any], Any], field_key: str) -> str: + name = f"custom_field_{field_key}" + if hasattr(form_data, "get"): + raw = form_data.get(name, "") + else: + raw = form_data.get(name, "") + return (raw or "").strip() if isinstance(raw, str) else str(raw).strip() + + def apply_from_form( + self, + entity, + entity_type: str, + form_data: Union[Dict[str, Any], Any], + *, + validate_mandatory: bool = True, + ) -> Optional[str]: + """Read ``custom_field_`` from form data into ``entity.custom_fields``. Returns error or None.""" + definitions = self.get_definitions(entity_type) + if not definitions: + return None + + collected = {} + for definition in definitions: + field_value = self._form_value(form_data, definition.field_key) + if field_value: + collected[definition.field_key] = field_value + elif validate_mandatory and definition.is_mandatory: + return definition.label + + merged = dict(getattr(entity, "custom_fields", None) or {}) + for definition in definitions: + key = definition.field_key + if key in collected: + merged[key] = collected[key] + elif key in merged: + del merged[key] + + entity.custom_fields = merged if merged else {} + return None + + def apply_from_dict( + self, + entity, + entity_type: str, + payload: Optional[Dict[str, Any]], + *, + merge: bool = True, + ) -> None: + """Apply a ``custom_fields`` dict from an API payload.""" + if payload is None: + return + if not isinstance(payload, dict): + return + if merge: + merged = dict(getattr(entity, "custom_fields", None) or {}) + merged.update(payload) + entity.custom_fields = merged + else: + entity.custom_fields = payload + + @staticmethod + def serialize_for_api(entity) -> Dict[str, Any]: + """Return custom fields for API responses.""" + return dict(getattr(entity, "custom_fields", None) or {}) + + @staticmethod + def definitions_by_key(definitions: List[Any]) -> Dict[str, Any]: + return {d.field_key: d for d in definitions} diff --git a/app/services/external_identity_service.py b/app/services/external_identity_service.py new file mode 100644 index 000000000..e884113a0 --- /dev/null +++ b/app/services/external_identity_service.py @@ -0,0 +1,204 @@ +"""Shared provisioning for external identity providers (OIDC, SAML).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Sequence + +from flask import current_app + +from app import db +from app.config import Config +from app.models import Role, User +from app.utils.config_manager import ConfigManager +from app.utils.db import safe_commit +from app.utils.deleted_usernames import is_username_reserved +from app.utils.posthog_funnels import track_onboarding_started + + +@dataclass +class ProvisionResult: + user: User | None + created: bool = False + error: str | None = None + error_flash: str | None = None + + +def _normalize_username(username: str | None, email: str | None, subject: str) -> str: + username = (username or "").strip() + if username: + return username + if email and "@" in email: + return email.split("@", 1)[0] + return f"user-{(subject or 'unknown')[-8:]}" + + +def _apply_admin_mapping( + user: User, + *, + email: str | None, + groups: Sequence[str] | None, + admin_emails: Iterable[str] | None, + admin_groups: Iterable[str] | None, +) -> bool: + """Promote user.role to admin when email/group matches. Returns True if changed.""" + admin_set = False + group_list = list(groups or []) + emails = [e.strip().lower() for e in (admin_emails or []) if e and str(e).strip()] + group_names = [g for g in (admin_groups or []) if g] + + for g in group_names: + if g in group_list and user.role != "admin": + user.role = "admin" + admin_set = True + break + + if email and email.lower() in emails and user.role != "admin": + user.role = "admin" + admin_set = True + + return admin_set + + +def provision_external_user( + provider: str, + subject: str, + issuer: str, + email: str | None = None, + full_name: str | None = None, + groups: Sequence[str] | None = None, + admin_emails: Iterable[str] | None = None, + admin_groups: Iterable[str] | None = None, + username: str | None = None, +) -> ProvisionResult: + """Find or create a User linked to an external IdP subject. + + For ``provider='oidc'`` also sets legacy ``oidc_sub`` / ``oidc_issuer``. + For ``provider='saml'`` sets ``auth_provider='saml'`` and external_* fields. + + Returns a :class:`ProvisionResult`. On failure ``user`` is None and + ``error`` / ``error_flash`` explain why (caller decides redirect/flash). + """ + provider = (provider or "").strip().lower() or "oidc" + subject = (subject or "").strip() + issuer = (issuer or "").strip() + email = (email or "").strip() or None + full_name = (full_name or "").strip() or None + + if not subject or not issuer: + return ProvisionResult(user=None, error="missing_subject_or_issuer", error_flash="Invalid identity assertion.") + + username = _normalize_username(username, email, subject) + + # Lookup order: external pair → oidc pair (legacy) → email → username + user = User.query.filter_by(external_issuer=issuer, external_subject=subject).first() + if not user and provider == "oidc": + user = User.query.filter_by(oidc_issuer=issuer, oidc_sub=subject).first() + if not user and email: + user = User.query.filter_by(email=email).first() + if not user: + user = User.query.filter_by(username=username).first() + + created = False + if not user: + if current_app.config.get("DEMO_MODE"): + return ProvisionResult( + user=None, + error="demo_mode_no_create", + error_flash="Demo mode: only the demo account can be used. Please use the credentials on the login page.", + ) + allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) + if not allow_self_register: + return ProvisionResult( + user=None, + error="self_registration_disabled", + error_flash="User account does not exist and self-registration is disabled.", + ) + if is_username_reserved(username): + return ProvisionResult( + user=None, + error="deleted_username_reserved", + error_flash="User account does not exist and self-registration is disabled.", + ) + + role_name = "user" + try: + user = User(username=username, role=role_name, email=email, full_name=full_name) + user.is_active = True + user.auth_provider = provider + user.external_issuer = issuer + user.external_subject = subject + if provider == "oidc": + user.oidc_issuer = issuer + user.oidc_sub = subject + try: + from app.models import Settings + + settings = Settings.get_settings() + user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0) + except Exception: + current_app.logger.debug("Could not apply default daily working hours for external user", exc_info=True) + + role_obj = Role.query.filter_by(name=role_name).first() + if role_obj: + user.roles.append(role_obj) + + db.session.add(user) + if not safe_commit(f"{provider}_create_user", {"username": username, "email": email}): + raise RuntimeError("db commit failed on user create") + + track_onboarding_started( + user.id, + { + "auth_method": provider, + "self_registered": True, + "is_admin": role_name == "admin", + "has_email": bool(email), + }, + ) + created = True + except Exception as e: + current_app.logger.exception("Failed to create user from %s claims: %s", provider, e) + return ProvisionResult( + user=None, + error="db_create_user_failed", + error_flash="Could not create your account due to a database error.", + ) + else: + changed = False + if getattr(user, "auth_provider", "local") != provider: + user.auth_provider = provider + changed = True + if not user.external_issuer or not user.external_subject: + user.external_issuer = issuer + user.external_subject = subject + changed = True + if provider == "oidc" and (not user.oidc_issuer or not user.oidc_sub): + user.oidc_issuer = issuer + user.oidc_sub = subject + changed = True + if email and user.email != email: + user.email = email + changed = True + if full_name and user.full_name != full_name: + user.full_name = full_name + changed = True + if changed: + if not safe_commit(f"{provider}_update_user", {"user_id": user.id}): + current_app.logger.warning("DB commit failed updating user from %s; continuing", provider) + + # Admin role mapping + try: + if _apply_admin_mapping( + user, + email=email, + groups=groups, + admin_emails=admin_emails, + admin_groups=admin_groups, + ): + if not safe_commit(f"{provider}_promote_admin", {"user_id": user.id}): + current_app.logger.warning("DB commit failed promoting user to admin from %s; continuing", provider) + except Exception: + current_app.logger.warning("%s admin role mapping failed", provider, exc_info=True) + + return ProvisionResult(user=user, created=created) diff --git a/app/services/labor_compliance_service.py b/app/services/labor_compliance_service.py new file mode 100644 index 000000000..610b4c510 --- /dev/null +++ b/app/services/labor_compliance_service.py @@ -0,0 +1,173 @@ +"""Labor-compliance export presets (Belgium attendance, EU WTD, US FLSA).""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from app.models import Settings, TimeEntry, User + +# Preset registry: id → metadata + default weekly hour threshold +PRESETS: Dict[str, Dict[str, Any]] = { + "belgium-attendance": { + "id": "belgium-attendance", + "name": "Belgium attendance (inspector)", + "description": "Daily attendance periods, breaks, rest, and overtime for Belgian labor inspection.", + "kind": "attendance", + "default_weekly_threshold": None, + "settings_key": None, + }, + "eu-wtd": { + "id": "eu-wtd", + "name": "EU Working Time Directive", + "description": "Weekly hours vs EU WTD threshold (default 48h); flags weeks over the limit.", + "kind": "weekly_hours", + "default_weekly_threshold": 48.0, + "settings_key": "weekly_hour_limit", + }, + "us-flsa": { + "id": "us-flsa", + "name": "US FLSA overtime", + "description": "Weekly hours with FLSA overtime (>40h/week by default).", + "kind": "weekly_hours", + "default_weekly_threshold": 40.0, + "settings_key": "compliance_standard_weekly_hours", + }, +} + + +class LaborComplianceService: + """Build labor-compliance export rows from named presets.""" + + def list_presets(self) -> List[Dict[str, Any]]: + return [dict(p) for p in PRESETS.values()] + + def get_preset(self, preset_id: str) -> Optional[Dict[str, Any]]: + return PRESETS.get((preset_id or "").strip().lower()) + + def resolve_weekly_threshold( + self, + preset_id: str, + *, + weekly_threshold: Optional[float] = None, + ) -> Optional[float]: + preset = self.get_preset(preset_id) + if not preset or preset.get("kind") != "weekly_hours": + return None + if weekly_threshold is not None: + try: + return float(weekly_threshold) + except (TypeError, ValueError): + pass + settings = Settings.get_settings() + key = preset.get("settings_key") + if key and hasattr(settings, key): + val = getattr(settings, key, None) + if val is not None: + try: + return float(val) + except (TypeError, ValueError): + pass + return float(preset.get("default_weekly_threshold") or 40.0) + + def export_rows( + self, + preset_id: str, + *, + start_date: date, + end_date: date, + user_id: Optional[int] = None, + weekly_threshold: Optional[float] = None, + overages_only: bool = False, + ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Return (rows, meta). Raises ValueError for unknown preset.""" + preset = self.get_preset(preset_id) + if not preset: + raise ValueError(f"Unknown labor-compliance preset: {preset_id}") + + meta: Dict[str, Any] = { + "preset_id": preset["id"], + "preset_name": preset["name"], + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + } + + if preset["kind"] == "attendance": + from app.services.attendance_compliance_service import AttendanceComplianceService + + rows = AttendanceComplianceService().belgium_inspector_rows( + start_date=start_date, end_date=end_date, user_id=user_id + ) + return rows, meta + + threshold = self.resolve_weekly_threshold(preset_id, weekly_threshold=weekly_threshold) + meta["weekly_threshold"] = threshold + rows = self._weekly_hours_rows( + start_date=start_date, + end_date=end_date, + user_id=user_id, + weekly_threshold=float(threshold or 40.0), + earning_label="OT" if preset_id == "us-flsa" else "OVER_LIMIT", + overages_only=overages_only, + ) + return rows, meta + + def _weekly_hours_rows( + self, + *, + start_date: date, + end_date: date, + user_id: Optional[int], + weekly_threshold: float, + earning_label: str, + overages_only: bool, + ) -> List[Dict[str, Any]]: + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()), + TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time()), + ) + if user_id is not None: + query = query.filter(TimeEntry.user_id == user_id) + + buckets: Dict[tuple, Dict[str, Any]] = {} + for entry in query.all(): + entry_date = entry.start_time.date() + iso_year, iso_week, _ = entry_date.isocalendar() + week_start = date.fromisocalendar(iso_year, iso_week, 1) + week_end = week_start + timedelta(days=6) + key = (entry.user_id, iso_year, iso_week) + if key not in buckets: + user = entry.user or User.query.get(entry.user_id) + buckets[key] = { + "user_id": entry.user_id, + "username": user.username if user else "", + "display_name": getattr(user, "display_name", None) or (user.username if user else ""), + "week_year": iso_year, + "week_number": iso_week, + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "total_hours": 0.0, + "weekly_threshold": weekly_threshold, + "overtime_hours": 0.0, + "over_limit": "no", + "earning_code": "REG", + } + hours = float(entry.duration_seconds or 0) / 3600.0 + buckets[key]["total_hours"] += hours + + rows: List[Dict[str, Any]] = [] + for item in buckets.values(): + total = round(item["total_hours"], 2) + overtime = round(max(0.0, total - weekly_threshold), 2) + item["total_hours"] = total + item["overtime_hours"] = overtime + item["regular_hours"] = round(min(total, weekly_threshold), 2) + item["over_limit"] = "yes" if overtime > 0 else "no" + item["earning_code"] = earning_label if overtime > 0 else "REG" + if overages_only and overtime <= 0: + continue + rows.append(item) + + rows.sort(key=lambda r: (r["week_year"], r["week_number"], r["user_id"] or 0)) + return rows diff --git a/app/services/peppol_inbound_service.py b/app/services/peppol_inbound_service.py new file mode 100644 index 000000000..ace9d9ed3 --- /dev/null +++ b/app/services/peppol_inbound_service.py @@ -0,0 +1,376 @@ +"""Poll Peppol Bridge inbox and store inbound UBL documents.""" + +from __future__ import annotations + +import hashlib +import logging +import xml.etree.ElementTree as ET +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlsplit, urlunsplit + +import requests + +from app import db +from app.models import Client, PeppolInboundDocument, Settings +from app.utils.db import safe_commit + +logger = logging.getLogger(__name__) + +_UBL_NS = { + "inv": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2", + "cn": "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2", + "cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + "cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", +} + + +def _bool_setting(settings: Settings, attr: str, default: bool = False) -> bool: + val = getattr(settings, attr, None) + if val is None: + return default + return bool(val) + + +def bridge_base_url_from_access_point(access_point_url: str) -> Optional[str]: + """Derive bridge base (… without /send) from PEPPOL_ACCESS_POINT_URL.""" + url = (access_point_url or "").strip() + if not url: + return None + parts = urlsplit(url) + if not parts.scheme or not parts.netloc: + return None + path = (parts.path or "").rstrip("/") + if path.endswith("/send"): + path = path[: -len("/send")] + return urlunsplit((parts.scheme, parts.netloc, path, "", "")).rstrip("/") + + +def _sha256(text: str) -> str: + return hashlib.sha256((text or "").encode("utf-8")).hexdigest() + + +def _parse_date(value: Optional[str]) -> Optional[date]: + if not value: + return None + text = value.strip() + for fmt in ("%Y-%m-%d", "%Y%m%d"): + try: + return datetime.strptime(text[:10] if fmt == "%Y-%m-%d" else text[:8], fmt).date() + except ValueError: + continue + return None + + +def _parse_amount(value: Optional[str]) -> Optional[Decimal]: + if value is None: + return None + try: + return Decimal(str(value).strip()) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _local_tag(tag: str) -> str: + if not tag: + return "" + return tag.split("}")[-1] + + +def parse_ubl_summary(ubl_xml: str) -> Dict[str, Any]: + """Best-effort extract of number/date/currency/total and supplier Peppol id.""" + out: Dict[str, Any] = { + "document_type": None, + "document_number": None, + "issue_date": None, + "currency": None, + "total_amount": None, + "supplier_scheme_id": None, + "supplier_endpoint_id": None, + "supplier_peppol_id": None, + } + if not ubl_xml or not ubl_xml.strip(): + return out + try: + root = ET.fromstring(ubl_xml) + except ET.ParseError: + return out + + local = _local_tag(root.tag).lower() + if "creditnote" in local: + out["document_type"] = "credit_note" + elif "invoice" in local: + out["document_type"] = "invoice" + else: + out["document_type"] = local or None + + def find_text(*paths: str) -> Optional[str]: + for path in paths: + el = root.find(path, _UBL_NS) + if el is not None and el.text and el.text.strip(): + return el.text.strip() + return None + + out["document_number"] = find_text("cbc:ID") + out["issue_date"] = _parse_date(find_text("cbc:IssueDate")) + out["currency"] = find_text("cbc:DocumentCurrencyCode") + out["total_amount"] = _parse_amount( + find_text( + "cac:LegalMonetaryTotal/cbc:PayableAmount", + "cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount", + ) + ) + + endpoint = root.find( + "cac:AccountingSupplierParty/cac:Party/cbc:EndpointID", + _UBL_NS, + ) + if endpoint is not None and endpoint.text and endpoint.text.strip(): + scheme = (endpoint.attrib.get("schemeID") or "").strip() or None + value = endpoint.text.strip() + out["supplier_scheme_id"] = scheme + out["supplier_endpoint_id"] = value + if scheme and not value.startswith(f"{scheme}:"): + out["supplier_peppol_id"] = f"{scheme}:{value}" + else: + out["supplier_peppol_id"] = value + return out + + +def match_client_by_peppol_id(peppol_id: Optional[str]) -> Optional[Client]: + """Match a Client whose custom_fields store the supplier Peppol endpoint.""" + if not peppol_id: + return None + raw = peppol_id.strip() + if not raw: + return None + scheme = None + endpoint = raw + if ":" in raw: + scheme, endpoint = raw.split(":", 1) + scheme = scheme.strip() or None + endpoint = endpoint.strip() + + # Prefer exact JSON contains when the DB dialect supports it; fall back to Python scan. + clients = Client.query.order_by(Client.id.asc()).limit(5000).all() + for client in clients: + fields = client.custom_fields or {} + if not isinstance(fields, dict): + continue + cf_endpoint = str(fields.get("peppol_endpoint_id") or "").strip() + cf_scheme = str(fields.get("peppol_scheme_id") or "").strip() or None + if not cf_endpoint: + continue + if cf_endpoint == raw or cf_endpoint == endpoint: + if not scheme or not cf_scheme or cf_scheme == scheme: + return client + combined = f"{cf_scheme}:{cf_endpoint}" if cf_scheme else cf_endpoint + if combined == raw: + return client + return None + + +class PeppolInboundService: + """Import inbound Peppol documents from the local peppol-bridge inbox API.""" + + def __init__(self, *, timeout_s: Optional[float] = None): + self.settings = Settings.get_settings() + if timeout_s is not None: + self.timeout_s = float(timeout_s) + else: + try: + self.timeout_s = float(getattr(self.settings, "peppol_access_point_timeout", None) or 30) + except (TypeError, ValueError): + self.timeout_s = 30.0 + + def inbox_enabled(self) -> bool: + from app.integrations.peppol import peppol_enabled + + if not peppol_enabled(): + return False + return _bool_setting(self.settings, "peppol_inbox_enabled", default=False) + + def _auth_headers(self) -> Dict[str, str]: + headers = {"Accept": "application/json"} + token = "" + try: + token = self.settings.get_secret("peppol_access_point_token") or "" + except Exception: + token = getattr(self.settings, "peppol_access_point_token", "") or "" + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + def _inbox_urls(self) -> Tuple[Optional[str], Optional[str]]: + base = bridge_base_url_from_access_point(getattr(self.settings, "peppol_access_point_url", "") or "") + if not base: + return None, None + return f"{base}/inbox", f"{base}/inbox/ack" + + def fetch_inbox(self, *, size: int = 100) -> List[Dict[str, Any]]: + inbox_url, _ = self._inbox_urls() + if not inbox_url: + raise RuntimeError("Peppol access point URL is not configured") + resp = requests.get( + inbox_url, + headers=self._auth_headers(), + params={"size": max(1, min(int(size or 100), 500))}, + timeout=self.timeout_s, + ) + if resp.status_code >= 400: + raise RuntimeError(f"Bridge inbox HTTP {resp.status_code}: {resp.text[:300]}") + data = resp.json() + if not isinstance(data, dict) or not data.get("ok"): + raise RuntimeError(f"Bridge inbox error: {data}") + docs = data.get("documents") or [] + if not isinstance(docs, list): + raise RuntimeError("Bridge inbox returned unexpected documents payload") + return [d for d in docs if isinstance(d, dict)] + + def ack_downloaded(self, external_ids: List[str]) -> None: + _, ack_url = self._inbox_urls() + if not ack_url: + raise RuntimeError("Peppol access point URL is not configured") + ids = [str(i).strip() for i in external_ids if str(i).strip()] + if not ids: + return + resp = requests.post( + ack_url, + headers={**self._auth_headers(), "Content-Type": "application/json"}, + json={"ids": ids}, + timeout=self.timeout_s, + ) + if resp.status_code >= 400: + raise RuntimeError(f"Bridge inbox ack HTTP {resp.status_code}: {resp.text[:300]}") + + def upsert_document( + self, payload: Dict[str, Any], *, provider: str = "letspeppol" + ) -> Tuple[PeppolInboundDocument, bool]: + """Create or update a local inbound row. Returns (row, created).""" + external_id = str(payload.get("id") or "").strip() + if not external_id: + raise ValueError("Inbound document missing id") + + ubl_xml = payload.get("ubl") + if ubl_xml is not None and not isinstance(ubl_xml, str): + ubl_xml = str(ubl_xml) + ubl_xml = ubl_xml or None + + summary = parse_ubl_summary(ubl_xml or "") + doc_type = payload.get("type") + if isinstance(doc_type, str) and doc_type.strip(): + mapped = doc_type.strip().lower() + if mapped == "credit_note" or mapped == "creditnote": + summary["document_type"] = "credit_note" + elif mapped == "invoice": + summary["document_type"] = "invoice" + else: + summary["document_type"] = mapped + + partner = (payload.get("partnerPeppolId") or summary.get("supplier_peppol_id") or "").strip() or None + owner = (payload.get("ownerPeppolId") or "").strip() or None + matched = match_client_by_peppol_id(partner) + + existing = PeppolInboundDocument.query.filter_by(external_id=external_id).first() + created = existing is None + row = existing or PeppolInboundDocument(external_id=external_id, provider=provider or "letspeppol") + row.provider = provider or row.provider or "letspeppol" + row.direction = "inbound" + row.partner_peppol_id = partner + row.owner_peppol_id = owner + row.document_type = summary.get("document_type") + row.document_number = summary.get("document_number") + row.issue_date = summary.get("issue_date") + row.currency = summary.get("currency") + row.total_amount = summary.get("total_amount") + if ubl_xml: + row.ubl_xml = ubl_xml + row.ubl_sha256 = _sha256(ubl_xml) + row.processed_status = ( + (payload.get("processedStatus") or None) if payload.get("processedStatus") else row.processed_status + ) + # Keep a trimmed copy of transport metadata (not the full UBL again). + row.raw_payload = { + k: payload.get(k) + for k in ( + "id", + "direction", + "type", + "ownerPeppolId", + "partnerPeppolId", + "createdOn", + "scheduledOn", + "processedOn", + "processedStatus", + ) + if k in payload + } + if matched is not None: + row.matched_client_id = matched.id + if created: + row.status = "new" + db.session.add(row) + return row, created + + def poll_and_import(self, *, size: int = 100) -> Dict[str, Any]: + """Fetch inbox, upsert locally, ack successfully stored ids.""" + if not self.inbox_enabled(): + return {"ok": True, "skipped": True, "reason": "inbox_disabled"} + + try: + documents = self.fetch_inbox(size=size) + except Exception as e: + logger.warning("Peppol inbox fetch failed: %s", e) + return {"ok": False, "error": str(e)} + + created_count = 0 + updated_count = 0 + ack_ids: List[str] = [] + errors: List[str] = [] + + provider_label = (getattr(self.settings, "peppol_provider", None) or "letspeppol").strip() or "letspeppol" + + for payload in documents: + external_id = str(payload.get("id") or "").strip() + try: + row, created = self.upsert_document(payload, provider=provider_label) + if created: + created_count += 1 + else: + updated_count += 1 + if external_id: + ack_ids.append(external_id) + # Persist per document so a later failure does not lose earlier imports. + if not safe_commit("peppol_inbound_upsert"): + raise RuntimeError("Database commit failed while importing inbound Peppol document") + if row.status == "error": + row.status = "new" + row.error_message = None + except Exception as e: + logger.exception("Failed to import inbound Peppol document %s", external_id or "?") + errors.append(f"{external_id or '?'}: {e}") + db.session.rollback() + + acked = 0 + if ack_ids: + try: + self.ack_downloaded(ack_ids) + acked = len(ack_ids) + for ext_id in ack_ids: + row = PeppolInboundDocument.query.filter_by(external_id=ext_id).first() + if row and row.status != "error": + row.mark_acknowledged() + safe_commit("peppol_inbound_ack") + except Exception as e: + logger.warning("Peppol inbox ack failed after import: %s", e) + errors.append(f"ack: {e}") + + return { + "ok": len(errors) == 0, + "fetched": len(documents), + "created": created_count, + "updated": updated_count, + "acked": acked, + "errors": errors, + } diff --git a/app/services/task_service.py b/app/services/task_service.py index a88339d61..ba934a7f2 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -230,12 +230,31 @@ def update_task(self, task_id: int, user_id: int, **kwargs) -> Dict[str, Any]: if not task: return {"success": False, "message": "Task not found", "error": "not_found"} + new_status = kwargs.get("status") + if new_status is not None and new_status != task.status: + from app.models import KanbanColumn + + wip_error = KanbanColumn.wip_violation_message(task.project_id, new_status, task_id=task.id) + if wip_error: + return { + "success": False, + "message": wip_error, + "error": "wip_limit", + "code": "WIP_LIMIT", + } + # Extract assignee fields before generic update (not model columns) assignee_ids = kwargs.pop("assignee_ids", None) assignee_id = kwargs.pop("assignee_id", None) assigned_to = kwargs.pop("assigned_to", None) update_assignees = assignee_ids is not None or assignee_id is not None or assigned_to is not None + custom_fields = kwargs.pop("custom_fields", None) + if custom_fields is not None: + from app.services.custom_field_service import CustomFieldService + + CustomFieldService().apply_from_dict(task, "task", custom_fields, merge=True) + # Update scalar fields self.task_repo.update(task, **kwargs) diff --git a/app/services/team_chat_service.py b/app/services/team_chat_service.py new file mode 100644 index 000000000..780292cd9 --- /dev/null +++ b/app/services/team_chat_service.py @@ -0,0 +1,133 @@ +"""Minimal team chat service extracted from web route ORM usage.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from app import db +from app.models.team_chat import ChatChannel, ChatChannelMember, ChatMessage, ChatReadReceipt + + +class TeamChatService: + """Channel and message helpers for API and web callers.""" + + def list_channels_for_user(self, user_id: int, *, include_archived: bool = False) -> List[ChatChannel]: + query = ChatChannel.query.join(ChatChannelMember).filter(ChatChannelMember.user_id == user_id) + if not include_archived: + query = query.filter(ChatChannel.is_archived == False) # noqa: E712 + return query.order_by(ChatChannel.updated_at.desc()).all() + + def get_channel(self, channel_id: int) -> Optional[ChatChannel]: + return ChatChannel.query.get(channel_id) + + def user_can_access_channel(self, channel_id: int, user_id: int, *, is_admin: bool = False) -> bool: + if is_admin: + return True + return ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=user_id).first() is not None + + def list_messages( + self, + channel_id: int, + *, + before_id: Optional[int] = None, + limit: int = 50, + ) -> List[ChatMessage]: + query = ChatMessage.query.filter_by(channel_id=channel_id, is_deleted=False) + if before_id: + query = query.filter(ChatMessage.id < before_id) + messages = query.order_by(ChatMessage.created_at.desc()).limit(max(1, min(limit, 200))).all() + messages.reverse() + return messages + + def mark_messages_read(self, messages: List[ChatMessage], user_id: int) -> None: + message_ids = [m.id for m in messages] + if not message_ids: + return + existing = ChatReadReceipt.query.filter( + ChatReadReceipt.message_id.in_(message_ids), + ChatReadReceipt.user_id == user_id, + ).all() + have = {r.message_id for r in existing} + for message in messages: + if message.id not in have: + db.session.add(ChatReadReceipt(message_id=message.id, user_id=user_id)) + db.session.commit() + + def post_message( + self, + channel_id: int, + user_id: int, + *, + message: str, + message_type: str = "text", + reply_to_id: Optional[int] = None, + attachment_url: Optional[str] = None, + attachment_filename: Optional[str] = None, + attachment_size: Optional[int] = None, + ) -> ChatMessage: + channel = ChatChannel.query.get_or_404(channel_id) + chat_message = ChatMessage( + channel_id=channel_id, + user_id=user_id, + message=message or "", + message_type=message_type or "text", + reply_to_id=reply_to_id, + attachment_url=attachment_url, + attachment_filename=attachment_filename, + attachment_size=attachment_size, + ) + mentions = chat_message.parse_mentions() + if mentions: + chat_message.mentions = mentions + db.session.add(chat_message) + channel.updated_at = datetime.utcnow() + db.session.commit() + return chat_message + + def create_channel( + self, + *, + name: str, + created_by: int, + description: Optional[str] = None, + channel_type: str = "public", + project_id: Optional[int] = None, + member_ids: Optional[List[int]] = None, + ) -> ChatChannel: + channel = ChatChannel( + name=name, + description=description, + channel_type=channel_type or "public", + created_by=created_by, + project_id=project_id, + ) + db.session.add(channel) + db.session.flush() + db.session.add(ChatChannelMember(channel_id=channel.id, user_id=created_by, is_admin=True)) + for user_id in member_ids or []: + if user_id != created_by: + db.session.add(ChatChannelMember(channel_id=channel.id, user_id=user_id)) + db.session.commit() + return channel + + def notify_mentions(self, channel: ChatChannel, sender_display_name: str, mentions: List[int]) -> None: + if not mentions: + return + from app.utils.notification_service import NotificationService + + service = NotificationService() + for user_id in mentions: + service.send_notification( + user_id=user_id, + title="You were mentioned", + message=f"{sender_display_name} mentioned you in {channel.name}", + type="info", + priority="high", + ) + + def serialize_channel(self, channel: ChatChannel) -> Dict[str, Any]: + return channel.to_dict() + + def serialize_message(self, message: ChatMessage) -> Dict[str, Any]: + return message.to_dict() diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index 12cb1cf7d..c4f2eec90 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -436,6 +436,7 @@ def create_manual_entry( billable: bool = True, paid: bool = False, invoice_number: Optional[str] = None, + custom_fields: Optional[dict] = None, skip_entry_requirements: bool = False, ) -> Dict[str, Any]: """ @@ -578,6 +579,7 @@ def create_manual_entry( billable=billable, paid=paid, invoice_number=invoice_number, + custom_fields=custom_fields, ) commit_data = {"user_id": user_id} @@ -659,6 +661,7 @@ def update_entry( invoice_number: Optional[str] = None, reason: Optional[str] = None, expected_updated_at: Optional[datetime] = None, + custom_fields: Optional[dict] = None, ) -> Dict[str, Any]: """ Update a time entry. @@ -783,6 +786,10 @@ def update_entry( entry.invoice_number = None if invoice_number is not None: entry.invoice_number = invoice_number.strip() if invoice_number else None + if custom_fields is not None: + from app.services.custom_field_service import CustomFieldService + + CustomFieldService().apply_from_dict(entry, "time_entry", custom_fields, merge=True) # Validate time entry requirements on updated state (entry reflects changes applied above) settings = Settings.get_settings() diff --git a/app/templates/admin/oauth_apps.html b/app/templates/admin/oauth_apps.html new file mode 100644 index 000000000..3d57156a3 --- /dev/null +++ b/app/templates/admin/oauth_apps.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block title %}{{ _('OAuth Apps') }} - {{ _('Admin') }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'OAuth Apps'} +] %} + +{{ page_header( + icon_class='fas fa-key', + title_text=_('OAuth Applications'), + subtitle_text=_('Register third-party apps for API access via OAuth 2.0'), + breadcrumbs=breadcrumbs +) }} + +{% if error %} +
+ {{ error }} +
+{% endif %} + +{% if new_secret and new_app %} +
+

{{ _('Client secret (shown once)') }}

+

{{ _('Application') }}: {{ new_app.name }} ({{ new_app.client_id }})

+ {{ new_secret }} +

{{ _('Copy this secret now — it will not be shown again.') }}

+
+{% endif %} + +
+

{{ _('Create application') }}

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + {% for app in apps %} + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ _('Name') }}{{ _('Client ID') }}{{ _('Scopes') }}{{ _('Active') }}{{ _('Actions') }}
{{ app.name }}{{ app.client_id }}{{ app.allowed_scopes }}{{ _('Yes') if app.is_active else _('No') }} +
+ + +
+
+ + +
+
+ {{ _('No OAuth applications yet.') }} +
+
+{% endblock %} diff --git a/app/templates/admin/pdf_layout.html b/app/templates/admin/pdf_layout.html index f46bf297d..8d76a006a 100644 --- a/app/templates/admin/pdf_layout.html +++ b/app/templates/admin/pdf_layout.html @@ -5874,12 +5874,20 @@

{{ _('Generated Code') }}

reportLabFont += '-Oblique'; } + const elementName = (attrs.name || '').toLowerCase(); + let reportType = 'text'; + if (elementName === 'qr-code') { + reportType = 'qr_code'; + } else if (elementName === 'barcode') { + reportType = 'barcode'; + } const textEl = { - type: 'text', + type: reportType, x: pxToPt(x), y: pxToPt(y), text: text, width: pxToPt(attrs.width || 400), + height: pxToPt(attrs.height || (reportType === 'qr_code' ? 100 : 50)), style: { font: reportLabFont, size: fontSize, diff --git a/app/templates/admin/peppol_setup_wizard.html b/app/templates/admin/peppol_setup_wizard.html index c250ba21e..df1ead620 100644 --- a/app/templates/admin/peppol_setup_wizard.html +++ b/app/templates/admin/peppol_setup_wizard.html @@ -30,6 +30,7 @@

1) Deploy the Peppol Bridge

+

@@ -157,6 +158,14 @@

3) Send a test invoice

' - GETPEPPR_BASE_URL=${GETPEPPR_BASE_URL:-https://api.getpeppr.dev/v1}', ' - GETPEPPR_API_KEY=${GETPEPPR_API_KEY:?Set GETPEPPR_API_KEY}', ].join('\\n'); + } else if (preset === 'letspeppol') { + providerEnv = [ + ' - PEPPOL_BRIDGE_PROVIDER=letspeppol', + ' - LETSPEPPOL_BASE_URL=${LETSPEPPOL_BASE_URL:-https://be.letspeppol.org}', + ' - LETSPEPPOL_CLIENT_ID=${LETSPEPPOL_CLIENT_ID:?Set LETSPEPPOL_CLIENT_ID}', + ' - LETSPEPPOL_CLIENT_SECRET=${LETSPEPPOL_CLIENT_SECRET:?Set LETSPEPPOL_CLIENT_SECRET}', + ' - LETSPEPPOL_PEPPOL_ID=${LETSPEPPOL_PEPPOL_ID:?Set LETSPEPPOL_PEPPOL_ID}', + ].join('\\n'); } else { providerEnv = [ ' - PEPPOL_BRIDGE_PROVIDER=generic_custom', @@ -198,9 +207,21 @@

3) Send a test invoice

document.addEventListener('DOMContentLoaded', function() { const presetEl = document.getElementById('wizardProviderPreset'); const tokenEl = document.getElementById('wizardBridgeAuthToken'); - if (presetEl) presetEl.addEventListener('change', buildComposeSnippet); + const labelEl = document.getElementById('wizardProviderLabel'); + function onPresetChange() { + buildComposeSnippet(); + if (labelEl && presetEl) { + const preset = presetEl.value || 'einvoice'; + if (preset === 'letspeppol') labelEl.value = 'letspeppol'; + else if (preset === 'getpeppr') labelEl.value = 'getpeppr'; + else if (preset === 'peppyrus') labelEl.value = 'peppyrus'; + else if (preset === 'einvoice') labelEl.value = 'e-invoice.be'; + else if (preset === 'generic_custom') labelEl.value = 'generic'; + } + } + if (presetEl) presetEl.addEventListener('change', onPresetChange); if (tokenEl) tokenEl.addEventListener('input', buildComposeSnippet); - buildComposeSnippet(); + onPresetChange(); const checkBtn = document.getElementById('wizardCheckBridgeBtn'); const applyBtn = document.getElementById('wizardApplyBtn'); diff --git a/app/templates/admin/quote_pdf_layout.html b/app/templates/admin/quote_pdf_layout.html index d5dbe12c9..55c843934 100644 --- a/app/templates/admin/quote_pdf_layout.html +++ b/app/templates/admin/quote_pdf_layout.html @@ -5298,12 +5298,20 @@

{{ _('Generated Code') }}

reportLabFont += '-Oblique'; } + const elementName = (attrs.name || '').toLowerCase(); + let reportType = 'text'; + if (elementName === 'qr-code') { + reportType = 'qr_code'; + } else if (elementName === 'barcode') { + reportType = 'barcode'; + } const textEl = { - type: 'text', + type: reportType, x: pxToPt(x), y: pxToPt(y), text: text, width: pxToPt(attrs.width || 400), + height: pxToPt(attrs.height || (reportType === 'qr_code' ? 100 : 50)), style: { font: reportLabFont, size: fontSize, diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 8458298b7..36f4e7ca6 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -779,6 +779,9 @@

{{ _('AI Helper') }}

+

+ {{ _('Get an OrcaRouter API key') }} +

@@ -1177,6 +1180,7 @@

{{ _('Up const aiModelInput = document.getElementById('ai_model'); const aiModelList = document.getElementById('ai_model_suggestions'); const aiRoutingWrap = document.getElementById('ai_routing_strategy_wrap'); + const aiOrcaSignupWrap = document.getElementById('ai_orcarouter_signup_wrap'); const aiPresets = { ollama: { base_url: 'http://127.0.0.1:11434', models: ['llama3.1', 'llama3.2', 'mistral', 'qwen2.5'], default_model: 'llama3.1' }, openai: { base_url: 'https://api.openai.com', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'], default_model: 'gpt-4o-mini' }, @@ -1191,6 +1195,9 @@

{{ _('Up if (aiRoutingWrap) { aiRoutingWrap.classList.toggle('hidden', aiProviderSelect.value !== 'orcarouter'); } + if (aiOrcaSignupWrap) { + aiOrcaSignupWrap.classList.toggle('hidden', aiProviderSelect.value !== 'orcarouter'); + } if (aiModelList) { aiModelList.innerHTML = ''; (preset.models || []).forEach(function(m) { diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index c13726fb0..3ab58caee 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -69,7 +69,7 @@

{{ _('Sign in to your account') }}

{{ _('Tip: Enter a new username to create your account.') }}

{% endif %} - {% if not demo_mode and auth_includes_oidc %} + {% if not demo_mode and (auth_includes_oidc or auth_includes_saml) %}
+ {% if auth_includes_oidc %} {{ _('Single Sign-On') }} {% endif %} + {% if auth_includes_saml %} + + + {{ _('Sign in with SSO (SAML)') }} + + {% endif %} + {% endif %}

diff --git a/app/templates/clients/create.html b/app/templates/clients/create.html index c8dd6e295..7d5e30ea6 100644 --- a/app/templates/clients/create.html +++ b/app/templates/clients/create.html @@ -93,6 +93,10 @@ +
+ + +
{% if custom_field_definitions %} diff --git a/app/templates/clients/edit.html b/app/templates/clients/edit.html index a1d055a90..b3b9be91b 100644 --- a/app/templates/clients/edit.html +++ b/app/templates/clients/edit.html @@ -87,6 +87,10 @@ +
+ + +
{% if custom_field_definitions %} diff --git a/app/templates/inventory/adjustments/form.html b/app/templates/inventory/adjustments/form.html index 058d721d9..89c201f83 100644 --- a/app/templates/inventory/adjustments/form.html +++ b/app/templates/inventory/adjustments/form.html @@ -42,6 +42,11 @@

{{ _('Use positive values to increase stock, negative values to decrease') }}

+
+ + +

{{ _('Optional. Updates the warehouse stock location for this item.') }}

+
diff --git a/app/templates/inventory/purchase_orders/view.html b/app/templates/inventory/purchase_orders/view.html index d4cbf12ad..57066a161 100644 --- a/app/templates/inventory/purchase_orders/view.html +++ b/app/templates/inventory/purchase_orders/view.html @@ -15,6 +15,7 @@ breadcrumbs=breadcrumbs, actions_html=('Back' + 'PDF' + + ('
' if (current_user.is_admin or has_permission('manage_purchase_orders')) else '') + ('Edit' if (purchase_order.status != 'received' and purchase_order.status != 'cancelled' and (current_user.is_admin or has_permission('manage_purchase_orders'))) else '') + ('
' if (purchase_order.status == 'draft' and (current_user.is_admin or has_permission('manage_purchase_orders'))) else '') + ('
' if (purchase_order.status not in ['received', 'cancelled'] and (current_user.is_admin or has_permission('manage_purchase_orders'))) else '') + diff --git a/app/templates/inventory/stock_levels/list.html b/app/templates/inventory/stock_levels/list.html index 5c95bbea6..1e31a7cd5 100644 --- a/app/templates/inventory/stock_levels/list.html +++ b/app/templates/inventory/stock_levels/list.html @@ -11,7 +11,8 @@ icon_class='fas fa-list-ul', title_text='Stock Levels', subtitle_text='View inventory levels across warehouses', - breadcrumbs=breadcrumbs + breadcrumbs=breadcrumbs, + actions_html=('' + _('Export CSV') + '') ) }}
diff --git a/app/templates/inventory/transfers/form.html b/app/templates/inventory/transfers/form.html index 0a209bda9..a092d4c38 100644 --- a/app/templates/inventory/transfers/form.html +++ b/app/templates/inventory/transfers/form.html @@ -50,6 +50,11 @@
+
+ + +

{{ _('Optional. Sets the location on the destination warehouse stock record.') }}

+
diff --git a/app/templates/invoices/peppol_inbox.html b/app/templates/invoices/peppol_inbox.html new file mode 100644 index 000000000..350d8e8e6 --- /dev/null +++ b/app/templates/invoices/peppol_inbox.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Invoices'), 'url': url_for('invoices.list_invoices')}, + {'text': _('Peppol Inbox')} +] %} + +{{ page_header( + icon_class='fas fa-inbox', + title_text=_('Peppol Inbox'), + subtitle_text=_('Inbound e-invoices received via Peppol'), + breadcrumbs=breadcrumbs, + actions_html='' + + '
' + + '' + _('Invoices') + '' + + ('
' + + '' + + '' + + '
' if current_user.is_admin or has_permission('manage_invoices') else '') + + '
' +) }} + +{% if not inbox_enabled %} +
+ {{ _('Peppol inbox polling is disabled. Configure Let’s Peppol in the Peppol setup wizard to enable inbound receive.') }} +
+{% endif %} + +
+
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + {% if documents %} + {% for doc in documents %} + + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
{{ _('Received') }}{{ _('Number') }}{{ _('Type') }}{{ _('Supplier Peppol ID') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Matched client') }}
{{ doc.created_at.strftime('%Y-%m-%d %H:%M') if doc.created_at else '—' }}{{ doc.document_number or '—' }}{{ doc.document_type or '—' }}{{ doc.partner_peppol_id or '—' }} + {% if doc.total_amount is not none %} + {{ doc.total_amount }}{% if doc.currency %} {{ doc.currency }}{% endif %} + {% else %} + — + {% endif %} + + + {{ doc.status }} + + + {% if doc.matched_client %} + {{ doc.matched_client.name }} + {% else %} + — + {% endif %} + + {{ _('View') }} +
+ {{ _('No inbound Peppol documents yet.') }} +
+
+{% endblock %} diff --git a/app/templates/invoices/peppol_inbox_detail.html b/app/templates/invoices/peppol_inbox_detail.html new file mode 100644 index 000000000..9e7747eff --- /dev/null +++ b/app/templates/invoices/peppol_inbox_detail.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Invoices'), 'url': url_for('invoices.list_invoices')}, + {'text': _('Peppol Inbox'), 'url': url_for('invoices.peppol_inbox')}, + {'text': document.document_number or document.external_id} +] %} + +{{ page_header( + icon_class='fas fa-file-invoice', + title_text=document.document_number or document.external_id, + subtitle_text=_('Inbound Peppol document'), + breadcrumbs=breadcrumbs, + actions_html='' + + '
' + + '' + _('Back') + '' + + ('' + _('Download UBL') + '' if document.ubl_xml else '') + + '
' +) }} + +
+
+

{{ _('Document') }}

+
+
{{ _('External ID') }}
{{ document.external_id }}
+
{{ _('Type') }}
{{ document.document_type or '—' }}
+
{{ _('Number') }}
{{ document.document_number or '—' }}
+
{{ _('Issue date') }}
{{ document.issue_date or '—' }}
+
{{ _('Amount') }}
+
{% if document.total_amount is not none %}{{ document.total_amount }}{% if document.currency %} {{ document.currency }}{% endif %}{% else %}—{% endif %}
+
+
{{ _('Status') }}
{{ document.status }}
+
{{ _('Provider') }}
{{ document.provider }}
+
{{ _('Processed status') }}
{{ document.processed_status or '—' }}
+
+
+ +
+

{{ _('Parties') }}

+
+
{{ _('Supplier Peppol ID') }}
{{ document.partner_peppol_id or '—' }}
+
{{ _('Owner Peppol ID') }}
{{ document.owner_peppol_id or '—' }}
+
{{ _('Matched client') }}
+
+ {% if document.matched_client %} + {{ document.matched_client.name }} + {% else %} + {{ _('No match') }} + {% endif %} +
+
+ {% if document.error_message %} +
{{ _('Error') }}
{{ document.error_message }}
+ {% endif %} +
{{ _('Received') }}
{{ document.created_at }}
+
{{ _('Acknowledged') }}
{{ document.acknowledged_at or '—' }}
+
+
+
+ +{% if document.ubl_xml %} +
+

{{ _('UBL preview') }}

+
{{ document.ubl_xml[:8000] }}{% if document.ubl_xml|length > 8000 %}
+…
+{% endif %}
+
+{% endif %} +{% endblock %} diff --git a/app/templates/invoices/view.html b/app/templates/invoices/view.html index 7c666008c..82bea99a3 100644 --- a/app/templates/invoices/view.html +++ b/app/templates/invoices/view.html @@ -32,6 +32,9 @@ {{ _('Download UBL') }} {% endif %} + + {{ _('Download XRechnung') }} + {% if invoice.status != 'paid' and invoice.status != 'cancelled' %} {{ _('Pay Online') }} diff --git a/app/templates/oauth/authorize.html b/app/templates/oauth/authorize.html new file mode 100644 index 000000000..1f345e398 --- /dev/null +++ b/app/templates/oauth/authorize.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Authorize application') }}{% endblock %} + +{% block content %} +
+
+ {% if error %} +

{{ _('Authorization error') }}

+

{{ error }}

+ {% else %} +

{{ _('Authorize') }} {{ application.name }}

+ {% if application.description %} +

{{ application.description }}

+ {% endif %} +

+ {{ _('This application is requesting access to your TimeTracker account with the following scopes:') }} +

+
    + {% for s in scope_list %} +
  • {{ s }}
  • + {% else %} +
  • {{ _('(no scopes)') }}
  • + {% endfor %} +
+
+ + + + + + + + + +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/partials/_custom_fields.html b/app/templates/partials/_custom_fields.html new file mode 100644 index 000000000..09f64451c --- /dev/null +++ b/app/templates/partials/_custom_fields.html @@ -0,0 +1,32 @@ +{% if custom_field_definitions %} +
+

{{ _('Custom Fields') }}

+ {% if section_help %} +

{{ section_help }}

+ {% endif %} +
+ {% for definition in custom_field_definitions %} + {% set cf = entity.custom_fields if entity and entity.custom_fields else {} %} + {% set stored = cf.get(definition.field_key, '') if cf else '' %} +
+ + + {% if definition.description %} +

{{ definition.description }}

+ {% endif %} +
+ {% endfor %} +
+
+{% endif %} diff --git a/app/templates/partials/_sidebar.html b/app/templates/partials/_sidebar.html index 1baff584e..0b3548740 100644 --- a/app/templates/partials/_sidebar.html +++ b/app/templates/partials/_sidebar.html @@ -305,10 +305,15 @@

- + {{ _('Invoices') }} +
  • + + {{ _('Peppol Inbox') }} + +
  • {% endif %} {% if is_module_enabled('invoice_approvals') %}
  • diff --git a/app/templates/payments/reconciliation_queue.html b/app/templates/payments/reconciliation_queue.html new file mode 100644 index 000000000..41b47c008 --- /dev/null +++ b/app/templates/payments/reconciliation_queue.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Payments'), 'url': url_for('payments.list_payments')}, + {'text': _('Bank reconciliation')} +] %} + +{{ page_header( + icon_class='fas fa-balance-scale', + title_text=_('Bank reconciliation'), + subtitle_text=_('Match imported bank lines to open invoices'), + breadcrumbs=breadcrumbs, + actions_html='' + _('Upload statement') + '' +) }} + + + +
    + + + + + + + + + + + + + {% for txn in transactions %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
    {{ _('Date') }}{{ _('Amount') }}{{ _('Counterparty') }}{{ _('Reference') }}{{ _('Suggestions') }}{{ _('Actions') }}
    {{ txn.booking_date }}{{ txn.currency }} {{ '%.2f'|format(txn.amount) }}{{ txn.counterparty or '—' }}{{ txn.reference or '—' }} + {% if txn.match_status == 'pending' %} + {% set sug = suggestions.get(txn.id, []) %} + {% if sug %} +
      + {% for s in sug %} +
    • {{ s.invoice_number }} — {{ s.currency }} {{ '%.2f'|format(s.outstanding_amount) }} (score {{ s.score }})
    • + {% endfor %} +
    + {% else %} + {{ _('No matches') }} + {% endif %} + {% elif txn.matched_invoice_id %} + {{ _('Invoice') }} #{{ txn.matched_invoice_id }} + {% else %} + — + {% endif %} +
    + {% if txn.match_status == 'pending' %} +
    + + + +
    +
    + + +
    + {% else %} + {{ txn.match_status }} + {% endif %} +
    {{ _('No transactions in this queue.') }}
    +
    +{% endblock %} diff --git a/app/templates/payments/reconciliation_upload.html b/app/templates/payments/reconciliation_upload.html new file mode 100644 index 000000000..420cb15bd --- /dev/null +++ b/app/templates/payments/reconciliation_upload.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Payments'), 'url': url_for('payments.list_payments')}, + {'text': _('Bank reconciliation'), 'url': url_for('bank_reconciliation.reconciliation_queue')}, + {'text': _('Upload')} +] %} + +{{ page_header( + icon_class='fas fa-file-import', + title_text=_('Upload bank statement'), + subtitle_text=_('Import CAMT.053 XML or CSV (date, amount, reference, counterparty)'), + breadcrumbs=breadcrumbs +) }} + +
    +
    + +
    + + +
    +
    + + +

    {{ _('CSV columns: date, amount, reference, counterparty (flexible headers).') }}

    +
    +
    + + {{ _('Cancel') }} +
    +
    +
    +{% endblock %} diff --git a/app/templates/tasks/_kanban.html b/app/templates/tasks/_kanban.html index ff6e2ee57..d813bdd1d 100644 --- a/app/templates/tasks/_kanban.html +++ b/app/templates/tasks/_kanban.html @@ -964,10 +964,22 @@
    ${escapeHtml(col.label || '')}
    headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ status: targetStatus }) }); + const data = await resp.json().catch(() => ({})); + if (resp.status === 409 && data.code === 'WIP_LIMIT') { + if (originalParent) originalParent.appendChild(card); + card.dataset.status = originalStatus; + updateCounts(); + const msg = data.error || '{{ _('Work-in-progress limit reached for this column') }}'; + if (window.ttAlert) { + window.ttAlert(msg); + } else { + alert(msg); + } + return; + } if (!resp.ok) { - throw new Error('{{ _('Failed to update status') }}'); + throw new Error(data.error || '{{ _('Failed to update status') }}'); } - const data = await resp.json(); if (!data.success) { throw new Error(data.error || 'Update rejected'); } @@ -976,7 +988,13 @@
    ${escapeHtml(col.label || '')}
    if (originalParent) originalParent.appendChild(card); card.dataset.status = originalStatus; updateCounts(); - alert('{{ _('Failed to update task status') }}'); + const fallback = '{{ _('Failed to update task status') }}'; + const msg = err && err.message ? err.message : fallback; + if (window.ttAlert) { + window.ttAlert(msg); + } else { + alert(msg); + } } }); }); diff --git a/app/templates/tasks/create.html b/app/templates/tasks/create.html index decbd2071..cc0f85bfc 100644 --- a/app/templates/tasks/create.html +++ b/app/templates/tasks/create.html @@ -136,6 +136,10 @@

    {{ _('Optional: Color for this task on the Gantt chart. If unset, the project color is used. Pick or enter a hex code (e.g. #3b82f6).') }}

  • + {% with section_help=_('These custom fields are defined globally and available for all tasks.') %} + {% include 'partials/_custom_fields.html' %} + {% endwith %} +
    {{ _('Cancel') }} diff --git a/app/templates/tasks/edit.html b/app/templates/tasks/edit.html index 8b8cc8dcf..617be5219 100644 --- a/app/templates/tasks/edit.html +++ b/app/templates/tasks/edit.html @@ -150,6 +150,10 @@
    {{ _('Optional: Color for this task on the Gantt chart. If unset, the project color is used. Pick or enter a hex code (e.g. #3b82f6).') }}

    + {% with entity=task, section_help=_('These custom fields are defined globally and available for all tasks.') %} + {% include 'partials/_custom_fields.html' %} + {% endwith %} +
    {{ _('Cancel') }} diff --git a/app/templates/tasks/view.html b/app/templates/tasks/view.html index 81c486ea1..88cb505f3 100644 --- a/app/templates/tasks/view.html +++ b/app/templates/tasks/view.html @@ -247,6 +247,20 @@

    {% endif %} + {% if task.custom_fields %} +
    +

    {{ _('Custom Fields') }}

    +
    + {% for key, value in task.custom_fields.items() %} + {% set field_definition = custom_field_definitions_by_key.get(key) if custom_field_definitions_by_key else none %} +
    + {% if field_definition %}{{ field_definition.label }}{% else %}{{ key }}{% endif %} +

    {{ value }}

    +
    + {% endfor %} +
    +
    + {% endif %} {% if task.due_date %}

    Due Date

    diff --git a/app/templates/timer/edit_timer.html b/app/templates/timer/edit_timer.html index 5d58bf1cb..69baa994a 100644 --- a/app/templates/timer/edit_timer.html +++ b/app/templates/timer/edit_timer.html @@ -555,6 +555,10 @@

    {{ _('Explain why you are making these changes') }}

    + {% with entity=timer, section_help=_('These custom fields are defined globally and available for time entries.') %} + {% include 'partials/_custom_fields.html' %} + {% endwith %} +
    @@ -682,6 +686,10 @@

    {{ _('Explain why you are making these changes') }}

    + {% with entity=timer, section_help=_('These custom fields are defined globally and available for time entries.') %} + {% include 'partials/_custom_fields.html' %} + {% endwith %} +
    diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index d1bb40ee2..68e21b1d1 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -222,6 +222,10 @@

    + {% with section_help=_('These custom fields are defined globally and available for time entries.') %} + {% include 'partials/_custom_fields.html' %} + {% endwith %} +
    diff --git a/app/utils/api_auth.py b/app/utils/api_auth.py index 2351074bb..4e97f648e 100644 --- a/app/utils/api_auth.py +++ b/app/utils/api_auth.py @@ -6,7 +6,7 @@ from flask import current_app, g, jsonify, request from app import db -from app.models import ApiToken, User +from app.models import ApiToken, OAuthAccessToken, User def extract_token_from_request(): @@ -37,17 +37,49 @@ def extract_token_from_request(): return None +def _authenticate_oauth_access_token(token_string, record_usage: bool = True): + """Resolve a ``tto_`` OAuth access token to (User, OAuthAccessToken, error).""" + hashes = OAuthAccessToken.lookup_hashes(token_string) + oauth_token = OAuthAccessToken.query.filter(OAuthAccessToken.token_hash.in_(hashes)).first() + if not oauth_token: + return None, None, "Token not found" + if oauth_token.revoked_at is not None: + return None, None, "Token has been revoked" + if oauth_token.expires_at and oauth_token.expires_at < datetime.utcnow(): + return None, None, "Token has expired" + + user = User.query.get(oauth_token.user_id) + if not user or not user.is_active: + return None, None, "User account is inactive" + + if record_usage: + try: + oauth_token.record_usage(request.remote_addr) + except Exception as e: + current_app.logger.warning(f"Failed to record OAuth token usage: {e}") + + return user, oauth_token, None + + def authenticate_token(token_string, record_usage: bool = True): - """Authenticate an API token and return the associated user + """Authenticate an API or OAuth access token and return the associated user Args: token_string: The plain token string record_usage: If True, increment usage counters (commit). Set False when rate limit runs first. Returns: - tuple: (User, ApiToken, error_message) if invalid, (User, ApiToken, None) if valid + tuple: (User, token_record, error_message) if invalid, (User, token_record, None) if valid. + token_record is ApiToken or OAuthAccessToken (duck-typed for scopes / has_scope / record_usage). """ - if not token_string or not token_string.startswith("tt_"): + if not token_string: + return None, None, "Invalid token format" + + # OAuth access tokens use prefix tto_ (must be checked before tt_) + if token_string.startswith("tto_"): + return _authenticate_oauth_access_token(token_string, record_usage=record_usage) + + if not token_string.startswith("tt_"): return None, None, "Invalid token format" # Lookup by HMAC hash (current) or legacy SHA256 hash @@ -100,10 +132,11 @@ def authenticate_token(token_string, record_usage: bool = True): return None, None, "User account is inactive" # Record usage - try: - api_token.record_usage(request.remote_addr) - except Exception as e: - current_app.logger.warning(f"Failed to record API token usage: {e}") + if record_usage: + try: + api_token.record_usage(request.remote_addr) + except Exception as e: + current_app.logger.warning(f"Failed to record API token usage: {e}") return user, api_token, None diff --git a/app/utils/auth_method.py b/app/utils/auth_method.py index 6978b840e..42af1d036 100644 --- a/app/utils/auth_method.py +++ b/app/utils/auth_method.py @@ -1,8 +1,8 @@ -"""Helpers for AUTH_METHOD parsing (none | local | oidc | ldap | both | all).""" +"""Helpers for AUTH_METHOD parsing (none | local | oidc | ldap | saml | both | all).""" from __future__ import annotations -_VALID = frozenset({"none", "local", "oidc", "ldap", "both", "all"}) +_VALID = frozenset({"none", "local", "oidc", "ldap", "saml", "both", "all"}) def normalize_auth_method(raw: str | None) -> str: @@ -26,6 +26,11 @@ def auth_includes_ldap(auth_method: str | None) -> bool: return m in ("ldap", "all") +def auth_includes_saml(auth_method: str | None) -> bool: + m = normalize_auth_method(auth_method) + return m in ("saml", "all") + + def requires_password_form(auth_method: str | None) -> bool: """True when login form should collect a password (local, ldap, or combined modes).""" m = normalize_auth_method(auth_method) diff --git a/app/utils/pdf_generator_reportlab.py b/app/utils/pdf_generator_reportlab.py index 889d42956..fcdfd3487 100644 --- a/app/utils/pdf_generator_reportlab.py +++ b/app/utils/pdf_generator_reportlab.py @@ -10,6 +10,7 @@ import os import tempfile from datetime import datetime +from decimal import Decimal from types import SimpleNamespace from typing import Any, Dict, List, Optional, Union @@ -40,6 +41,37 @@ from app.utils.pdf_watermark import make_page_callback, should_draw_watermark +def build_epc_sepa_qr_payload( + *, + name: str, + iban: str, + amount: Any, + remittance: str = "", + bic: str = "", +) -> str: + """Build EPC/GiroCode SEPA credit transfer QR payload (version 002).""" + try: + amt = amount if isinstance(amount, Decimal) else Decimal(str(amount or 0)) + except Exception: + amt = Decimal("0") + amount_line = f"EUR{amt.quantize(Decimal('0.01'))}" + return "\n".join( + [ + "BCD", + "002", + "1", + "SCT", + (bic or "").replace(" ", ""), + (name or "").strip(), + (iban or "").replace(" ", ""), + amount_line, + "", + "", + (remittance or "").strip(), + ] + ) + + class AbsolutePositionedFlowable(Flowable): """Flowable that stores element info for canvas drawing (elements drawn in page callbacks)""" @@ -397,6 +429,8 @@ def _render_element(self, element: Dict[str, Any]) -> Optional[Flowable]: return self._render_line(element) elif element_type == ElementType.TABLE: return self._render_table(element) + elif element_type in (ElementType.QR_CODE, ElementType.BARCODE): + return Spacer(1, 1) return None @@ -937,6 +971,77 @@ def _get_alignment(self, align: str) -> int: } return align_map.get(align.lower(), TA_LEFT) + def _is_epc_qr_element(self, element: Dict[str, Any]) -> bool: + flags = ( + element.get("epc"), + element.get("sepa"), + element.get("girocode"), + element.get("dataField"), + element.get("mode"), + element.get("barcodeType"), + ) + for flag in flags: + if isinstance(flag, str) and flag.strip().lower() in {"epc", "sepa", "girocode", "epc_sepa"}: + return True + if flag is True: + return True + return False + + def _resolve_qr_barcode_payload(self, element: Dict[str, Any]) -> str: + if self._is_epc_qr_element(element): + ctx = getattr(self, "context", {}) or {} + settings = ctx.get("settings") or getattr(self, "settings", None) or Settings.get_settings() + invoice = ctx.get("invoice") or getattr(self, "invoice", None) + amount = getattr(invoice, "total_amount", 0) if invoice is not None else 0 + remittance = getattr(invoice, "invoice_number", "") if invoice is not None else "" + return build_epc_sepa_qr_payload( + name=(getattr(settings, "company_name", None) or "").strip(), + iban=(getattr(settings, "company_iban", None) or "").strip(), + bic=(getattr(settings, "company_bic", None) or "").strip(), + amount=amount, + remittance=str(remittance or ""), + ) + for key in ("value", "text", "dataField"): + raw = element.get(key) + if raw: + return self._process_template_variables(str(raw)).strip() + return "" + + def _draw_qr_code_on_canvas( + self, canv: canvas.Canvas, element: Dict[str, Any], actual_x: float, actual_y: float, page_height: float + ) -> None: + from reportlab.graphics import renderPDF + from reportlab.graphics.barcode.qr import QrCodeWidget + from reportlab.graphics.shapes import Drawing + + data = self._resolve_qr_barcode_payload(element) + if not data: + return + width = float(element.get("width", 80) or 80) + height = float(element.get("height", width) or width) + qr = QrCodeWidget(data) + bounds = qr.getBounds() + bw = bounds[2] - bounds[0] or 1.0 + bh = bounds[3] - bounds[1] or 1.0 + drawing = Drawing(width, height, transform=[width / bw, 0, 0, height / bh, 0, 0]) + drawing.add(qr) + draw_y = actual_y - height + renderPDF.draw(drawing, canv, actual_x, draw_y) + + def _draw_barcode_on_canvas( + self, canv: canvas.Canvas, element: Dict[str, Any], actual_x: float, actual_y: float, page_height: float + ) -> None: + from reportlab.graphics.barcode import code128 + + data = self._resolve_qr_barcode_payload(element) + if not data: + return + width = float(element.get("width", 200) or 200) + height = float(element.get("height", 50) or 50) + bar = code128.Code128(data, barHeight=max(12.0, height * 0.75), barWidth=0.55) + _w, bar_h = bar.wrap(width, height) + bar.drawOn(canv, actual_x, actual_y - bar_h) + def _process_template_variables(self, text: str) -> str: """Process Jinja2-style template variables in text""" from app.utils.safe_template_render import render_sandboxed_string @@ -1127,6 +1232,10 @@ def _on_page(self, canv: canvas.Canvas, doc: SimpleDocTemplate): self._draw_circle_on_canvas(canv, element_copy, actual_x, actual_y, page_height) elif element_type == ElementType.LINE: self._draw_line_on_canvas(canv, element_copy, actual_x, actual_y, page_height) + elif element_type == ElementType.QR_CODE: + self._draw_qr_code_on_canvas(canv, element_copy, actual_x, actual_y, page_height) + elif element_type == ElementType.BARCODE: + self._draw_barcode_on_canvas(canv, element_copy, actual_x, actual_y, page_height) except Exception as e: if current_app: current_app.logger.error(f"Error drawing element {element_type} on canvas: {e}") diff --git a/app/utils/pdf_template_schema.py b/app/utils/pdf_template_schema.py index 83468e19d..b741f35b3 100644 --- a/app/utils/pdf_template_schema.py +++ b/app/utils/pdf_template_schema.py @@ -31,6 +31,8 @@ class ElementType(str, Enum): LINE = "line" TABLE = "table" SPACER = "spacer" + QR_CODE = "qr_code" + BARCODE = "barcode" class TextAlign(str, Enum): @@ -118,6 +120,8 @@ def validate_template_json(template_json: Dict[str, Any]) -> tuple[bool, Optiona elif element_type == ElementType.TABLE: if "columns" not in element: return False, f"Table element {idx} must specify 'columns'" + elif element_type in (ElementType.QR_CODE, ElementType.BARCODE): + pass return True, None diff --git a/app/utils/safe_redirect.py b/app/utils/safe_redirect.py index 5bc302e2f..2d07b885e 100644 --- a/app/utils/safe_redirect.py +++ b/app/utils/safe_redirect.py @@ -9,6 +9,24 @@ from werkzeug.wrappers import Response +def match_registered_redirect(candidate: Optional[str], allowed: Sequence[str]) -> Optional[str]: + """ + Return the registered redirect URI from ``allowed`` that equals ``candidate``. + + Used by the OAuth authorize consent flow so the redirect sink uses the + allowlisted (DB-sourced) string rather than the raw request value. + """ + if not candidate or not isinstance(candidate, str): + return None + needle = candidate.strip() + if not needle: + return None + for uri in allowed or (): + if isinstance(uri, str) and uri == needle: + return uri + return None + + def is_safe_relative_url(next_url: Optional[str]) -> bool: """ Return True if next_url is a same-origin relative path (not protocol-relative). diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 1d637c711..42333a1c0 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -713,6 +713,28 @@ def retry_failed_webhooks_with_app(): ) logger.info("Registered webhook retry task") + def poll_peppol_inbox_with_app(): + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for Peppol inbox poll") + return + with external_url_context(app_instance): + poll_peppol_inbox() + + _add_job( + scheduler, + func=poll_peppol_inbox_with_app, + trigger="cron", + minute="*/10", + id="poll_peppol_inbox", + name="Poll Peppol bridge inbox", + replace_existing=True, + ) + logger.info("Registered Peppol inbox poll task") + # Check for expiring quotes daily at 9:30 AM # Create a closure that captures the app instance def check_expiring_quotes_with_app(): @@ -1816,6 +1838,32 @@ def retry_failed_webhooks(): logger.error(f"Error retrying failed webhooks: {e}") +def poll_peppol_inbox(): + """Poll peppol-bridge /inbox and store inbound UBL documents. + + Note: This function should be called within an app context. + """ + try: + from app.services.peppol_inbound_service import PeppolInboundService + + result = PeppolInboundService().poll_and_import() + if result.get("skipped"): + return + if result.get("fetched"): + logger.info( + "Peppol inbox poll: fetched=%s created=%s updated=%s acked=%s errors=%s", + result.get("fetched"), + result.get("created"), + result.get("updated"), + result.get("acked"), + len(result.get("errors") or []), + ) + elif not result.get("ok"): + logger.warning("Peppol inbox poll failed: %s", result.get("error") or result.get("errors")) + except Exception as e: + logger.error(f"Error polling Peppol inbox: {e}") + + def check_expiring_quotes(): """Check for quotes expiring soon and send reminders diff --git a/app/utils/stripe_integration.py b/app/utils/stripe_integration.py index 43e85035c..acecab835 100644 --- a/app/utils/stripe_integration.py +++ b/app/utils/stripe_integration.py @@ -3,7 +3,7 @@ """ import logging -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from typing import Any, Dict, Optional import stripe @@ -11,6 +11,36 @@ logger = logging.getLogger(__name__) +# Stripe takes amounts in the currency's smallest unit. Most currencies have +# two decimals (cents), but these have none or three: +# https://docs.stripe.com/currencies#zero-decimal +# ISK, UGX, HUF and TWD are listed there as special cases: Stripe takes them +# in the two-decimal form, so they are intentionally not in these sets. +ZERO_DECIMAL_CURRENCIES = frozenset( + {"BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF"} +) +THREE_DECIMAL_CURRENCIES = frozenset({"BHD", "JOD", "KWD", "OMR", "TND"}) + + +def _minor_unit_factor(currency: str) -> int: + code = (currency or "").upper() + if code in ZERO_DECIMAL_CURRENCIES: + return 1 + if code in THREE_DECIMAL_CURRENCIES: + return 1000 + return 100 + + +def to_stripe_amount(amount, currency: str) -> int: + """Convert an amount in major units (e.g. 19.99 EUR) to Stripe's integer amount.""" + factor = _minor_unit_factor(currency) + return int((Decimal(str(amount)) * factor).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def from_stripe_amount(stripe_amount, currency: str) -> Decimal: + """Convert Stripe's integer amount back to major units.""" + return Decimal(str(stripe_amount)) / _minor_unit_factor(currency) + class StripeIntegration: """ @@ -42,8 +72,7 @@ def create_payment_intent( dict with 'success', 'client_secret', and 'payment_intent' keys """ try: - # Convert amount to cents - amount_cents = int(amount * 100) + amount_cents = to_stripe_amount(amount, currency) payment_intent_data = { "amount": amount_cents, @@ -105,7 +134,7 @@ def create_checkout_session( dict with 'success', 'session_id', and 'url' keys """ try: - amount_cents = int(amount * 100) + amount_cents = to_stripe_amount(amount, currency) session_data = { "payment_method_types": ["card"], diff --git a/app/utils/xrechnung.py b/app/utils/xrechnung.py index c675029ea..6af384eba 100644 --- a/app/utils/xrechnung.py +++ b/app/utils/xrechnung.py @@ -1,79 +1,385 @@ """ -XRechnung (Germany) export helper — experimental minimal skeleton. +XRechnung 3.0 (CIUS-DE) UBL export helpers. -Full CIUS-DE / EN 16931 compliance is TODO; this module produces a minimal UBL 2.1 -Invoice XML shell suitable for pipeline wiring and validator smoke tests. - -For production German e-invoicing, extend with complete BT/BG mappings and -embed via the Factur-X / Peppol paths where applicable. +Builds on PEPPOL BIS3 UBL generation and adjusts CustomizationID, mandatory fields, +payment means, and seller contact for German public-sector e-invoicing. """ from __future__ import annotations import xml.etree.ElementTree as ET -from datetime import date -from decimal import Decimal -from typing import Any, Optional -from xml.dom import minidom +from types import SimpleNamespace +from typing import Any, List, Optional, Tuple -UBL_NS = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" -CBC_NS = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" -CAC_NS = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" +from app.integrations.peppol import PeppolParty, build_peppol_ubl_invoice_xml -ET.register_namespace("", UBL_NS) -ET.register_namespace("cbc", CBC_NS) -ET.register_namespace("cac", CAC_NS) +XRECHNUNG_30_CUSTOMIZATION_ID = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0" +LEITWEG_SCHEME_ID = "0204" +DE_VAT_SCHEME_ID = "9930" +_NS_INVOICE = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" +_NS_CAC = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" +_NS_CBC = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" -def build_minimal_xrechnung_xml( - invoice: Any, - *, - seller_name: str, - buyer_name: str, - currency: str = "EUR", -) -> str: - """ - Build a minimal UBL Invoice XML document for an invoice model instance. +CAC = f"{{{_NS_CAC}}}" +CBC = f"{{{_NS_CBC}}}" - TODO: Map full XRechnung / CIUS-DE mandatory fields (seller/buyer address, - VAT IDs, payment means, line-level tax categories, etc.). - """ - root = ET.Element(f"{{{UBL_NS}}}Invoice") - inv_id = str(getattr(invoice, "invoice_number", None) or getattr(invoice, "id", "")) - issue = getattr(invoice, "issue_date", None) or date.today() - if hasattr(issue, "isoformat"): - issue_str = issue.isoformat() + +def xrechnung_export_available() -> bool: + return True + + +def _strip(v: Any) -> str: + return (v or "").strip() if v is not None else "" + + +def effective_buyer_reference(invoice: Any, client: Any) -> str: + """BT-10: invoice buyer_reference, else client Leitweg-ID.""" + br = _strip(getattr(invoice, "buyer_reference", None)) + if br: + return br + if client is not None: + return _strip(getattr(client, "leitweg_id", None)) + return "" + + +def validate_xrechnung_ready(invoice: Any, settings: Any, client: Any) -> List[str]: + """Return human-readable messages for missing mandatory XRechnung fields.""" + issues: List[str] = [] + + if not _strip(getattr(settings, "company_name", None)): + issues.append("Settings: company name is required (seller).") + if not (_strip(getattr(settings, "company_street", None)) or _strip(getattr(settings, "company_address", None))): + issues.append("Settings: company street or address is required.") + if not _strip(getattr(settings, "company_postcode", None)): + issues.append("Settings: company postcode is required.") + if not _strip(getattr(settings, "company_city", None)): + issues.append("Settings: company city is required.") + if not _strip(getattr(settings, "company_country", None)): + issues.append("Settings: company country (ISO 2) is required.") + if not _strip(getattr(settings, "company_tax_id", None)): + issues.append("Settings: company VAT ID is required.") + if not _strip(getattr(settings, "company_phone", None)): + issues.append("Settings: company phone is required (seller contact BG-6).") + if not _strip(getattr(settings, "company_email", None)): + issues.append("Settings: company email is required (seller contact BG-6).") + if not _strip(getattr(settings, "company_iban", None)): + issues.append("Settings: company IBAN is required (payment means BG-16).") + + if client is None: + issues.append("Invoice must be linked to a client.") else: - issue_str = str(issue) + if not _strip(getattr(client, "name", None)): + issues.append("Client: name is required.") + if not (_strip(getattr(client, "street", None)) or _strip(getattr(client, "address", None))): + issues.append("Client: street or address is required.") + if not _strip(getattr(client, "postcode", None)): + issues.append("Client: postcode is required.") + if not _strip(getattr(client, "city", None)): + issues.append("Client: city is required.") + if not _strip(getattr(client, "country", None)): + issues.append("Client: country (ISO 2) is required.") + vat = _strip(getattr(client, "vat_id", None)) + if not vat and client is not None and hasattr(client, "get_custom_field"): + vat = _strip(client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "")) + if not vat: + issues.append("Client: VAT ID is required.") + + if not effective_buyer_reference(invoice, client): + issues.append("Buyer reference (BT-10) / client Leitweg-ID is required.") + + inv_no = _strip(getattr(invoice, "invoice_number", None)) + if not inv_no: + issues.append("Invoice number is required.") + + return issues + + +def _structured_address_line(street: Optional[str], fallback: Optional[str]) -> Optional[str]: + s = _strip(street) or _strip(fallback) + return s or None + + +def build_supplier_party(settings: Any) -> PeppolParty: + tax_id = _strip(getattr(settings, "company_tax_id", None)) or None + endpoint_id = _strip(getattr(settings, "peppol_sender_endpoint_id", None)) + scheme_id = _strip(getattr(settings, "peppol_sender_scheme_id", None)) + if not endpoint_id and tax_id: + endpoint_id = tax_id.replace(" ", "") + scheme_id = DE_VAT_SCHEME_ID + if not endpoint_id: + endpoint_id = tax_id or "SELLER" + scheme_id = scheme_id or DE_VAT_SCHEME_ID - ET.SubElement(root, f"{{{CBC_NS}}}CustomizationID").text = ( - "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:standard:xrechnung_2.0" + country = ( + _strip(getattr(settings, "company_country", None)) + or _strip(getattr(settings, "peppol_sender_country", None)) + or None ) - ET.SubElement(root, f"{{{CBC_NS}}}ProfileID").text = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0" - ET.SubElement(root, f"{{{CBC_NS}}}ID").text = inv_id - ET.SubElement(root, f"{{{CBC_NS}}}IssueDate").text = issue_str - ET.SubElement(root, f"{{{CBC_NS}}}InvoiceTypeCode").text = "380" - ET.SubElement(root, f"{{{CBC_NS}}}DocumentCurrencyCode").text = currency.upper() - supplier = ET.SubElement(root, f"{{{CAC_NS}}}AccountingSupplierParty") - sp = ET.SubElement(supplier, f"{{{CAC_NS}}}Party") - spn = ET.SubElement(sp, f"{{{CAC_NS}}}PartyName") - ET.SubElement(spn, f"{{{CBC_NS}}}Name").text = seller_name or "Seller" + return PeppolParty( + endpoint_id=endpoint_id, + endpoint_scheme_id=scheme_id or DE_VAT_SCHEME_ID, + name=_strip(getattr(settings, "company_name", None)) or "Company", + tax_id=tax_id, + address_line=_structured_address_line( + getattr(settings, "company_street", None), getattr(settings, "company_address", None) + ), + country_code=country, + email=_strip(getattr(settings, "company_email", None)) or None, + phone=_strip(getattr(settings, "company_phone", None)) or None, + ) - customer = ET.SubElement(root, f"{{{CAC_NS}}}AccountingCustomerParty") - cp = ET.SubElement(customer, f"{{{CAC_NS}}}Party") - cpn = ET.SubElement(cp, f"{{{CAC_NS}}}PartyName") - ET.SubElement(cpn, f"{{{CBC_NS}}}Name").text = buyer_name or "Buyer" - total = Decimal(str(getattr(invoice, "total_amount", None) or getattr(invoice, "total", 0) or 0)) - monetary = ET.SubElement(root, f"{{{CAC_NS}}}LegalMonetaryTotal") - ET.SubElement(monetary, f"{{{CBC_NS}}}PayableAmount", currencyID=currency.upper()).text = f"{total:.2f}" +def build_customer_party(invoice: Any, client: Any, leitweg_id: str) -> PeppolParty: + vat = _strip(getattr(client, "vat_id", None)) + if not vat and hasattr(client, "get_custom_field"): + vat = _strip(client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "")) or None - rough = ET.tostring(root, encoding="unicode") - parsed = minidom.parseString(rough) - return parsed.toprettyxml(indent=" ") + return PeppolParty( + endpoint_id=leitweg_id, + endpoint_scheme_id=LEITWEG_SCHEME_ID, + name=_strip(getattr(client, "name", None) or getattr(invoice, "client_name", None)) or "Customer", + tax_id=vat, + address_line=_structured_address_line(getattr(client, "street", None), getattr(client, "address", None)), + country_code=_strip(getattr(client, "country", None)) or None, + email=_strip(getattr(client, "email", None) or getattr(invoice, "client_email", None)) or None, + phone=_strip(getattr(client, "phone", None)) or None, + ) -def xrechnung_export_available() -> bool: - """Feature flag helper: XRechnung export is experimental until full CIUS mapping ships.""" - return True +def _invoice_with_buyer_reference(invoice: Any, buyer_reference: str) -> Any: + """Shallow invoice view with buyer_reference set for UBL generation.""" + if _strip(getattr(invoice, "buyer_reference", None)) == buyer_reference: + return invoice + patched = SimpleNamespace() + for key in ( + "id", + "invoice_number", + "issue_date", + "due_date", + "currency_code", + "subtotal", + "tax_rate", + "tax_amount", + "total_amount", + "notes", + "items", + "expenses", + "extra_goods", + "project", + ): + if hasattr(invoice, key): + setattr(patched, key, getattr(invoice, key)) + patched.buyer_reference = buyer_reference + return patched + + +def _set_text(parent: ET.Element, tag: str, text: str) -> ET.Element: + el = ET.SubElement(parent, tag) + el.text = text + return el + + +def _ensure_structured_address(party_el: ET.Element, street: str, city: str, postcode: str, country: str) -> None: + addr = party_el.find(f"{CAC}PostalAddress") + if addr is None: + addr = ET.SubElement(party_el, f"{CAC}PostalAddress") + if street and addr.find(f"{CBC}StreetName") is None: + _set_text(addr, f"{CBC}StreetName", street) + if city and addr.find(f"{CBC}CityName") is None: + _set_text(addr, f"{CBC}CityName", city) + if postcode and addr.find(f"{CBC}PostalZone") is None: + _set_text(addr, f"{CBC}PostalZone", postcode) + if country: + country_el = addr.find(f"{CAC}Country") + if country_el is None: + country_el = ET.SubElement(addr, f"{CAC}Country") + if country_el.find(f"{CBC}IdentificationCode") is None: + _set_text(country_el, f"{CBC}IdentificationCode", country) + + +def _ensure_supplier_contact(supplier_party: ET.Element, contact_name: str) -> None: + contact = supplier_party.find(f"{CAC}Contact") + if contact is None: + contact = ET.SubElement(supplier_party, f"{CAC}Contact") + name_el = contact.find(f"{CBC}Name") + if name_el is None and contact_name: + contact.insert(0, _set_text(contact, f"{CBC}Name", contact_name)) + + +def _insert_payment_means(root: ET.Element, *, iban: str, bic: Optional[str], payment_id: Optional[str]) -> None: + if root.find(f"{CAC}PaymentMeans") is not None: + return + pm = ET.Element(f"{CAC}PaymentMeans") + _set_text(pm, f"{CBC}PaymentMeansCode", "30") + if payment_id: + _set_text(pm, f"{CBC}PaymentID", payment_id) + account = ET.SubElement(pm, f"{CAC}PayeeFinancialAccount") + _set_text(account, f"{CBC}ID", iban.replace(" ", "")) + if bic: + branch = ET.SubElement(account, f"{CAC}FinancialInstitutionBranch") + fin = ET.SubElement(branch, f"{CAC}FinancialInstitution") + _set_text(fin, f"{CBC}ID", bic.replace(" ", "")) + + insert_at = 0 + children = list(root) + for i, child in enumerate(children): + if child.tag == f"{CAC}AccountingCustomerParty": + insert_at = i + 1 + break + root.insert(insert_at, pm) + + +def _insert_payment_terms(root: ET.Element, invoice: Any, settings: Any) -> None: + if root.find(f"{CAC}PaymentTerms") is not None: + return + note = _strip(getattr(invoice, "payment_terms", None)) + if not note: + note = _strip(getattr(settings, "invoice_terms", None)) + due = getattr(invoice, "due_date", None) + if not note and not due: + return + pt = ET.Element(f"{CAC}PaymentTerms") + if note: + _set_text(pt, f"{CBC}Note", note) + insert_at = len(list(root)) + for i, child in enumerate(list(root)): + if child.tag == f"{CAC}PaymentMeans": + insert_at = i + 1 + break + if child.tag == f"{CAC}AccountingCustomerParty": + insert_at = i + 1 + root.insert(insert_at, pt) + + +def _patch_xrechnung_ubl( + xml: str, + *, + invoice: Any, + settings: Any, + client: Any, + buyer_reference: str, +) -> str: + root = ET.fromstring(xml.encode("utf-8")) + + cust = root.find(f"{CBC}CustomizationID") + if cust is not None: + cust.text = XRECHNUNG_30_CUSTOMIZATION_ID + + buyer_ref = root.find(f"{CBC}BuyerReference") + if buyer_ref is None: + buyer_ref = ET.SubElement(root, f"{CBC}BuyerReference") + buyer_ref.text = buyer_reference + + supplier_root = root.find(f"{CAC}AccountingSupplierParty") + if supplier_root is not None: + supplier_party = supplier_root.find(f"{CAC}Party") + if supplier_party is not None: + _ensure_structured_address( + supplier_party, + _strip(getattr(settings, "company_street", None)), + _strip(getattr(settings, "company_city", None)), + _strip(getattr(settings, "company_postcode", None)), + _strip(getattr(settings, "company_country", None)), + ) + _ensure_supplier_contact(supplier_party, _strip(getattr(settings, "company_name", None))) + + customer_root = root.find(f"{CAC}AccountingCustomerParty") + if customer_root is not None and client is not None: + customer_party = customer_root.find(f"{CAC}Party") + if customer_party is not None: + _ensure_structured_address( + customer_party, + _strip(getattr(client, "street", None)) or _strip(getattr(client, "address", None)), + _strip(getattr(client, "city", None)), + _strip(getattr(client, "postcode", None)), + _strip(getattr(client, "country", None)), + ) + + iban = _strip(getattr(settings, "company_iban", None)) + bic = _strip(getattr(settings, "company_bic", None)) or None + if iban: + _insert_payment_means( + root, + iban=iban, + bic=bic, + payment_id=_strip(getattr(invoice, "invoice_number", None)) or None, + ) + + _insert_payment_terms(root, invoice, settings) + + return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8") + + +def build_xrechnung_ubl_invoice_xml( + invoice: Any, + settings: Any, + client: Any, +) -> Tuple[str, str]: + """ + Build XRechnung 3.0 UBL XML and return (xml_string, sha256_hex). + Call validate_xrechnung_ready first in routes. + """ + buyer_reference = effective_buyer_reference(invoice, client) + supplier = build_supplier_party(settings) + customer = build_customer_party(invoice, client, buyer_reference) + inv = _invoice_with_buyer_reference(invoice, buyer_reference) + + xml, sha = build_peppol_ubl_invoice_xml(invoice=inv, supplier=supplier, customer=customer, attachments=None) + xml = _patch_xrechnung_ubl(xml, invoice=invoice, settings=settings, client=client, buyer_reference=buyer_reference) + import hashlib + + sha256_hex = hashlib.sha256(xml.encode("utf-8")).hexdigest() + return xml, sha256_hex + + +# Backwards compatibility for smoke tests / docs +def build_minimal_xrechnung_xml(invoice: Any, *, seller_name: str, buyer_name: str, currency: str = "EUR") -> str: + settings = SimpleNamespace( + company_name=seller_name, + company_street="Street 1", + company_postcode="10115", + company_city="Berlin", + company_country="DE", + company_tax_id="DE123456789", + company_phone="+49 30 123456", + company_email="seller@example.com", + company_iban="DE89370400440532013000", + company_bic="COBADEFFXXX", + company_address="Street 1", + peppol_sender_endpoint_id="", + peppol_sender_scheme_id="", + peppol_sender_country="DE", + invoice_terms="Payable within 30 days.", + ) + client = SimpleNamespace( + name=buyer_name, + street="Buyer St 2", + postcode="80331", + city="Munich", + country="DE", + vat_id="DE987654321", + address="Buyer St 2", + email="buyer@example.com", + phone="+49 89 123456", + leitweg_id="993-470090002-90", + ) + inv = SimpleNamespace( + invoice_number=getattr(invoice, "invoice_number", None) or getattr(invoice, "id", "1"), + issue_date=getattr(invoice, "issue_date", None), + due_date=getattr(invoice, "due_date", None), + currency_code=currency, + subtotal=getattr(invoice, "subtotal", getattr(invoice, "total_amount", 0)), + tax_rate=getattr(invoice, "tax_rate", 0), + tax_amount=getattr(invoice, "tax_amount", 0), + total_amount=getattr(invoice, "total_amount", getattr(invoice, "total", 0)), + buyer_reference=effective_buyer_reference(invoice, client), + notes=getattr(invoice, "notes", None), + items=getattr(invoice, "items", []) or [], + expenses=[], + extra_goods=[], + client_name=buyer_name, + ) + xml, _ = build_xrechnung_ubl_invoice_xml(inv, settings, client) + return xml diff --git a/browser-extension/README.md b/browser-extension/README.md index bc4b95f8d..e3e642841 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -77,8 +77,19 @@ browser-extension/ icons/ # idle + running PNGs ``` +## Packaging + +```bash +npm test +npm run zip # Chromium / Chrome Web Store +npm run zip:firefox # Firefox (gecko id + background.scripts overlay) +``` + +## Optional: suggest project from the current tab + +Enable **Suggest project from current tab URL** in the extension options (requests optional `activeTab`). The popup matches project name/code tokens against the active tab hostname and path. No content scripts are injected. + ## Not in this version -- Firefox-specific packaging -- Page content scripts / automatic project detection +- Page content scripts / DOM scraping - OIDC device flow inside the extension diff --git a/browser-extension/build/manifest.firefox.json b/browser-extension/build/manifest.firefox.json new file mode 100644 index 000000000..a81c48532 --- /dev/null +++ b/browser-extension/build/manifest.firefox.json @@ -0,0 +1,12 @@ +{ + "background": { + "scripts": ["background.js"], + "type": "module" + }, + "browser_specific_settings": { + "gecko": { + "id": "timetracker-extension@drytrix.com", + "strict_min_version": "121.0" + } + } +} diff --git a/browser-extension/build/zip.js b/browser-extension/build/zip.js index 04c3b5c86..29d94cf3f 100644 --- a/browser-extension/build/zip.js +++ b/browser-extension/build/zip.js @@ -1,9 +1,11 @@ /** - * Package the extension into dist/timetracker-extension-vX.Y.Z.zip - * for Chrome Web Store / sideload distribution. + * Package the extension into dist/timetracker-extension-vX.Y.Z[.firefox].zip + * for Chrome Web Store / Firefox AMO / sideload distribution. * * Usage (from browser-extension/): * npm run zip + * npm run zip:firefox + * node build/zip.js --target firefox * * Pure Node — no npm dependencies. */ @@ -26,6 +28,8 @@ const INCLUDE = [ 'options.js', 'lib/api.js', 'lib/picker.js', + 'lib/url_suggest.js', + 'lib/browser.js', 'icons', ]; @@ -53,6 +57,30 @@ function readManifestVersion() { return manifest.version || '0.0.0'; } +function deepMerge(base, overlay) { + if (Array.isArray(overlay)) return overlay.slice(); + if (overlay && typeof overlay === 'object') { + const out = { ...(base && typeof base === 'object' && !Array.isArray(base) ? base : {}) }; + for (const [k, v] of Object.entries(overlay)) { + out[k] = deepMerge(out[k], v); + } + return out; + } + return overlay; +} + +function buildManifestForTarget(target) { + const chromeManifest = JSON.parse(readFileSync(join(ROOT, 'manifest.json'), 'utf8')); + if (target !== 'firefox') return chromeManifest; + const overlay = JSON.parse(readFileSync(join(ROOT, 'build/manifest.firefox.json'), 'utf8')); + const merged = deepMerge(chromeManifest, overlay); + // Firefox MV3 uses background.scripts; drop Chromium-only service_worker key if both present. + if (merged.background?.scripts) { + delete merged.background.service_worker; + } + return merged; +} + function crc32(buf) { let c = ~0; for (let i = 0; i < buf.length; i++) { @@ -81,7 +109,7 @@ function buildZipStore(files) { let offset = 0; for (const file of files) { - const data = readFileSync(file.abs); + const data = file.data || readFileSync(file.abs); const nameBuf = Buffer.from(file.rel, 'utf8'); const crc = crc32(data); const localHeader = Buffer.concat([ @@ -138,21 +166,40 @@ function buildZipStore(files) { return Buffer.concat([...localParts, central, end]); } +function parseTarget() { + const idx = process.argv.indexOf('--target'); + if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1]; + return 'chromium'; +} + function main() { mkdirSync(DIST, { recursive: true }); + const target = parseTarget(); + if (target !== 'chromium' && target !== 'firefox') { + throw new Error(`Unknown target: ${target} (use chromium or firefox)`); + } const files = []; for (const entry of INCLUDE) { + if (entry === 'manifest.json') continue; collectFiles(entry, ROOT, files); } + const manifest = buildManifestForTarget(target); + files.unshift({ + abs: join(ROOT, 'manifest.json'), + rel: 'manifest.json', + data: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'), + }); + const version = readManifestVersion(); - const zipName = `timetracker-extension-v${version}.zip`; + const suffix = target === 'firefox' ? '.firefox' : ''; + const zipName = `timetracker-extension-v${version}${suffix}.zip`; const zipPath = join(DIST, zipName); const buffer = buildZipStore(files); writeFileSync(zipPath, buffer); - console.log(`Wrote ${zipPath}`); + console.log(`Wrote ${zipPath} (target=${target})`); console.log(` ${files.length} files, ${(buffer.length / 1024).toFixed(1)} KiB`); for (const f of files) console.log(` + ${f.rel}`); } diff --git a/browser-extension/lib/api.js b/browser-extension/lib/api.js index 576cd8e59..0b06e7230 100644 --- a/browser-extension/lib/api.js +++ b/browser-extension/lib/api.js @@ -41,7 +41,13 @@ export function classifyError(error, response, data) { return { ok: false, code: 'UNAUTHORIZED', message: 'Authentication failed. Sign in again.', status }; } if (status === 403) { - return { ok: false, code: 'FORBIDDEN', message: data?.error || 'Access denied for this account.', status }; + return { + ok: false, + code: data?.requires_2fa ? 'REQUIRES_2FA' : 'FORBIDDEN', + message: data?.error || 'Access denied for this account.', + status, + data, + }; } if (status === 409) { return { diff --git a/browser-extension/lib/browser.js b/browser-extension/lib/browser.js new file mode 100644 index 000000000..74c7aa73f --- /dev/null +++ b/browser-extension/lib/browser.js @@ -0,0 +1,7 @@ +/** + * Tiny chrome/browser API shim for Chromium + Firefox. + * Prefer `browser` (Promise-based) when available; fall back to `chrome`. + */ + +export const ext = + typeof globalThis.browser !== 'undefined' ? globalThis.browser : globalThis.chrome; diff --git a/browser-extension/lib/url_suggest.js b/browser-extension/lib/url_suggest.js new file mode 100644 index 000000000..537ae392e --- /dev/null +++ b/browser-extension/lib/url_suggest.js @@ -0,0 +1,45 @@ +/** + * Suggest a project from the active tab URL (no content scripts). + * Matches project name / code tokens against hostname + path. + */ + +function tokensFromProject(project) { + const parts = []; + if (project?.name) parts.push(String(project.name)); + if (project?.code) parts.push(String(project.code)); + return parts + .flatMap((s) => s.toLowerCase().split(/[^a-z0-9]+/g)) + .filter((t) => t.length >= 3); +} + +/** + * @param {string|null|undefined} url + * @param {Array<{id:number,name?:string,code?:string|null}>} projects + * @returns {{id:number,name?:string,code?:string|null}|null} + */ +export function suggestProjectFromUrl(url, projects) { + if (!url || !Array.isArray(projects) || projects.length === 0) return null; + let haystack = ''; + try { + const u = new URL(url); + haystack = `${u.hostname}${u.pathname}`.toLowerCase(); + } catch { + haystack = String(url).toLowerCase(); + } + + let best = null; + let bestScore = 0; + for (const project of projects) { + const tokens = tokensFromProject(project); + if (!tokens.length) continue; + let score = 0; + for (const token of tokens) { + if (haystack.includes(token)) score += token.length; + } + if (score > bestScore) { + bestScore = score; + best = project; + } + } + return best; +} diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index e433a6c3c..337f1d7f3 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "TimeTracker Timer", - "version": "5.19.1", + "version": "5.20.0", "description": "Start and stop TimeTracker timers from your browser toolbar.", "permissions": [ "storage", @@ -9,6 +9,9 @@ "idle", "notifications" ], + "optional_permissions": [ + "activeTab" + ], "optional_host_permissions": [ "http://*/*", "https://*/*" diff --git a/browser-extension/options.html b/browser-extension/options.html index 2160c2d11..596d52bba 100644 --- a/browser-extension/options.html +++ b/browser-extension/options.html @@ -20,16 +20,31 @@

    TimeTracker extension

    Base URL only — no path. Example: http://localhost:8080

    -
    Sign in with password
    -
    - - +
    +
    Sign in with password
    +
    + + +
    +
    + + +
    +
    -
    - - + + -
    @@ -46,6 +61,18 @@

    TimeTracker extension


    +
    Suggestions from the current tab
    +

    + When enabled, the popup can suggest a project whose name or code appears in the active tab URL + (requires the optional activeTab permission). +

    + + +
    +
    Not connected.
    diff --git a/browser-extension/options.js b/browser-extension/options.js index 5d9a69605..e3fe53839 100644 --- a/browser-extension/options.js +++ b/browser-extension/options.js @@ -1,5 +1,5 @@ /** - * Options page: server URL + password login or pasted API token. + * Options page: server URL + password login (with 2FA) or pasted API token. */ import { ApiClient, normalizeServerUrl, originFromServerUrl } from './lib/api.js'; @@ -14,8 +14,17 @@ const els = { disconnectBtn: document.getElementById('disconnect-btn'), message: document.getElementById('message'), statusLine: document.getElementById('status-line'), + loginSection: document.getElementById('login-section'), + totpSection: document.getElementById('totp-section'), + totpCode: document.getElementById('totp-code'), + totpBtn: document.getElementById('totp-btn'), + totpCancelBtn: document.getElementById('totp-cancel-btn'), + suggestFromTab: document.getElementById('suggest-from-tab'), }; +/** @type {{ serverUrl: string, tempToken: string }|null} */ +let pending2fa = null; + function showMessage(text, kind = 'error') { els.message.textContent = text; els.message.className = kind === 'success' ? 'success' : 'error'; @@ -27,6 +36,21 @@ function clearMessage() { els.message.textContent = ''; } +function showTotpStep(serverUrl, tempToken) { + pending2fa = { serverUrl, tempToken }; + els.loginSection.classList.add('hidden'); + els.totpSection.classList.remove('hidden'); + els.totpCode.value = ''; + els.totpCode.focus(); +} + +function hideTotpStep() { + pending2fa = null; + els.totpSection.classList.add('hidden'); + els.loginSection.classList.remove('hidden'); + els.totpCode.value = ''; +} + async function requestHostAccess(serverUrl) { const originPattern = originFromServerUrl(serverUrl); if (!originPattern) { @@ -53,13 +77,30 @@ async function persistSession(serverUrl, token) { chrome.runtime.sendMessage({ type: 'refresh_timer' }).catch(() => {}); } +async function finishWithToken(serverUrl, token) { + const client = new ApiClient(serverUrl, token); + const session = await client.validateSession(); + if (!session.ok) { + showMessage(session.message); + return false; + } + await persistSession(serverUrl, token); + els.password.value = ''; + hideTotpStep(); + showMessage('Connected successfully.', 'success'); + await updateStatus(); + return true; +} + async function updateStatus() { - const { server_url, api_token, logged_out } = await chrome.storage.local.get([ + const { server_url, api_token, logged_out, suggest_from_tab } = await chrome.storage.local.get([ 'server_url', 'api_token', 'logged_out', + 'suggest_from_tab', ]); if (server_url) els.serverUrl.value = server_url; + els.suggestFromTab.checked = Boolean(suggest_from_tab); if (!server_url || !api_token || logged_out) { els.statusLine.textContent = 'Not connected.'; return; @@ -99,22 +140,17 @@ async function connectWithPassword() { } const login = await ApiClient.loginWithPassword(serverUrl, username, password); - if (!login.ok) { - showMessage(login.message); + if (login.requires_2fa && login.temp_token) { + showTotpStep(serverUrl, login.temp_token); + showMessage('Enter your two-factor authentication code.', 'success'); return; } - - const client = new ApiClient(serverUrl, login.token); - const session = await client.validateSession(); - if (!session.ok) { - showMessage(session.message); + if (!login.ok) { + showMessage(login.message); return; } - await persistSession(serverUrl, login.token); - els.password.value = ''; - showMessage('Connected successfully.', 'success'); - await updateStatus(); + await finishWithToken(serverUrl, login.token); } catch (error) { showMessage(error?.message || 'Connect failed. Check the URL and try again.'); } finally { @@ -122,6 +158,33 @@ async function connectWithPassword() { } } +async function verifyTotp() { + clearMessage(); + if (!pending2fa) { + hideTotpStep(); + return; + } + const code = els.totpCode.value.trim(); + if (!code) { + showMessage('Enter the authentication code.'); + return; + } + + els.totpBtn.disabled = true; + try { + const result = await ApiClient.verifyLogin2fa(pending2fa.serverUrl, pending2fa.tempToken, code); + if (!result.ok) { + showMessage(result.message); + return; + } + await finishWithToken(pending2fa.serverUrl, result.token); + } catch (error) { + showMessage(error?.message || 'Verification failed.'); + } finally { + els.totpBtn.disabled = false; + } +} + async function connectWithToken() { clearMessage(); const serverUrl = normalizeServerUrl(els.serverUrl.value); @@ -169,6 +232,7 @@ async function connectWithToken() { async function disconnect() { clearMessage(); + hideTotpStep(); await chrome.storage.local.set({ api_token: null, logged_out: true, @@ -180,7 +244,34 @@ async function disconnect() { } els.loginBtn.addEventListener('click', connectWithPassword); +els.totpBtn.addEventListener('click', verifyTotp); +els.totpCancelBtn.addEventListener('click', () => { + clearMessage(); + hideTotpStep(); +}); +els.totpCode.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + verifyTotp(); + } +}); els.tokenBtn.addEventListener('click', connectWithToken); els.disconnectBtn.addEventListener('click', disconnect); +els.suggestFromTab.addEventListener('change', async () => { + const enabled = els.suggestFromTab.checked; + if (enabled) { + try { + const granted = await chrome.permissions.request({ permissions: ['activeTab'] }); + if (!granted) { + els.suggestFromTab.checked = false; + showMessage('activeTab permission is required for URL suggestions.'); + return; + } + } catch { + // Firefox may grant activeTab implicitly when used from the popup. + } + } + await chrome.storage.local.set({ suggest_from_tab: els.suggestFromTab.checked }); +}); updateStatus(); diff --git a/browser-extension/package.json b/browser-extension/package.json index b2aa5bd51..845ffccc7 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -1,11 +1,12 @@ { "name": "timetracker-browser-extension", "private": true, - "version": "5.19.1", - "description": "TimeTracker Chromium extension (Manifest V3)", + "version": "5.20.0", + "description": "TimeTracker browser extension (Manifest V3 \u2014 Chromium + Firefox)", "type": "module", "scripts": { "test": "node --test test/*.test.js", - "zip": "node build/zip.js" + "zip": "node build/zip.js", + "zip:firefox": "node build/zip.js --target firefox" } } diff --git a/browser-extension/popup.js b/browser-extension/popup.js index 6a2ce9d6f..279d1c6e3 100644 --- a/browser-extension/popup.js +++ b/browser-extension/popup.js @@ -9,6 +9,7 @@ import { formatElapsedHhMm, } from './lib/api.js'; import { enhanceSelect } from './lib/picker.js'; +import { suggestProjectFromUrl } from './lib/url_suggest.js'; const els = { message: document.getElementById('message'), @@ -236,6 +237,19 @@ async function loadTasksForProject(projectId, selectedTaskId = null) { } } +async function readActiveTabUrl() { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = tabs?.[0]?.url; + if (!url || url.startsWith('chrome://') || url.startsWith('about:') || url.startsWith('moz-extension:')) { + return null; + } + return url; + } catch { + return null; + } +} + async function loadProjects(preferredProjectId = null) { if (!client) return; const clientId = els.clientSelect.value ? Number(els.clientSelect.value) : null; @@ -253,6 +267,7 @@ async function loadProjects(preferredProjectId = null) { .map((p) => ({ id: p.id, name: p.name, + code: p.code ?? null, client_id: p.client_id ?? null, favorite: favIds.has(p.id), last_used_at: p.last_used_at ?? null, @@ -265,7 +280,20 @@ async function loadProjects(preferredProjectId = null) { return a.name.localeCompare(b.name); }); - fillProjectSelect(preferredProjectId); + let selectedId = preferredProjectId; + if (!selectedId) { + const { suggest_from_tab } = await chrome.storage.local.get(['suggest_from_tab']); + if (suggest_from_tab) { + const tabUrl = await readActiveTabUrl(); + const suggested = suggestProjectFromUrl(tabUrl, projects); + if (suggested) { + selectedId = suggested.id; + showMessage(`Suggested project from tab: ${suggested.name}`, 'success'); + } + } + } + + fillProjectSelect(selectedId); const selected = Number(els.projectSelect.value); if (selected) await loadTasksForProject(selected); else await loadTasksForProject(null); diff --git a/browser-extension/test/api.test.js b/browser-extension/test/api.test.js index 9a1a90ae3..8cc56896c 100644 --- a/browser-extension/test/api.test.js +++ b/browser-extension/test/api.test.js @@ -63,3 +63,57 @@ test('ApiClient.stopTimer posts stop_time when provided', async () => { delete globalThis.fetch; }); + +test('loginWithPassword returns REQUIRES_2FA when server asks for TOTP', async () => { + globalThis.fetch = async () => ({ + ok: false, + status: 403, + text: async () => + JSON.stringify({ + requires_2fa: true, + temp_token: 'tmp_abc', + error: 'Two-factor authentication required.', + }), + }); + + const result = await ApiClient.loginWithPassword('https://tracker.test', 'alice', 'secret'); + assert.equal(result.ok, false); + assert.equal(result.code, 'REQUIRES_2FA'); + assert.equal(result.requires_2fa, true); + assert.equal(result.temp_token, 'tmp_abc'); + + delete globalThis.fetch; +}); + +test('verifyLogin2fa posts temp_token and code then returns API token', async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ token: 'tt_final' }), + }; + }; + + const result = await ApiClient.verifyLogin2fa('https://tracker.test', 'tmp_abc', '123456'); + assert.equal(result.ok, true); + assert.equal(result.token, 'tt_final'); + assert.equal(calls[0].url, 'https://tracker.test/api/v1/auth/2fa/verify'); + const body = JSON.parse(calls[0].init.body); + assert.equal(body.temp_token, 'tmp_abc'); + assert.equal(body.code, '123456'); + + delete globalThis.fetch; +}); + +test('suggestProjectFromUrl matches project name substring in hostname/path', async () => { + const { suggestProjectFromUrl } = await import('../lib/url_suggest.js'); + const projects = [ + { id: 1, name: 'Acme Website', code: 'ACME' }, + { id: 2, name: 'Internal', code: null }, + ]; + assert.equal(suggestProjectFromUrl('https://acme.example.com/dashboard', projects)?.id, 1); + assert.equal(suggestProjectFromUrl('https://example.com/acme-website/tickets', projects)?.id, 1); + assert.equal(suggestProjectFromUrl('https://other.example.com/', projects), null); +}); diff --git a/ca_profile.xml b/ca_profile.xml new file mode 100644 index 000000000..5410c1961 --- /dev/null +++ b/ca_profile.xml @@ -0,0 +1,6 @@ + + + TimeTracker is a self-hosted time tracking, project management, and Peppol e-invoicing app (GPL-3.0). Install TimeTracker-DB first, then TimeTracker on the same timetracker network. Set SECRET_KEY with openssl rand -hex 32. Support: https://github.com/DRYTRIX/TimeTracker/issues — Docs: https://github.com/DRYTRIX/TimeTracker/blob/main/docs/admin/deployment/DISTRIBUTION.md + https://raw.githubusercontent.com/DRYTRIX/TimeTracker/main/app/static/images/timetracker-logo-icon.svg + https://github.com/DRYTRIX/TimeTracker + diff --git a/desktop/package-lock.json b/desktop/package-lock.json index a67380c81..f467b26e0 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetracker-desktop", - "version": "5.19.1", + "version": "5.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetracker-desktop", - "version": "5.19.1", + "version": "5.20.0", "license": "MIT", "dependencies": { "axios": "^1.21.0", @@ -16,12 +16,12 @@ "react-dom": "^19.3.1" }, "devDependencies": { - "@vitejs/plugin-react": "^6.1.2", - "electron": "^45.2.1", + "@vitejs/plugin-react": "^6.2.1", + "electron": "^45.2.3", "electron-builder": "^26.18.0", "esbuild": "^0.28.2", "sprintf-js": "file:vendor/sprintf-js", - "vite": "^8.3.5" + "vite": "^8.3.6" } }, "node_modules/@electron-internal/extract-zip": { @@ -832,9 +832,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", - "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -897,9 +897,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", - "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -914,9 +914,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", - "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -931,9 +931,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", - "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -948,9 +948,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", - "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -965,9 +965,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", - "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -982,9 +982,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", - "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -999,9 +999,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", - "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -1019,9 +1019,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", - "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -1039,9 +1039,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", - "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -1059,9 +1059,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", - "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -1079,9 +1079,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", - "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -1099,9 +1099,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", - "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -1119,9 +1119,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", - "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -1136,9 +1136,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", - "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -1153,9 +1153,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", - "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -1279,9 +1279,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", - "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.2.1.tgz", + "integrity": "sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==", "dev": true, "license": "MIT", "dependencies": { @@ -1291,9 +1291,9 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0 || ^0.3.0", "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.152.0", + "oxc-transform-react": ">=0.152.0 <0.157.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -2283,9 +2283,9 @@ } }, "node_modules/electron": { - "version": "45.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-45.2.1.tgz", - "integrity": "sha512-t433yuo8Kh8WG/mTStnPRT/NtpWw8A+caNLJgeU0tS/FlfOkeNEntFsac4ViLgDxZOmDe7BeSkOANVqrqNHhDA==", + "version": "45.2.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-45.2.3.tgz", + "integrity": "sha512-Vurzu5UOA5nTGT4LqIwXD16QFGrOZdiXxF+1UP4KNsAOo/YtDcT3/u0jhP4WSwNJ0iLrLDi85hzBJ0arnGDhyw==", "dev": true, "license": "MIT", "dependencies": { @@ -3910,9 +3910,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -4223,13 +4223,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", - "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.155.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4239,21 +4239,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.15", - "@rolldown/binding-android-arm64": "1.2.15", - "@rolldown/binding-darwin-arm64": "1.2.15", - "@rolldown/binding-darwin-x64": "1.2.15", - "@rolldown/binding-freebsd-x64": "1.2.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", - "@rolldown/binding-linux-arm64-gnu": "1.2.15", - "@rolldown/binding-linux-arm64-musl": "1.2.15", - "@rolldown/binding-linux-ppc64-gnu": "1.2.15", - "@rolldown/binding-linux-s390x-gnu": "1.2.15", - "@rolldown/binding-linux-x64-gnu": "1.2.15", - "@rolldown/binding-linux-x64-musl": "1.2.15", - "@rolldown/binding-openharmony-arm64": "1.2.15", - "@rolldown/binding-win32-arm64-msvc": "1.2.15", - "@rolldown/binding-win32-x64-msvc": "1.2.15" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/safe-buffer": { @@ -4742,16 +4742,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", - "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.30", - "rolldown": "~1.2.14", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/desktop/package.json b/desktop/package.json index 9eb4ca144..0092a3800 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "timetracker-desktop", - "version": "5.19.1", + "version": "5.20.0", "description": "TimeTracker desktop app for Windows, Linux, and macOS", "main": "src/main/main.js", "scripts": { @@ -33,12 +33,12 @@ "homepage": "https://github.com/DRYTRIX/TimeTracker", "license": "MIT", "devDependencies": { - "@vitejs/plugin-react": "^6.1.2", - "electron": "^45.2.1", + "@vitejs/plugin-react": "^6.2.1", + "electron": "^45.2.3", "electron-builder": "^26.18.0", "esbuild": "^0.28.2", "sprintf-js": "file:vendor/sprintf-js", - "vite": "^8.3.5" + "vite": "^8.3.6" }, "dependencies": { "axios": "^1.21.0", diff --git a/desktop/src/main/activity-tracker.js b/desktop/src/main/activity-tracker.js new file mode 100644 index 000000000..5baefc452 --- /dev/null +++ b/desktop/src/main/activity-tracker.js @@ -0,0 +1,61 @@ +/** + * Desktop activity autotracker stub (privacy-first). + * + * Disabled by default. Window title/app capture is intentionally not + * implemented here — see docs/design/DESKTOP_ACTIVITY_AUTOTRACKER.md. + * When enabled later, samples stay local and feed the ActivityWatch / + * PendingActivity pipeline rather than a new server path. + */ + +'use strict'; + +let enabled = false; +let intervalHandle = null; + +/** + * @param {object} [options] + * @param {object} [options.store] electron-store-like instance + * @param {number} [options.intervalMs] + * @returns {{ started: boolean, reason?: string }} + */ +function start(options = {}) { + const store = options.store; + const flagOn = store ? Boolean(store.get('activity_tracker_enabled', false)) : false; + if (!flagOn) { + enabled = false; + return { started: false, reason: 'feature_flag_off' }; + } + + // Capture is a follow-up; keep API stable without sampling yet. + enabled = true; + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + } + // Placeholder interval so stop() has something to clear once capture lands. + const intervalMs = Number(options.intervalMs) > 0 ? Number(options.intervalMs) : 60000; + intervalHandle = setInterval(() => { + // No-op: active window capture not implemented in Phase 6 stub. + }, intervalMs); + + return { started: true }; +} + +function stop() { + enabled = false; + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + } + return { stopped: true }; +} + +function isEnabled() { + return Boolean(enabled); +} + +module.exports = { + start, + stop, + isEnabled, +}; diff --git a/desktop/src/main/main.js b/desktop/src/main/main.js index 8d736084b..b7e93c197 100644 --- a/desktop/src/main/main.js +++ b/desktop/src/main/main.js @@ -3,6 +3,7 @@ const { createWindow, registerAppProtocol } = require('./window'); const { createTray, destroyTray } = require('./tray'); const { createIdleMonitor } = require('./idle'); const { createSecureStore } = require('./secure-store'); +const activityTracker = require('./activity-tracker'); let store = null; let idleMonitor = null; @@ -198,6 +199,14 @@ app.whenReady().then(() => { }); idleMonitor.start(); + // Privacy-first activity tracker: disabled by default; no window capture yet. + // See docs/design/DESKTOP_ACTIVITY_AUTOTRACKER.md + try { + activityTracker.start({ store }); + } catch (e) { + console.warn('TimeTracker: activity tracker stub failed to start:', e.message); + } + try { powerMonitor.on('resume', () => { notifyAppResume('power-resume'); @@ -261,6 +270,11 @@ app.on('before-quit', () => { app.isQuitting = true; unregisterGlobalShortcuts(); if (idleMonitor) idleMonitor.stop(); + try { + activityTracker.stop(); + } catch (_) { + /* ignore */ + } }); app.on('window-all-closed', () => { @@ -364,6 +378,7 @@ const ALLOWED_STORE_KEYS = new Set([ 'sync_interval', 'minimize_to_tray', 'global_shortcuts', + 'activity_tracker_enabled', '__migrated_from_plaintext', ]); diff --git a/desktop/src/renderer-react/src/i18n/en.json b/desktop/src/renderer-react/src/i18n/en.json index a3fb05cdf..e8072a0c6 100644 --- a/desktop/src/renderer-react/src/i18n/en.json +++ b/desktop/src/renderer-react/src/i18n/en.json @@ -22,5 +22,68 @@ "credit": "Credit notes", "workforce": "Workforce", "settings": "Settings" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "retry": "Retry", + "discard": "Discard", + "refresh": "Refresh", + "reload": "Reload", + "back": "Back", + "signOut": "Sign out", + "never": "Never", + "none": "None" + }, + "shell": { + "welcome": "Welcome", + "welcomeNamed": "Welcome, {{name}}", + "syncing": "Syncing…", + "queue": "Queue {{count}}", + "failedBadge": "{{count}} failed", + "syncNow": "Sync now", + "desktopVersion": "Desktop {{version}}" + }, + "dashboard": { + "activeTimer": "Active timer", + "noTimer": "No timer running", + "trackingTime": "Tracking time", + "startHint": "Start a focused session when you are ready.", + "paused": "Paused", + "startTimer": "Start timer", + "pause": "Pause", + "resume": "Resume", + "stop": "Stop", + "refreshing": "Refreshing…", + "projects": "Projects", + "recentEntries": "Recent entries", + "queuedSync": "Queued sync", + "recentTimeEntries": "Recent time entries" + }, + "settings": { + "title": "Settings", + "subtitle": "Server, sign-in, theme, language, and sync controls.", + "connection": "Connection", + "serverUrl": "Server URL", + "username": "Username", + "password": "Password", + "passwordPlaceholder": "Enter to re-authenticate", + "saveSettings": "Save settings", + "oidcHint": "OIDC-only servers do not accept username/password API login. Sign out, then paste an API token from Admin → API tokens on the sign-in screen.", + "appearance": "Appearance", + "language": "Language", + "locale": "Locale", + "offlineSync": "Offline sync", + "autoSync": "Auto sync", + "intervalSeconds": "Interval seconds", + "queueDepth": "Queue depth: {{count}}. Last sync: {{lastSync}}.", + "syncNow": "Sync now", + "resetApp": "Reset app", + "syncProblems": "Sync problems", + "syncProblemsEmpty": "No failed sync items.", + "syncProblemsHint": "Items that exhausted retries stay here until you retry or discard them.", + "failedItemType": "Type", + "failedItemError": "Error", + "failedAttempts": "{{count}} attempts" } } diff --git a/desktop/src/renderer-react/src/i18n/i18n.js b/desktop/src/renderer-react/src/i18n/i18n.js index 393fb817b..132ab0b97 100644 --- a/desktop/src/renderer-react/src/i18n/i18n.js +++ b/desktop/src/renderer-react/src/i18n/i18n.js @@ -2,17 +2,58 @@ import en from './en.json'; const catalogs = { en }; let locale = 'en'; +const listeners = new Set(); + +function interpolate(template, vars = {}) { + return String(template).replace(/\{\{(\w+)\}\}/g, (_, key) => { + if (vars[key] == null) return ''; + return String(vars[key]); + }); +} + +export function getLocale() { + return locale; +} + +export function listLocales() { + return Object.keys(catalogs); +} export function setLocale(next) { - if (catalogs[next]) locale = next; + if (!catalogs[next]) return locale; + if (locale === next) return locale; + locale = next; + listeners.forEach((fn) => { + try { + fn(locale); + } catch { + /* ignore listener errors */ + } + }); + return locale; } -export function t(key, fallback) { +export function onLocaleChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function t(key, fallbackOrVars, maybeVars) { const parts = String(key).split('.'); let cur = catalogs[locale]; for (const part of parts) { cur = cur?.[part]; } - if (cur != null && typeof cur === 'string') return cur; - return fallback ?? key; + + let fallback = key; + let vars = {}; + if (typeof fallbackOrVars === 'string') { + fallback = fallbackOrVars; + vars = maybeVars || {}; + } else if (fallbackOrVars && typeof fallbackOrVars === 'object') { + vars = fallbackOrVars; + } + + if (cur != null && typeof cur === 'string') return interpolate(cur, vars); + return interpolate(fallback, vars); } diff --git a/desktop/src/renderer-react/src/main.jsx b/desktop/src/renderer-react/src/main.jsx index 582e573fe..81734df05 100644 --- a/desktop/src/renderer-react/src/main.jsx +++ b/desktop/src/renderer-react/src/main.jsx @@ -21,6 +21,7 @@ import { CrmView } from './views/CrmView.jsx'; import { FinanceExtraView } from './views/FinanceExtraView.jsx'; import { StartTimerDialog, TimeEntryDialog } from './views/Dialogs.jsx'; import { formatAttendanceError, runAttendanceAction } from './views/WorkdayCard.jsx'; +import { onLocaleChange, setLocale } from './i18n/i18n.js'; const defaultConnection = { state: 'not_configured', @@ -67,6 +68,7 @@ function App() { const [settings, setSettings] = useState({ autoSync: true, syncInterval: 60 }); const [syncStatus, setSyncStatus] = useState({ queueDepth: 0, + failedDepth: 0, syncing: false, lastSyncAt: null, lastError: '', @@ -76,6 +78,7 @@ function App() { const [attendance, setAttendance] = useState(null); const [attendanceLoading, setAttendanceLoading] = useState(false); const [idlePrompt, setIdlePrompt] = useState(null); + const [localeTick, setLocaleTick] = useState(0); const syncEngineRef = useRef(null); const showToast = useCallback((message, type = 'info') => { @@ -94,8 +97,17 @@ function App() { const tokenServer = await storeGet('api_token_server_url'); const autoSync = await storeGet('auto_sync'); const syncInterval = await storeGet('sync_interval'); + let savedLocale = (await storeGet('locale')) || null; + if (!savedLocale) { + try { + savedLocale = window.localStorage?.getItem('tt_desktop_locale') || 'en'; + } catch { + savedLocale = 'en'; + } + } if (cancelled) return; + setLocale(savedLocale || 'en'); setTheme(savedTheme); setServerUrl(savedUrl || ''); setUsername(savedUsername || ''); @@ -155,6 +167,9 @@ function App() { storeSet('theme_mode', theme); }, [theme]); + useEffect(() => onLocaleChange(() => setLocaleTick((n) => n + 1)), []); + void localeTick; + const refreshAttendance = useCallback(async () => { if (!apiClient) return; setAttendanceLoading(true); @@ -776,6 +791,7 @@ function App() { setTheme={setTheme} onSyncNow={() => syncEngineRef.current?.syncNow()} onLogout={handleLogout} + onOpenSettings={() => setActiveView('settings')} />
    {activeView === 'dashboard' && ( @@ -923,6 +939,7 @@ function App() { syncStatus={syncStatus} theme={theme} setTheme={setTheme} + syncEngine={syncEngineRef.current} onSave={async ({ nextUrl, nextUsername, nextPassword, nextSettings }) => { setServerUrl(nextUrl); setUsername(nextUsername); diff --git a/desktop/src/renderer-react/src/styles/app.css b/desktop/src/renderer-react/src/styles/app.css index 8978bd613..9274b650f 100644 --- a/desktop/src/renderer-react/src/styles/app.css +++ b/desktop/src/renderer-react/src/styles/app.css @@ -508,6 +508,38 @@ label { .sync-pill { color: var(--primary); + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.sync-failed-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + background: color-mix(in srgb, var(--danger) 18%, var(--surface)); + color: var(--danger); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.failed-sync-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.85rem; +} + +.failed-sync-item { + display: grid; + gap: 0.75rem; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: color-mix(in srgb, var(--surface) 92%, var(--danger)); } .theme-switch { diff --git a/desktop/src/renderer-react/src/sync/syncEngine.js b/desktop/src/renderer-react/src/sync/syncEngine.js index fe2476f95..eb82a1900 100644 --- a/desktop/src/renderer-react/src/sync/syncEngine.js +++ b/desktop/src/renderer-react/src/sync/syncEngine.js @@ -34,18 +34,36 @@ async function getQueueDepth() { return db.queue.where('status').equals('pending').count(); } +async function getFailedDepth() { + return db.queue.where('status').equals('failed').count(); +} + +function serializeFailedItem(item) { + return { + id: item.id, + type: item.type, + attempts: item.attempts || 0, + lastError: item.lastError || '', + createdAt: item.createdAt || null, + updatedAt: item.updatedAt || null, + payload: item.payload || {}, + }; +} + export function createSyncEngine({ apiClient, settings, onStatus, onToast, onRefresh }) { let interval = null; let stopped = false; async function publish(partial = {}) { - const [queueDepth, lastSyncAt, lastError] = await Promise.all([ + const [queueDepth, failedDepth, lastSyncAt, lastError] = await Promise.all([ getQueueDepth(), + getFailedDepth(), db.meta.get('lastSyncAt'), db.meta.get('lastError'), ]); onStatus({ queueDepth, + failedDepth, syncing: false, lastSyncAt: lastSyncAt?.value || null, lastError: lastError?.value || '', @@ -59,6 +77,7 @@ export function createSyncEngine({ apiClient, settings, onStatus, onToast, onRef payload: { ...(payload || {}) }, status: 'pending', attempts: 0, + lastError: '', createdAt: Date.now(), updatedAt: Date.now(), }; @@ -94,6 +113,32 @@ export function createSyncEngine({ apiClient, settings, onStatus, onToast, onRef } } + async function listFailed() { + const items = await db.queue.where('status').equals('failed').sortBy('updatedAt'); + return items.map(serializeFailedItem).reverse(); + } + + async function retryItem(id) { + const item = await db.queue.get(id); + if (!item || item.status !== 'failed') return false; + await db.queue.update(id, { + status: 'pending', + attempts: 0, + lastError: '', + updatedAt: Date.now(), + }); + await publish(); + return true; + } + + async function discardItem(id) { + const item = await db.queue.get(id); + if (!item) return false; + await db.queue.delete(id); + await publish(); + return true; + } + async function syncNow() { if (stopped || !navigator.onLine) { await publish({ syncing: false }); @@ -180,5 +225,8 @@ export function createSyncEngine({ apiClient, settings, onStatus, onToast, onRef queueOperation, cacheReadData, clearAll, + listFailed, + retryItem, + discardItem, }; } diff --git a/desktop/src/renderer-react/src/views/DashboardView.jsx b/desktop/src/renderer-react/src/views/DashboardView.jsx index 9099a8c4b..1763d8340 100644 --- a/desktop/src/renderer-react/src/views/DashboardView.jsx +++ b/desktop/src/renderer-react/src/views/DashboardView.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { EntryList, Panel, StatCard } from '../components/ui.jsx'; +import { t } from '../i18n/i18n.js'; import { formatDuration, isTimerPaused, timerElapsedSeconds } from '../utils/format.js'; import { WorkdayCard } from './WorkdayCard.jsx'; @@ -21,11 +22,13 @@ export function DashboardView({ const [tick, setTick] = useState(0); useEffect(() => { if (!active || paused) return undefined; - const id = window.setInterval(() => setTick((t) => t + 1), 1000); + const id = window.setInterval(() => setTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [active, paused]); void tick; const seconds = timerElapsedSeconds(data.timer); + const projectLabel = + data.timer?.timer?.project || data.timer?.timer?.client || data.timer?.timer?.project_name || t('dashboard.trackingTime'); return (
    @@ -36,42 +39,49 @@ export function DashboardView({ />
    -

    Active timer

    -

    {active ? formatDuration(seconds) : 'No timer running'}

    +

    {t('dashboard.activeTimer')}

    +

    {active ? formatDuration(seconds) : t('dashboard.noTimer')}

    {active - ? `${paused ? 'Paused · ' : ''}${data.timer?.timer?.project || data.timer?.timer?.client || data.timer?.timer?.project_name || 'Tracking time'}` - : 'Start a focused session when you are ready.'} + ? `${paused ? `${t('dashboard.paused')} · ` : ''}${projectLabel}` + : t('dashboard.startHint')}

    {active && !paused && ( )} {active && paused && ( )}
    - - - + + +
    - Reload}> + + {t('common.reload')} + + } + >
    diff --git a/desktop/src/renderer-react/src/views/SettingsView.jsx b/desktop/src/renderer-react/src/views/SettingsView.jsx index ea208cad2..41d9014d5 100644 --- a/desktop/src/renderer-react/src/views/SettingsView.jsx +++ b/desktop/src/renderer-react/src/views/SettingsView.jsx @@ -1,5 +1,7 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Panel, ThemeSwitch, ViewHeader } from '../components/ui.jsx'; +import { getLocale, listLocales, setLocale, t } from '../i18n/i18n.js'; +import { storeSet } from '../services/store.js'; export function SettingsView(props) { const { @@ -15,28 +17,77 @@ export function SettingsView(props) { onSave, onReset, onSyncNow, + syncEngine, } = props; const [password, setPassword] = useState(''); + const [locale, setLocaleState] = useState(getLocale()); + const [failedItems, setFailedItems] = useState([]); + const [failedLoading, setFailedLoading] = useState(false); + + const refreshFailed = useCallback(async () => { + if (!syncEngine?.listFailed) { + setFailedItems([]); + return; + } + setFailedLoading(true); + try { + const items = await syncEngine.listFailed(); + setFailedItems(items || []); + } finally { + setFailedLoading(false); + } + }, [syncEngine]); + + useEffect(() => { + refreshFailed(); + }, [refreshFailed, syncStatus.failedDepth, syncStatus.queueDepth, syncStatus.lastSyncAt]); + + const handleLocaleChange = async (next) => { + setLocale(next); + setLocaleState(next); + await storeSet('locale', next); + try { + window.localStorage?.setItem('tt_desktop_locale', next); + } catch { + /* ignore */ + } + }; + + const handleRetry = async (id) => { + await syncEngine?.retryItem?.(id); + await refreshFailed(); + await onSyncNow?.(); + }; + + const handleDiscard = async (id) => { + await syncEngine?.discardItem?.(id); + await refreshFailed(); + }; + + const lastSyncLabel = syncStatus.lastSyncAt + ? new Date(syncStatus.lastSyncAt).toLocaleString() + : t('common.never'); + return (
    - +
    - + -

    - OIDC-only servers do not accept username/password API login. Sign out, then paste an API token from Admin → - API tokens on the sign-in screen. -

    +

    {t('settings.oidcHint')}

    - + + +

    {t('settings.language')}: {locale}

    - +

    - Queue depth: {syncStatus.queueDepth}. Last sync:{' '} - {syncStatus.lastSyncAt ? new Date(syncStatus.lastSyncAt).toLocaleString() : 'Never'}. + {t('settings.queueDepth', { count: syncStatus.queueDepth, lastSync: lastSyncLabel })}

    {syncStatus.lastError &&

    {syncStatus.lastError}

    }
    + +

    {t('settings.syncProblemsHint')}

    + {failedLoading &&

    {t('dashboard.refreshing')}

    } + {!failedLoading && failedItems.length === 0 && ( +

    {t('settings.syncProblemsEmpty')}

    + )} + {failedItems.length > 0 && ( +
      + {failedItems.map((item) => ( +
    • +
      + + {t('settings.failedItemType')}: {item.type} + +

      {t('settings.failedAttempts', { count: item.attempts || 0 })}

      +

      + {t('settings.failedItemError')}: {item.lastError || t('common.none')} +

      +
      +
      + + +
      +
    • + ))} +
    + )} +
    ); diff --git a/desktop/src/renderer-react/src/views/Shell.jsx b/desktop/src/renderer-react/src/views/Shell.jsx index dc8f1e80b..5dd348f6e 100644 --- a/desktop/src/renderer-react/src/views/Shell.jsx +++ b/desktop/src/renderer-react/src/views/Shell.jsx @@ -71,7 +71,7 @@ export function AuthFlow(props) {