diff --git a/.cursor/plans/mobile_and_desktop_apps_5c5af1fb.plan.md b/.cursor/plans/mobile_and_desktop_apps_5c5af1fb.plan.md new file mode 100644 index 00000000..dfbe882c --- /dev/null +++ b/.cursor/plans/mobile_and_desktop_apps_5c5af1fb.plan.md @@ -0,0 +1,609 @@ +--- +name: Mobile and Desktop Apps +overview: Create complete Android/iOS mobile apps using Flutter and lightweight Windows/Linux/macOS desktop applications using Electron that integrate with the existing TimeTracker REST API. +todos: + - id: flutter_setup + content: Set up Flutter project structure with clean architecture (data/domain/presentation layers) + status: pending + - id: electron_setup + content: Set up Electron project with main/renderer process separation and build configuration + status: pending + - id: api_client_mobile + content: Implement Flutter API client with Dio, token auth, and error handling + status: pending + - id: api_client_desktop + content: Implement Electron API client (Axios) with token auth and error handling + status: pending + - id: auth_flow + content: Implement authentication flows for both platforms with secure token storage + status: pending + - id: timer_mobile + content: Implement timer functionality in Flutter (start/stop/status with background updates) + status: pending + - id: timer_desktop + content: Implement timer functionality in Electron with system tray integration + status: pending + - id: offline_storage + content: Set up local databases (Hive/SQLite for mobile, IndexedDB/SQLite for desktop) + status: pending + - id: offline_sync + content: Implement offline sync with conflict resolution for both platforms + status: pending + - id: projects_tasks_ui + content: Build projects and tasks UI screens for both platforms + status: pending + - id: time_entries_ui + content: Build time entries listing and editing screens + status: pending + - id: settings_ui + content: Implement settings screens (server URL, API token, sync preferences) + status: pending + - id: background_tasks + content: Implement background timer updates using WorkManager (mobile) + status: pending + - id: system_tray + content: Complete system tray implementation with timer controls (desktop) + status: pending + - id: notifications + content: Implement push notifications for timer events on both platforms + status: pending + - id: platform_polish + content: Platform-specific polish (Material Design 3 for Android, HIG for iOS, native desktop features) + status: pending + - id: testing + content: Write unit, widget, and integration tests for both applications + status: pending + - id: build_config + content: Configure builds for all target platforms (Android APK/AAB, iOS archive, Electron installers) + status: pending + - id: documentation + content: Create user guides and API integration documentation + status: pending + - id: distribution + content: Set up distribution pipelines (app stores for mobile, installers for desktop) + status: pending +--- + +# Mobile and Desktop Apps Development Plan + +## Overview + +This plan covers developing: + +1. **Mobile Apps** (Android & iOS) - Built with Flutter for cross-platform code sharing +2. **Desktop Apps** (Windows/Linux/macOS) - Built with Electron for web-based cross-platform deployment + +Both applications will integrate with the existing TimeTracker REST API (`/api/v1/`) that uses token-based authentication. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TimeTracker Backend │ +│ (Flask + PostgreSQL + REST API) │ +│ Base URL: /api/v1/ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ REST API + │ (Bearer Token Auth) + │ + ┌───────────────────┴───────────────────┐ + │ │ +┌───────▼────────┐ ┌────────▼────────┐ +│ Flutter Mobile │ │ Electron Desktop│ +│ (Android/iOS)│ │ (Win/Linux/macOS)│ +│ │ │ │ +│ - Shared API │ │ - Shared API │ +│ Client │ │ Client │ +│ - Local Storage│ │ - Local Storage │ +│ - Background │ │ - System Tray │ +│ Tasks │ │ - Notifications │ +└────────────────┘ └─────────────────┘ +``` + +## Phase 1: Flutter Mobile Apps (Android & iOS) + +### 1.1 Project Setup and Architecture + +**Location**: `mobile/` directory at project root + +**Structure**: + +``` +mobile/ +├── android/ # Android platform files +├── ios/ # iOS platform files +├── lib/ +│ ├── main.dart # App entry point +│ ├── core/ +│ │ ├── config/ # App configuration +│ │ ├── constants/ # Constants and enums +│ │ └── themes/ # App theming +│ ├── data/ +│ │ ├── api/ # REST API client +│ │ ├── local/ # Local database (Hive/SQLite) +│ │ └── models/ # Data models +│ ├── domain/ +│ │ ├── repositories/ # Repository interfaces +│ │ └── usecases/ # Business logic +│ ├── presentation/ +│ │ ├── screens/ # UI screens +│ │ ├── widgets/ # Reusable widgets +│ │ └── providers/ # State management (Riverpod/Provider) +│ └── utils/ +│ ├── auth/ # Authentication utilities +│ └── storage/ # Secure storage +├── pubspec.yaml # Dependencies +└── README.md +``` + +**Key Dependencies**: + +- `dio` - HTTP client for API calls +- `hive` or `sqflite` - Local database for offline support +- `riverpod` or `provider` - State management +- `flutter_secure_storage` - Secure token storage +- `workmanager` - Background tasks +- `local_notifications` - Push notifications +- `permission_handler` - Platform permissions + +### 1.2 Core Features Implementation + +#### 1.2.1 Authentication & API Client + +**API Client** (`lib/data/api/api_client.dart`): + +- Base URL configuration from user input or auto-discovery +- Token-based authentication using Bearer tokens +- Request/response interceptors for error handling +- Retry logic for network failures +- Token refresh mechanism (if implemented) + +**Authentication Flow**: + +1. User enters server URL (with validation) +2. User provides API token (from web admin panel) +3. Token stored securely using `flutter_secure_storage` +4. Token validated on first API call +5. Persistent login session + +**Integration with existing API**: + +- Use existing `/api/v1/` endpoints +- Leverage `require_api_token()` decorator from `app/utils/api_auth.py` +- Support scopes: `read:time_entries`, `write:time_entries`, `read:projects`, `read:tasks` + +#### 1.2.2 Time Tracking Features + +**Timer Management**: + +- **Start Timer**: `POST /api/v1/timer/start` with `project_id`, optional `task_id` +- **Stop Timer**: `POST /api/v1/timer/stop` +- **Timer Status**: `GET /api/v1/timer/status` - Poll every 5-10 seconds when active +- Visual timer display with running time +- Background timer updates using `workmanager` +- Persistent timer state (survives app restarts) + +**Time Entries**: + +- **List Entries**: `GET /api/v1/time-entries` with date filtering +- **Create Entry**: `POST /api/v1/time-entries` for manual entries +- **Update Entry**: `PUT /api/v1/time-entries/{id}` +- **Delete Entry**: `DELETE /api/v1/time-entries/{id}` + +**Offline Support**: + +- Local database stores time entries when offline +- Sync queue for pending operations +- Background sync when connection restored +- Conflict resolution for concurrent edits + +#### 1.2.3 Projects & Tasks + +**Projects**: + +- **List Projects**: `GET /api/v1/projects?status=active` +- Project filtering and search +- Favorite projects (stored locally) +- Project details view + +**Tasks**: + +- **List Tasks**: `GET /api/v1/tasks?project_id={id}` +- Task selection when starting timer +- Task status display + +#### 1.2.4 UI Screens + +**Home/Dashboard Screen**: + +- Active timer display (large, prominent) +- Quick start button for most recent project +- Today's time summary +- Recent time entries list + +**Timer Screen**: + +- Large timer display (minutes:seconds or hours:minutes) +- Project and task selection +- Start/Stop/Pause controls +- Notes input field +- Timer notes can be added on stop + +**Projects Screen**: + +- List of active projects +- Search and filter +- Project cards with time spent today +- Tap to view details or start timer + +**Time Entries Screen**: + +- Calendar view for selecting date +- List of time entries for selected date +- Swipe to edit/delete +- Manual entry form + +**Settings Screen**: + +- Server URL configuration +- API token management +- Sync settings (auto-sync, sync interval) +- Theme settings (light/dark mode) +- About and version info + +#### 1.2.5 Background Features + +**Background Timer**: + +- Use `workmanager` for periodic timer updates +- Update local display every minute +- Sync with server periodically +- Show notification when timer is running + +**Push Notifications** (Future enhancement): + +- Idle detection reminders +- Timer stop reminders +- Sync status notifications + +### 1.3 Platform-Specific Features + +#### Android + +- Material Design 3 UI +- Android 12+ splash screen +- Edge-to-edge display support +- Android 13+ notification permissions +- Background execution limits handling + +#### iOS + +- iOS Human Interface Guidelines +- Native iOS navigation patterns +- Face ID/Touch ID for secure token storage (optional) +- iOS 14+ widget support (Future) +- Background app refresh configuration + +### 1.4 Testing & Deployment + +**Testing**: + +- Unit tests for business logic +- Widget tests for UI components +- Integration tests for API calls +- Test local database operations + +**Build & Release**: + +- Android: Generate signed APK/AAB via Gradle +- iOS: Archive and distribute via Xcode +- App Store/Play Store submission +- Version management aligned with backend + +## Phase 2: Electron Desktop App (Windows/Linux/macOS) + +### 2.1 Project Setup and Architecture + +**Location**: `desktop/` directory at project root + +**Structure**: + +``` +desktop/ +├── src/ +│ ├── main/ # Electron main process +│ │ ├── main.js # Main entry point +│ │ ├── preload.js # Preload script +│ │ ├── tray.js # System tray management +│ │ └── window.js # Window management +│ ├── renderer/ # Electron renderer (frontend) +│ │ ├── index.html # Main HTML +│ │ ├── css/ # Styles +│ │ ├── js/ # Frontend JavaScript +│ │ │ ├── api/ # API client +│ │ │ ├── storage/ # Local storage +│ │ │ ├── ui/ # UI components +│ │ │ └── utils/ # Utilities +│ │ └── assets/ # Static assets +│ └── shared/ # Shared code between main/renderer +│ └── config.js # Configuration +├── package.json # Dependencies and scripts +├── electron-builder.yml # Build configuration +└── README.md +``` + +**Key Dependencies**: + +- `electron` - Electron framework +- `electron-store` - Persistent storage +- `axios` - HTTP client +- `dexie` or `better-sqlite3` - Local database +- `auto-updater` (platform-specific) - Auto-update functionality +- `electron-notifications` - Desktop notifications + +### 2.2 Core Features Implementation + +#### 2.2.1 Main Process Setup + +**Window Management** (`src/main/window.js`): + +- Create main window (800x600 minimum, 1200x800 default) +- Window state persistence (position, size) +- Minimize to tray option +- Always on top option (optional) +- Multi-monitor support + +**System Tray** (`src/main/tray.js`): + +- System tray icon with menu +- Quick timer controls from tray +- Active timer display in tooltip +- Context menu: Start Timer, Stop Timer, Show Window, Quit +- Tray icon updates based on timer state + +**Preload Script** (`src/main/preload.js`): + +- Expose secure APIs to renderer +- IPC communication setup +- Electron API access control + +#### 2.2.2 Renderer Process (Frontend) + +**UI Framework Options**: + +- **Option A**: Vanilla JS + modern CSS (lightweight, fast) +- **Option B**: React/Vue (if more complex UI needed) +- **Recommendation**: Start with vanilla JS for simplicity + +**API Client** (`src/renderer/js/api/client.js`): + +- Similar structure to Flutter API client +- Base URL configuration +- Token authentication +- Request/response handling +- Error management + +**Local Storage** (`src/renderer/js/storage/`): + +- Use `electron-store` for settings +- IndexedDB or SQLite for time entries cache +- Offline queue for pending operations + +#### 2.2.3 Time Tracking Features + +**Timer Functionality**: + +- Same API endpoints as mobile app +- `POST /api/v1/timer/start`, `POST /api/v1/timer/stop`, `GET /api/v1/timer/status` +- Persistent timer (survives window close) +- System tray timer display +- Desktop notifications for timer events + +**UI Components**: + +- Compact timer widget (can be separate small window) +- Full dashboard view +- Project/task selection +- Time entries list +- Settings panel + +#### 2.2.4 Desktop-Specific Features + +**System Integration**: + +- Global keyboard shortcuts (Ctrl+Shift+T to toggle timer) +- Auto-start on login (optional) +- Idle detection using system APIs +- System notifications for timer reminders + +**Performance**: + +- Lightweight bundle size (<50MB) +- Fast startup time (<2 seconds) +- Low memory footprint +- Efficient background operation + +**Offline Support**: + +- Local database for cached data +- Offline queue for operations +- Background sync when online +- Conflict resolution + +### 2.3 Platform-Specific Configuration + +#### Windows + +- NSIS installer or MSI package +- Windows 10+ compatibility +- Windows notification API +- Windows registry for auto-start (optional) + +#### Linux + +- AppImage, .deb, or .rpm packages +- Desktop entry file for app launcher +- XDG desktop integration +- System tray via StatusNotifierItem (AppIndicator) + +#### macOS + +- DMG installer +- macOS 10.15+ compatibility +- Native macOS notifications +- Menu bar integration (alternative to dock) +- Code signing and notarization for distribution + +### 2.4 Build and Distribution + +**Build Configuration** (`electron-builder.yml`): + +- Multi-platform builds from single codebase +- Code signing certificates (platform-specific) +- Auto-updater configuration +- Icon and branding assets + +**Distribution**: + +- GitHub Releases for downloadable installers +- Optional: Auto-update server setup +- Version management aligned with backend + +## Phase 3: Shared Components and Integration + +### 3.1 API Client Library + +**Shared API Client** (optional separate package): + +- Common API client logic for both mobile and desktop +- TypeScript definitions for API responses +- Request/response models +- Error handling utilities + +### 3.2 Backend API Enhancements + +**Additional API Endpoints** (if needed): + +- WebSocket support for real-time timer updates (optional enhancement) +- Bulk operations endpoint for offline sync +- Health check endpoint with version info + +**Existing API Usage**: + +- Leverage existing `/api/v1/` endpoints +- Use existing authentication mechanism (`app/utils/api_auth.py`) +- Follow existing API documentation (`docs/api/REST_API.md`) + +### 3.3 Documentation + +**API Integration Guide**: + +- Document how mobile/desktop apps connect to backend +- API token creation instructions +- Common integration patterns +- Troubleshooting guide + +**User Guides**: + +- Mobile app user manual +- Desktop app user manual +- Setup and configuration instructions +- Offline mode explanation + +## Implementation Phases + +### Phase 1: Foundation (Weeks 1-2) + +- [ ] Set up Flutter project structure +- [ ] Set up Electron project structure +- [ ] Implement basic API client for both platforms +- [ ] Implement authentication flow +- [ ] Basic UI skeleton for both apps + +### Phase 2: Core Time Tracking (Weeks 3-4) + +- [ ] Timer start/stop functionality +- [ ] Timer status polling +- [ ] Projects and tasks integration +- [ ] Time entries listing +- [ ] Basic offline storage setup + +### Phase 3: Enhanced Features (Weeks 5-6) + +- [ ] Offline sync implementation +- [ ] Background tasks (mobile) +- [ ] System tray integration (desktop) +- [ ] Notifications +- [ ] Settings and configuration + +### Phase 4: Polish & Testing (Weeks 7-8) + +- [ ] UI/UX refinements +- [ ] Cross-platform testing +- [ ] Performance optimization +- [ ] Security audit +- [ ] Documentation completion + +### Phase 5: Distribution (Week 9+) + +- [ ] Build configuration for all platforms +- [ ] Store submission (mobile) +- [ ] Installer creation (desktop) +- [ ] Release and distribution +- [ ] User feedback collection + +## Technical Considerations + +### Security + +- API tokens stored securely (Keychain on iOS, Keystore on Android, encrypted storage on desktop) +- HTTPS required for API communication +- Token validation on app startup +- Secure token transmission only + +### Offline Support + +- Local database for all core entities +- Sync queue with conflict resolution +- Background sync when connection restored +- Clear offline/online status indicators + +### Performance + +- Efficient API polling intervals +- Lazy loading for large lists +- Image/asset optimization +- Memory management +- Battery optimization (mobile) + +### Error Handling + +- Network error handling with retry logic +- API error response parsing +- User-friendly error messages +- Offline mode graceful degradation + +## Dependencies on Existing Codebase + +**No Backend Changes Required**: + +- Existing REST API (`app/routes/api_v1.py`) is sufficient +- Existing authentication (`app/utils/api_auth.py`) works as-is +- Existing API token management (`app/services/api_token_service.py`) supports the apps +- Existing endpoints cover all required functionality + +**Optional Enhancements** (Future): + +- WebSocket endpoint for real-time updates +- Bulk sync endpoint for offline operations +- Push notification service (FCM/APNS) integration + +## Success Metrics + +- Mobile apps support all core time tracking features +- Desktop app is lightweight (<50MB, <2s startup) +- Both apps work seamlessly offline +- API integration is stable and reliable +- User experience is intuitive and responsive +- Apps can be built and distributed for all target platforms \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 56e685b7..a2437c36 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -722,6 +722,13 @@ def create_app(config=None): if (not secret) or (secret in placeholder_values) or (isinstance(secret, str) and len(secret) < 32): app.logger.error("Invalid SECRET_KEY configured in production; refusing to start") raise RuntimeError("Invalid SECRET_KEY in production") + + # Check for debug mode in production - this is a security risk + flask_debug = app.config.get("FLASK_DEBUG", False) + if flask_debug or app.debug: + app.logger.error("Debug mode is enabled in production; refusing to start") + app.logger.error("Debug mode can leak sensitive information and should never be enabled in production") + raise RuntimeError("Debug mode cannot be enabled in production") # Apply security headers and a basic CSP @app.after_request diff --git a/app/integrations/google_calendar.py b/app/integrations/google_calendar.py index cb13b57f..1339abe9 100644 --- a/app/integrations/google_calendar.py +++ b/app/integrations/google_calendar.py @@ -234,7 +234,11 @@ class GoogleCalendarConnector(BaseConnector): sync_direction = self.integration.config.get("sync_direction", "time_tracker_to_calendar") calendar_id = self.integration.config.get("calendar_id", "primary") - synced_count = 0 + # Initialize counters for both sync directions + time_tracker_to_calendar_count = 0 + imported = 0 + skipped = 0 + skipped_reasons = {"time_tracker_created": 0, "already_imported": 0, "invalid_time": 0, "other": 0} errors = [] # Sync TimeTracker → Google Calendar @@ -292,14 +296,14 @@ class GoogleCalendarConnector(BaseConnector): ) db.session.add(link) - synced_count += 1 + time_tracker_to_calendar_count += 1 logger.debug(f"Synced time entry {entry.id} to Google Calendar") except Exception as e: error_msg = f"Error syncing entry {entry.id}: {str(e)}" errors.append(error_msg) logger.warning(f"{error_msg}", exc_info=True) - logger.info(f"TimeTracker→Calendar sync completed: synced {synced_count} time entries") + logger.info(f"TimeTracker→Calendar sync completed: synced {time_tracker_to_calendar_count} time entries") # Sync Google Calendar → TimeTracker if sync_direction in ["calendar_to_time_tracker", "bidirectional"]: @@ -326,7 +330,8 @@ class GoogleCalendarConnector(BaseConnector): events = events_result.get("items", []) logger.info(f"Fetched {len(events)} events from Google Calendar") - + + # Reset counters for calendar-to-tracker sync (already initialized above) imported = 0 skipped = 0 skipped_reasons = {"time_tracker_created": 0, "already_imported": 0, "invalid_time": 0, "other": 0} @@ -337,13 +342,16 @@ class GoogleCalendarConnector(BaseConnector): event_summary = event.get("summary", "No title") # Skip events we created (check description for marker) - if event.get("description", "").startswith("TimeTracker:"): + description = event.get("description") or "" + if description.startswith("TimeTracker:"): logger.debug(f"Skipping event {event_id} - created by TimeTracker") skipped += 1 skipped_reasons["time_tracker_created"] += 1 continue # Check if we already have this event using IntegrationExternalEventLink + from app.models.integration_external_event_link import IntegrationExternalEventLink + existing_link = IntegrationExternalEventLink.query.filter_by( integration_id=self.integration.id, external_uid=event_id @@ -355,19 +363,33 @@ class GoogleCalendarConnector(BaseConnector): skipped_reasons["already_imported"] += 1 continue - # Create time entry from calendar event - start_str = event["start"].get("dateTime", event["start"].get("date")) - end_str = event["end"].get("dateTime", event["end"].get("date")) - + # Get start and end times - handle both dateTime (timed events) and date (all-day events) + start_data = event.get("start", {}) + end_data = event.get("end", {}) + + start_str = start_data.get("dateTime") + end_str = end_data.get("dateTime") + + # Skip all-day events (they only have "date", not "dateTime") if not start_str or not end_str: - logger.warning(f"Event {event_id} missing start or end time, skipping") + logger.debug(f"Skipping all-day event {event_id} ({event_summary}) - only timed events are imported") skipped += 1 - skipped_reasons["invalid_time"] += 1 + skipped_reasons["other"] += 1 continue # Parse datetime strings (Google Calendar returns ISO format with timezone) - start_time_utc = datetime.fromisoformat(start_str.replace("Z", "+00:00")) - end_time_utc = datetime.fromisoformat(end_str.replace("Z", "+00:00")) + try: + # Handle Z suffix and convert to +00:00 for fromisoformat + start_str_normalized = start_str.replace("Z", "+00:00") + end_str_normalized = end_str.replace("Z", "+00:00") + + start_time_utc = datetime.fromisoformat(start_str_normalized) + end_time_utc = datetime.fromisoformat(end_str_normalized) + except (ValueError, AttributeError) as parse_error: + logger.warning(f"Event {event_id} has invalid datetime format: start={start_str}, end={end_str}, error={parse_error}") + skipped += 1 + skipped_reasons["invalid_time"] += 1 + continue # Ensure timezone-aware (assume UTC if naive) if start_time_utc.tzinfo is None: @@ -393,25 +415,24 @@ class GoogleCalendarConnector(BaseConnector): # Try to match project/task from event title project = None - task = None title = event_summary # Simple matching: look for project name in title - from app.models import Project, Task + from app.models import Project projects = Project.query.filter_by(user_id=self.integration.user_id, status="active").all() for p in projects: - if p.name in title: + if p and p.name and p.name in title: project = p break time_entry = TimeEntry( user_id=self.integration.user_id, project_id=project.id if project else None, - task_id=task.id if task else None, + task_id=None, # Tasks are not matched from calendar events start_time=start_time_local, end_time=end_time_local, - notes=event.get("description", ""), + notes=description, billable=False, source="auto", ) @@ -429,12 +450,12 @@ class GoogleCalendarConnector(BaseConnector): db.session.add(link) imported += 1 - synced_count += 1 logger.info(f"Imported event {event_id} ({event_summary}) as time entry {time_entry.id}") except Exception as e: error_msg = f"Error syncing calendar event {event.get('id', 'unknown')}: {str(e)}" errors.append(error_msg) logger.warning(f"{error_msg}", exc_info=True) + logger.exception(f"Full exception details for event {event.get('id', 'unknown')}") if imported > 0 or skipped > 0: logger.info(f"Calendar→TimeTracker sync: imported={imported}, skipped={skipped} (reasons: {skipped_reasons}), total_events={len(events)}") @@ -445,13 +466,42 @@ class GoogleCalendarConnector(BaseConnector): if errors: self.integration.last_error = "; ".join(errors[:3]) # Store first 3 errors - db.session.commit() + # Commit all changes in a single transaction (time entries, links, integration status) + try: + db.session.commit() + logger.info(f"Committed sync results: TimeTracker→Calendar={time_tracker_to_calendar_count}, Calendar→TimeTracker imported={imported}") + except Exception as commit_error: + db.session.rollback() + logger.error(f"Failed to commit sync results: {commit_error}", exc_info=True) + errors.append(f"Failed to commit sync: {str(commit_error)}") + return { + "success": False, + "errors": errors, + "message": f"Sync completed but failed to save results: {str(commit_error)}", + } + + # Build detailed message + message_parts = [] + if sync_direction in ["time_tracker_to_calendar", "bidirectional"]: + if time_tracker_to_calendar_count > 0: + message_parts.append(f"TimeTracker→Calendar: synced {time_tracker_to_calendar_count} items") + if sync_direction in ["calendar_to_time_tracker", "bidirectional"]: + if imported > 0: + message_parts.append(f"Calendar→TimeTracker: imported {imported} events") + if skipped > 0: + skipped_summary = ", ".join([f"{k}={v}" for k, v in skipped_reasons.items() if v > 0]) + message_parts.append(f"({skipped} skipped: {skipped_summary})") + + total_synced = time_tracker_to_calendar_count + imported + message = " | ".join(message_parts) if message_parts else f"Synced {total_synced} items" return { "success": True, - "synced_count": synced_count, + "synced_count": total_synced, + "imported": imported if sync_direction in ["calendar_to_time_tracker", "bidirectional"] else 0, + "skipped": skipped if sync_direction in ["calendar_to_time_tracker", "bidirectional"] else 0, "errors": errors, - "message": f"Synced {synced_count} items", + "message": message, } except Exception as e: diff --git a/app/routes/auth.py b/app/routes/auth.py index ff47a258..37e64b9c 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -79,9 +79,23 @@ def login(): request.headers.get("X-Forwarded-For") or request.remote_addr, ) - if not username: - log_event("auth.login_failed", reason="empty_username", auth_method=auth_method) - flash(_("Username is required"), "error") + # Validate username input + from app.utils.validation import sanitize_input + import re + try: + if not username: + raise ValueError("Username is required") + + # Sanitize username to prevent injection + username = sanitize_input(username, max_length=100) + # Additional validation: only allow safe characters for usernames + if not re.match(r'^[a-z0-9._-]+$', username): + raise ValueError("Username contains invalid characters") + if len(username) < 1 or len(username) > 100: + raise ValueError("Username must be between 1 and 100 characters") + except (ValueError, Exception) as e: + log_event("auth.login_failed", reason="invalid_username", auth_method=auth_method) + flash(_("Invalid username format"), "error") allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) return render_template( "auth/login.html", @@ -224,36 +238,8 @@ def login(): requires_password=requires_password, ) else: - # User doesn't have password set - allow them to set one if provided - if password: - # Validate password length - if len(password) < 8: - log_event( - "auth.login_failed", user_id=user.id, reason="password_too_short", auth_method=auth_method - ) - flash(_("Password must be at least 8 characters long."), "error") - allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) - return render_template( - "auth/login.html", - allow_self_register=allow_self_register, - auth_method=auth_method, - requires_password=requires_password, - ) - # Set the password and log them in - user.set_password(password) - if not safe_commit("set_initial_password", {"user_id": user.id, "username": user.username}): - current_app.logger.error("Failed to set initial password for '%s' due to DB error", user.username) - flash(_("Could not set password due to a database error. Please try again."), "error") - allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) - return render_template( - "auth/login.html", - allow_self_register=allow_self_register, - auth_method=auth_method, - requires_password=requires_password, - ) - current_app.logger.info("User '%s' set initial password during login", user.username) - flash(_("Password has been set. You are now logged in."), "success") - else: + # User doesn't have password set - require password to be provided + if not password: # No password provided - prompt user to set one log_event("auth.login_failed", user_id=user.id, reason="no_password_set", auth_method=auth_method) flash( @@ -267,9 +253,41 @@ def login(): auth_method=auth_method, requires_password=requires_password, ) + + # Password provided - validate and set it + if len(password) < 8: + log_event( + "auth.login_failed", user_id=user.id, reason="password_too_short", auth_method=auth_method + ) + flash(_("Password must be at least 8 characters long."), "error") + allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) + return render_template( + "auth/login.html", + allow_self_register=allow_self_register, + auth_method=auth_method, + requires_password=requires_password, + ) + + # Set the password and continue to login + user.set_password(password) + if not safe_commit("set_initial_password", {"user_id": user.id, "username": user.username}): + current_app.logger.error("Failed to set initial password for '%s' due to DB error", user.username) + flash(_("Could not set password due to a database error. Please try again."), "error") + allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER) + return render_template( + "auth/login.html", + allow_self_register=allow_self_register, + auth_method=auth_method, + requires_password=requires_password, + ) + current_app.logger.info("User '%s' set initial password during login", user.username) + flash(_("Password has been set. You are now logged in."), "success") + else: + # requires_password=False (AUTH_METHOD='none') - allow login without password + # This mode is for trusted environments only + pass - # For 'none' mode, no password check needed - just log in - # Log in the user + # Log in the user (password validation passed or password not required) login_user(user, remember=True) # Auto-migrate user from legacy role to new role system if needed diff --git a/app/routes/clients.py b/app/routes/clients.py index f5e973d9..b3a228d8 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -26,6 +26,15 @@ def list_clients(): status = request.args.get("status", "active") search = request.args.get("search", "").strip() + # Validate search input with length limits + from app.utils.validation import sanitize_input + if search: + # Limit search input to prevent long queries and potential DoS + search = sanitize_input(search, max_length=200) + if len(search) > 200: + flash(_("Search query is too long. Maximum 200 characters."), "warning") + search = search[:200] + query = Client.query if status == "active": query = query.filter_by(status="active") @@ -42,7 +51,11 @@ def list_clients(): pass if search: - like = f"%{search}%" + # Escape special LIKE characters to prevent SQL injection + # Note: SQLAlchemy parameterized queries already protect against SQL injection, + # but we still escape % and _ for LIKE patterns to get expected search behavior + search_escaped = search.replace('%', '\\%').replace('_', '\\_') + like = f"%{search_escaped}%" search_conditions = [ Client.name.ilike(like), Client.description.ilike(like), diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index d0a3d420..2361165c 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -259,24 +259,45 @@ def generate_report_data(config, user_id=None): start_date = filters.get("start_date") end_date = filters.get("end_date") - try: - if start_date and isinstance(start_date, str) and start_date.strip(): - start_dt = datetime.strptime(start_date.strip(), "%Y-%m-%d") - else: + # Validate and parse start_date with stricter validation + from flask import current_app + from app.utils.validation import sanitize_input + import re + + start_dt = None + end_dt = None + + if start_date and isinstance(start_date, str) and start_date.strip(): + try: + # Sanitize input and validate date format strictly + sanitized_date = sanitize_input(start_date.strip(), max_length=10) + # Validate date format: YYYY-MM-DD + if not re.match(r'^\d{4}-\d{2}-\d{2}$', sanitized_date): + raise ValueError(f"Invalid date format. Expected YYYY-MM-DD, got: {sanitized_date}") + start_dt = datetime.strptime(sanitized_date, "%Y-%m-%d") + except (ValueError, AttributeError, TypeError) as e: + current_app.logger.warning(f"Invalid start_date format: {start_date}, using default. Error: {e}") start_dt = datetime.utcnow() - timedelta(days=30) - except (ValueError, AttributeError) as e: - from flask import current_app - current_app.logger.warning(f"Invalid start_date format: {start_date}, using default") + else: start_dt = datetime.utcnow() - timedelta(days=30) - try: - if end_date and isinstance(end_date, str) and end_date.strip(): - end_dt = datetime.strptime(end_date.strip(), "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) - else: + # Validate and parse end_date with stricter validation + if end_date and isinstance(end_date, str) and end_date.strip(): + try: + # Sanitize input and validate date format strictly + sanitized_date = sanitize_input(end_date.strip(), max_length=10) + # Validate date format: YYYY-MM-DD + if not re.match(r'^\d{4}-\d{2}-\d{2}$', sanitized_date): + raise ValueError(f"Invalid date format. Expected YYYY-MM-DD, got: {sanitized_date}") + end_dt = datetime.strptime(sanitized_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + # Validate date range + if end_dt <= start_dt: + current_app.logger.warning(f"end_date must be after start_date, adjusting") + end_dt = start_dt + timedelta(days=1) + except (ValueError, AttributeError, TypeError) as e: + current_app.logger.warning(f"Invalid end_date format: {end_date}, using default. Error: {e}") end_dt = datetime.utcnow() - except (ValueError, AttributeError) as e: - from flask import current_app - current_app.logger.warning(f"Invalid end_date format: {end_date}, using default") + else: end_dt = datetime.utcnow() # Generate data based on source @@ -307,7 +328,10 @@ def generate_report_data(config, user_id=None): # Filter by user if not admin or if user_id is specified if user_id: user = User.query.get(user_id) - if not user or not user.is_admin: + if not user: + # User not found, return empty results + return {"success": False, "message": f"User {user_id} not found", "data": []} + if not user.is_admin: query = query.filter(TimeEntry.user_id == user_id) project_id = filters.get("project_id") diff --git a/app/routes/integrations.py b/app/routes/integrations.py index 748c1d21..eef2a49b 100644 --- a/app/routes/integrations.py +++ b/app/routes/integrations.py @@ -737,6 +737,26 @@ def delete_integration(integration_id): return redirect(url_for("integrations.list_integrations")) +@integrations_bp.route("/integrations//reset", methods=["POST"]) +@login_required +def reset_integration(integration_id): + """Reset an integration by removing credentials and clearing config.""" + service = IntegrationService() + integration = service.get_integration(integration_id, current_user.id if not current_user.is_admin else None, allow_admin_override=current_user.is_admin) + if not integration: + flash(_("Integration not found."), "error") + return redirect(url_for("integrations.list_integrations")) + + result = service.reset_integration(integration_id, current_user.id) + + if result["success"]: + flash(_("Integration reset successfully. You can now reconfigure it."), "success") + else: + flash(result["message"], "error") + + return redirect(url_for("integrations.view_integration", integration_id=integration_id)) + + @integrations_bp.route("/integrations//sync", methods=["POST"]) @login_required def sync_integration(integration_id): diff --git a/app/routes/invoices.py b/app/routes/invoices.py index c3fdc498..c4a7c999 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -1018,7 +1018,12 @@ def export_invoice_csv(invoice_id): output.seek(0) - filename = f"invoice_{invoice.invoice_number}.csv" + # Get invoice prefix from settings, default to "INV" + settings = Settings.get_settings() + prefix = getattr(settings, "invoice_prefix", "INV") if settings else "INV" + if not prefix: + prefix = "INV" + filename = f"{prefix}_{invoice.invoice_number}.csv" return send_file( io.BytesIO(output.getvalue().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name=filename @@ -1063,7 +1068,11 @@ def export_invoice_pdf(invoice_id): pdf_bytes = pdf_generator.generate_pdf() pdf_size_bytes = len(pdf_bytes) current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes") - filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf" + # Get invoice prefix from settings, default to "INV" + prefix = getattr(settings, "invoice_prefix", "INV") if settings else "INV" + if not prefix: + prefix = "INV" + filename = f"{prefix}_{invoice.invoice_number}_{page_size}.pdf" current_app.logger.info(f"[PDF_EXPORT] Returning PDF file - Filename: '{filename}', PageSize: '{page_size}', InvoiceID: {invoice_id}") return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename) except Exception as e: @@ -1079,7 +1088,11 @@ def export_invoice_pdf(invoice_id): pdf_bytes = pdf_generator.generate_pdf() pdf_size_bytes = len(pdf_bytes) current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes") - filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf" + # Get invoice prefix from settings, default to "INV" + prefix = getattr(settings, "invoice_prefix", "INV") if settings else "INV" + if not prefix: + prefix = "INV" + filename = f"{prefix}_{invoice.invoice_number}_{page_size}.pdf" return send_file( io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename ) diff --git a/app/routes/team_chat.py b/app/routes/team_chat.py index 2738877f..b32be4bc 100644 --- a/app/routes/team_chat.py +++ b/app/routes/team_chat.py @@ -425,28 +425,37 @@ def upload_attachment(channel_id): if file.filename == "": return jsonify({"error": _("No file selected")}), 400 - if not allowed_file(file.filename): - return jsonify({"error": _("File type not allowed")}), 400 + # Use the file upload utility for proper validation + from app.utils.file_upload import validate_file_upload + # Normalize allowed extensions to include leading dots for validation + normalized_allowed = {ext if ext.startswith('.') else '.' + ext for ext in ALLOWED_EXTENSIONS} + + is_valid, error_msg = validate_file_upload(file, allowed_extensions=normalized_allowed, max_size=MAX_FILE_SIZE) + if not is_valid: + return jsonify({"error": _(error_msg)}), 400 - # Check file size - file.seek(0, os.SEEK_END) - file_size = file.tell() - file.seek(0) - - if file_size > MAX_FILE_SIZE: - return jsonify({"error": _("File size exceeds maximum allowed size (10 MB)")}), 400 - - # Save file + # Save file - secure_filename after validation original_filename = secure_filename(file.filename) + if not original_filename: + return jsonify({"error": _("Invalid filename")}), 400 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{channel_id}_{timestamp}_{original_filename}" # Ensure upload directory exists upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER) - os.makedirs(upload_dir, exist_ok=True) + try: + os.makedirs(upload_dir, exist_ok=True) + except (OSError, IOError) as e: + current_app.logger.error(f"Failed to create upload directory {upload_dir}: {e}") + return jsonify({"error": _("Server error: Could not create upload directory")}), 500 file_path = os.path.join(upload_dir, filename) - file.save(file_path) + try: + file.save(file_path) + except (OSError, IOError) as e: + current_app.logger.error(f"Failed to save file {filename}: {e}") + return jsonify({"error": _("Server error: Could not save file")}), 500 # Return file info for message creation return jsonify( diff --git a/app/services/integration_service.py b/app/services/integration_service.py index 62c38e57..88eb6778 100644 --- a/app/services/integration_service.py +++ b/app/services/integration_service.py @@ -190,16 +190,64 @@ class IntegrationService: if not user or not user.is_admin: return {"success": False, "message": "Only administrators can delete global integrations."} + # Explicitly delete associated credentials first + credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).all() + for credential in credentials: + db.session.delete(credential) + + # Delete associated events (for cleanup, though cascade should handle it) + events = IntegrationEvent.query.filter_by(integration_id=integration_id).all() + for event in events: + db.session.delete(event) + + # Delete the integration + provider = integration.provider # Save before deletion db.session.delete(integration) + if not safe_commit("delete_integration", {"integration_id": integration_id}): return {"success": False, "message": "Could not delete integration due to a database error."} emit_event( - WebhookEvent.INTEGRATION_DELETED, {"integration_id": integration_id, "provider": integration.provider} + WebhookEvent.INTEGRATION_DELETED, {"integration_id": integration_id, "provider": provider} ) return {"success": True, "message": "Integration deleted successfully."} + def reset_integration(self, integration_id: int, user_id: Optional[int] = None) -> Dict[str, Any]: + """Reset an integration by removing credentials and clearing config.""" + integration = self.get_integration(integration_id, user_id) + if not integration: + return {"success": False, "message": "Integration not found."} + + # Only admins can reset global integrations + if integration.is_global: + from app.models import User + + user = User.query.get(user_id) if user_id else None + if not user or not user.is_admin: + return {"success": False, "message": "Only administrators can reset global integrations."} + + # Delete associated credentials + credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).all() + for credential in credentials: + db.session.delete(credential) + + # Clear config, reset status fields + integration.config = {} + integration.is_active = False + integration.last_sync_at = None + integration.last_sync_status = None + integration.last_error = None + + if not safe_commit("reset_integration", {"integration_id": integration_id}): + return {"success": False, "message": "Could not reset integration due to a database error."} + + emit_event( + WebhookEvent.INTEGRATION_UPDATED, {"integration_id": integration_id, "provider": integration.provider, "action": "reset"} + ) + + return {"success": True, "message": "Integration reset successfully. You can now reconfigure it."} + def save_credentials( self, integration_id: int, diff --git a/app/services/workflow_engine.py b/app/services/workflow_engine.py index f3d94532..05996000 100644 --- a/app/services/workflow_engine.py +++ b/app/services/workflow_engine.py @@ -8,6 +8,7 @@ from datetime import datetime from app import db from app.models.workflow import WorkflowRule, WorkflowExecution from app.models import TimeEntry, Task, Project, User +from app.utils.db import safe_commit import time import logging @@ -121,7 +122,16 @@ class WorkflowEngine: rule.last_executed_at = datetime.utcnow() rule.execution_count += 1 - db.session.commit() + # Use safe_commit for proper error handling + if not safe_commit("execute_workflow_rule", {"rule_id": rule.id, "execution_count": rule.execution_count}): + logger.error(f"Failed to commit workflow execution for rule {rule.id}") + db.session.rollback() + return { + "success": False, + "message": "Database error during workflow execution", + "results": results, + "execution_time_ms": execution_time_ms, + } return { "success": success, @@ -144,7 +154,10 @@ class WorkflowEngine: execution_time_ms=execution_time_ms, ) db.session.add(execution) - db.session.commit() + # Use safe_commit for proper error handling + if not safe_commit("execute_workflow_rule_error", {"rule_id": rule.id, "error": str(e)}): + logger.error(f"Failed to commit workflow execution error for rule {rule.id}") + db.session.rollback() return { "success": False, @@ -245,13 +258,17 @@ class WorkflowEngine: task = Task.query.get(entity_id) if task: task.status = status - db.session.commit() + if not safe_commit("workflow_update_task_status", {"task_id": entity_id, "status": status}): + db.session.rollback() + raise ValueError(f"Failed to update task {entity_id} status") return {"updated": True, "entity": "task", "id": entity_id} elif entity_type == "project": project = Project.query.get(entity_id) if project: project.status = status - db.session.commit() + if not safe_commit("workflow_update_project_status", {"project_id": entity_id, "status": status}): + db.session.rollback() + raise ValueError(f"Failed to update project {entity_id} status") return {"updated": True, "entity": "project", "id": entity_id} raise ValueError(f"Entity not found: {entity_type} {entity_id}") @@ -267,7 +284,9 @@ class WorkflowEngine: raise ValueError(f"Task not found: {task_id}") task.assigned_to = int(user_id) - db.session.commit() + if not safe_commit("workflow_assign_task", {"task_id": task_id, "user_id": user_id}): + db.session.rollback() + raise ValueError(f"Failed to assign task {task_id} to user {user_id}") return {"assigned": True, "task_id": task_id, "user_id": user_id} @@ -289,7 +308,9 @@ class WorkflowEngine: priority=action.get("priority", "medium"), ) db.session.add(task) - db.session.commit() + if not safe_commit("workflow_create_task", {"project_id": project_id, "name": name}): + db.session.rollback() + raise ValueError(f"Failed to create task in project {project_id}") return {"created": True, "task_id": task.id} @@ -308,7 +329,9 @@ class WorkflowEngine: resolved_value = WorkflowEngine._resolve_template(value, context) setattr(project, key, resolved_value) - db.session.commit() + if not safe_commit("workflow_update_project", {"project_id": project_id, "updates": list(updates.keys())}): + db.session.rollback() + raise ValueError(f"Failed to update project {project_id}") return {"updated": True, "project_id": project_id} diff --git a/app/templates/integrations/view.html b/app/templates/integrations/view.html index e1cbae76..f836245d 100644 --- a/app/templates/integrations/view.html +++ b/app/templates/integrations/view.html @@ -134,7 +134,13 @@ {% endif %} -
+ + + +
+