diff --git a/.bandit b/.bandit deleted file mode 100644 index 8e801f2..0000000 --- a/.bandit +++ /dev/null @@ -1,22 +0,0 @@ -# Bandit configuration file -# https://bandit.readthedocs.io/ - -[bandit] -# Exclude test files and migrations -exclude_dirs = ['/tests', '/migrations', '/venv', '/.venv', '/env'] - -# Tests to skip (if any) -skips = [] - -# Tests to run (empty = run all) -tests = [] - -# Severity level to report (LOW, MEDIUM, HIGH) -level = LOW - -# Confidence level to report (LOW, MEDIUM, HIGH) -confidence = LOW - -[bandit.plugins] -# Plugin configuration (if needed) - diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml deleted file mode 100644 index aad7271..0000000 --- a/.github/workflows/security-scan.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: Security Scanning - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - schedule: - # Run daily at 2 AM UTC - - cron: '0 2 * * *' - workflow_dispatch: - -permissions: - contents: read - security-events: write - actions: read - -jobs: - dependency-scan: - name: Dependency Vulnerability Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install safety bandit pip-audit - - - name: Run Safety check - run: | - pip install -r requirements.txt - safety check --json --output safety-report.json || true - - - name: Run pip-audit - run: | - pip-audit --requirement requirements.txt --format json --output pip-audit-report.json || true - - - name: Upload Safety report - if: always() - uses: actions/upload-artifact@v4 - with: - name: safety-report - path: safety-report.json - - - name: Upload pip-audit report - if: always() - uses: actions/upload-artifact@v4 - with: - name: pip-audit-report - path: pip-audit-report.json - - code-security-scan: - name: Code Security Analysis - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install Bandit - run: | - python -m pip install --upgrade pip - pip install bandit[toml] - - - name: Run Bandit security scan - run: | - bandit -r app/ -f json -o bandit-report.json || true - bandit -r app/ -f txt - - - name: Upload Bandit report - if: always() - uses: actions/upload-artifact@v4 - with: - name: bandit-report - path: bandit-report.json - - secret-scan: - name: Secret Detection - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history for better secret detection - - - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_ENABLE_SUMMARY: true - - docker-scan: - name: Docker Image Security Scan - runs-on: ubuntu-latest - if: github.event_name != 'schedule' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Build Docker image - run: | - docker build -t timetracker:security-scan . - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: 'timetracker:security-scan' - format: 'sarif' - output: 'trivy-results.sarif' - - - name: Upload Trivy results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: 'trivy-results.sarif' - - codeql-analysis: - name: CodeQL Analysis - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: python, javascript - queries: security-extended - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - - security-report: - name: Generate Security Report - runs-on: ubuntu-latest - needs: [dependency-scan, code-security-scan, secret-scan] - if: always() - - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - - - name: Generate summary - run: | - echo "# Security Scan Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Scan Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -f safety-report/safety-report.json ]; then - echo "### Dependency Vulnerabilities (Safety)" >> $GITHUB_STEP_SUMMARY - python -c "import json; data=json.load(open('safety-report/safety-report.json')); print(f'Found {len(data.get(\"vulnerabilities\", []))} vulnerabilities')" >> $GITHUB_STEP_SUMMARY || echo "No vulnerabilities found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - if [ -f bandit-report/bandit-report.json ]; then - echo "### Code Security Issues (Bandit)" >> $GITHUB_STEP_SUMMARY - python -c "import json; data=json.load(open('bandit-report/bandit-report.json')); metrics=data.get('metrics', {}); print(f'Total Issues: {sum([m.get(\"SEVERITY.HIGH\", 0) + m.get(\"SEVERITY.MEDIUM\", 0) + m.get(\"SEVERITY.LOW\", 0) for m in metrics.values()])}')" >> $GITHUB_STEP_SUMMARY || echo "Scan completed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - echo "## Actions Required" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "1. Review all vulnerability reports" >> $GITHUB_STEP_SUMMARY - echo "2. Update vulnerable dependencies" >> $GITHUB_STEP_SUMMARY - echo "3. Fix high-severity security issues" >> $GITHUB_STEP_SUMMARY - echo "4. Review and rotate any exposed secrets" >> $GITHUB_STEP_SUMMARY - diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index edbc52a..0000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,61 +0,0 @@ -# Gitleaks configuration -# https://github.com/gitleaks/gitleaks - -title = "TimeTracker Gitleaks Configuration" - -[extend] -# Use default rules -useDefault = true - -[[rules]] -id = "generic-api-key" -description = "Generic API Key" -regex = '''(?i)(api[_-]?key|apikey|api[_-]?secret)(['\"]?\s*[:=]\s*['\"]?)([a-zA-Z0-9]{32,})''' -tags = ["api", "key"] - -[[rules]] -id = "slack-token" -description = "Slack Token" -regex = '''xox[baprs]-[0-9]{12}-[0-9]{12}-[a-zA-Z0-9]{24}''' -tags = ["slack", "token"] - -[[rules]] -id = "stripe-api-key" -description = "Stripe API Key" -regex = '''sk_live_[0-9a-zA-Z]{24,}''' -tags = ["stripe", "api-key"] - -[[rules]] -id = "stripe-restricted-api-key" -description = "Stripe Restricted API Key" -regex = '''rk_live_[0-9a-zA-Z]{24,}''' -tags = ["stripe", "api-key"] - -[[rules]] -id = "aws-access-key" -description = "AWS Access Key" -regex = '''AKIA[0-9A-Z]{16}''' -tags = ["aws", "access-key"] - -[[rules]] -id = "github-pat" -description = "GitHub Personal Access Token" -regex = '''ghp_[0-9a-zA-Z]{36}''' -tags = ["github", "token"] - -[allowlist] -description = "Allowlist for false positives" -paths = [ - '''^\.env\.example$''', - '''^env\.example$''', - '''\.md$''', # Markdown files (documentation) -] - -regexes = [ - '''your-secret-key-here''', - '''your-api-key-here''', - '''example-secret''', - '''test-secret-key''', - '''dummy-key''', -] - diff --git a/ADMIN_TOOLS_IMPLEMENTATION_SUMMARY.md b/ADMIN_TOOLS_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 3478ad8..0000000 --- a/ADMIN_TOOLS_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,518 +0,0 @@ -# Admin Tools & Internal Dashboard - Implementation Summary - -**Priority:** Medium -**Status:** ✅ Complete -**Date:** 2025-10-07 - -## Overview - -Successfully implemented a comprehensive Admin Tools & Internal Dashboard feature that enables administrators to manage customers (organizations), handle subscriptions, monitor billing, and support operations. - -## ✅ Acceptance Criteria Met - -### 1. Admin can view and edit subscription quantity; changes reflect in Stripe -- ✅ Update subscription quantity (seats) through admin UI -- ✅ Changes are synchronized with Stripe in real-time -- ✅ Prorated billing supported -- ✅ Quantity changes logged in webhook events - -### 2. Support staff can view invoices, download logs, and create refunds -- ✅ View all invoices for any organization -- ✅ Access to hosted invoice URLs -- ✅ Download invoice PDFs -- ✅ Create full or partial refunds -- ✅ View and download webhook logs -- ✅ View raw webhook payloads - ---- - -## 📦 Implementation Details - -### 1. Database Models - -#### Enhanced `SubscriptionEvent` Model (`app/models/subscription_event.py`) -- **New fields:** - - `event_id`: For Stripe webhook event ID - - `raw_payload`: Raw webhook payload storage - - `stripe_customer_id`, `stripe_subscription_id`, `stripe_invoice_id`: Transaction tracking - - `stripe_charge_id`, `stripe_refund_id`: Charge and refund tracking - - `amount`, `currency`: Financial amounts - - `status`, `previous_status`: Status change tracking - - `quantity`, `previous_quantity`: Subscription seat tracking - - `notes`: Additional context -- Made `stripe_event_id` nullable to support manual events -- Updated `__init__` to accept **kwargs for flexibility -- Enhanced `to_dict()` with all new fields - -### 2. Stripe Service Extensions (`app/utils/stripe_service.py`) - -#### New Methods: -1. **Refund Management:** - - `create_refund()`: Create full or partial refunds - - `get_refunds()`: Retrieve refund history - -2. **Billing Reconciliation:** - - `sync_organization_with_stripe()`: Sync individual organization - - `check_all_organizations_sync()`: Check all organizations - - Automatic discrepancy detection and correction - - Comprehensive reporting - -3. **Updated:** - - `_log_event()`: Now uses updated SubscriptionEvent signature - -### 3. Admin Routes (`app/routes/admin.py`) - -#### Customer Management Routes: -- `GET /admin/customers`: List all organizations with billing info -- `GET /admin/customers/`: Detailed customer view -- `POST /admin/customers//subscription/quantity`: Update subscription seats -- `POST /admin/customers//subscription/cancel`: Cancel subscription -- `POST /admin/customers//subscription/reactivate`: Reactivate subscription -- `POST /admin/customers//suspend`: Suspend organization -- `POST /admin/customers//activate`: Activate organization -- `POST /admin/customers//invoice//refund`: Create refund - -#### Billing Reconciliation Routes: -- `GET /admin/billing/reconciliation`: View sync status for all organizations -- `POST /admin/billing/reconciliation//sync`: Manually sync organization - -#### Webhook Management Routes: -- `GET /admin/webhooks`: List webhook events with filtering -- `GET /admin/webhooks/`: View webhook event details -- `POST /admin/webhooks//reprocess`: Reprocess failed webhook - -### 4. Templates - -#### `templates/admin/customers.html` -- **Features:** - - List all organizations with key metrics - - Summary cards: Total orgs, active orgs, total users, paying customers - - Table with: Organization name, status, subscription, active users, invoices, last activity - - Quick links to customer detail view - - Filter and search capabilities - -#### `templates/admin/customer_detail.html` -- **Features:** - - Organization status and information - - Subscription management: - - Update quantity (seats) - - Cancel at period end or immediately - - Reactivate subscription - - Organization actions: - - Suspend/activate organization - - Members list with roles and activity - - Recent invoices with refund capability - - Payment methods display - - Recent refunds history - - Recent subscription events - - Refund modal for creating refunds - -#### `templates/admin/billing_reconciliation.html` -- **Features:** - - Summary statistics: Total, synced, discrepancies, errors - - Organization-by-organization sync status - - Discrepancy details with collapsible views - - Manual re-sync buttons - - Color-coded table rows (green=ok, yellow=discrepancies, red=errors) - - Informational panel explaining reconciliation - -#### `templates/admin/webhook_logs.html` -- **Features:** - - Paginated webhook event list - - Filter by: Event type, organization, processing status - - Table showing: Date, event type, organization, status, amount, notes - - Quick actions: View detail, reprocess failed events - - Pagination controls - - Retry count badges - -#### `templates/admin/webhook_detail.html` -- **Features:** - - Complete event information - - Processing status with error messages - - Transaction details (customer, subscription, invoice, charge, refund IDs) - - Amount and currency display - - Status change tracking - - Quantity change tracking - - Notes display - - Raw event data accordion (payload and event data) - - Reprocess button for failed events - -#### Updated `templates/admin/dashboard.html` -- **Features:** - - Added "Customer Management" section with links to: - - Manage Customers - - Billing Sync - - Webhook Logs - - Reorganized into 3-column layout - ---- - -## 🎨 UI/UX Features - -### Design Principles -- **Light, modern UI** (per user preference [[memory:7692072]]) -- **Consistent with TimeTracker** application styling -- **Bootstrap 5** based components -- **Responsive design** for all screen sizes - -### Visual Elements -- **Color-coded badges** for status (success=green, warning=yellow, danger=red) -- **Icon-driven navigation** using Font Awesome -- **Hover effects** on cards -- **Collapsible sections** for detailed information -- **Modal dialogs** for critical actions (refunds) -- **Toast notifications** for feedback -- **Empty states** with helpful messages - -### User Experience -- **Confirmation dialogs** for destructive actions -- **Inline editing** for subscription quantity -- **Real-time sync** with Stripe -- **Comprehensive filtering** on webhook logs -- **Pagination** for large datasets -- **Breadcrumb navigation** with back buttons -- **Quick actions** accessible from list views - ---- - -## 🔒 Security & Permissions - -### Access Control -- All routes protected by `@admin_required` decorator -- User must be authenticated (`@login_required`) -- User must have admin role (`current_user.is_admin`) - -### Rate Limiting -- Update subscription quantity: 10 requests/minute -- Cancel/reactivate subscription: 5 requests/minute -- Suspend/activate organization: 5 requests/minute -- Create refund: 3 requests/minute -- Manual sync: 10 requests/minute -- Reprocess webhook: 10 requests/minute - -### Validation -- Subscription quantity must be ≥ 1 -- Refund amounts validated before processing -- Organization existence checks -- Stripe configuration checks before operations - ---- - -## 📊 Key Capabilities - -### Customer Management -1. **View all customers** with comprehensive metrics -2. **Drill down** into individual customers -3. **Monitor subscription status** and health -4. **Track member activity** and last login -5. **View invoice history** - -### Subscription Management -1. **Update seats** in real-time (syncs to Stripe) -2. **Cancel subscriptions**: - - At period end (graceful) - - Immediately (instant) -3. **Reactivate** cancelled subscriptions -4. **View subscription history** through events - -### Account Operations -1. **Suspend organizations** (with reason tracking) -2. **Reactivate organizations** (restore access) -3. **Track organization status** changes -4. **Audit trail** via subscription events - -### Billing & Revenue -1. **View invoices** for any customer -2. **Create refunds** (full or partial) -3. **Track payment methods** -4. **Monitor refund history** -5. **Access Stripe customer portal** - -### Billing Reconciliation -1. **Automatic sync checking** between Stripe and local DB -2. **Discrepancy detection**: - - Subscription status mismatches - - Quantity differences - - Missing subscriptions - - Billing cycle inconsistencies -3. **Automatic correction** when discrepancies found -4. **Manual re-sync** capability -5. **Comprehensive reporting** - -### Webhook Management -1. **View all webhook events** with filtering -2. **Monitor processing status** -3. **View raw payloads** for debugging -4. **Reprocess failed events** -5. **Track retry attempts** -6. **Filter by**: - - Event type - - Organization - - Processing status - ---- - -## 🧪 Testing Recommendations - -### Manual Testing Checklist -1. **Customer List:** - - [ ] View all organizations - - [ ] Verify counts are accurate - - [ ] Check last activity dates - - [ ] Navigate to customer detail - -2. **Customer Detail:** - - [ ] View organization info - - [ ] Update subscription quantity - - [ ] Cancel subscription - - [ ] Reactivate subscription - - [ ] Suspend organization - - [ ] Activate organization - - [ ] View members list - - [ ] View invoices - - [ ] Create refund - -3. **Billing Reconciliation:** - - [ ] View sync status for all orgs - - [ ] Manually trigger sync - - [ ] Verify discrepancy detection - - [ ] Check auto-correction - -4. **Webhook Logs:** - - [ ] View event list - - [ ] Filter by event type - - [ ] Filter by organization - - [ ] Filter by status - - [ ] View event detail - - [ ] Reprocess failed event - -### Integration Testing -1. **Stripe Integration:** - - [ ] Quantity update reflects in Stripe dashboard - - [ ] Cancellation syncs to Stripe - - [ ] Reactivation syncs to Stripe - - [ ] Refund appears in Stripe - - [ ] Webhook signature verification works - -2. **Database Integrity:** - - [ ] Events are logged correctly - - [ ] Organization status updates persist - - [ ] Subscription changes are tracked - - [ ] No race conditions - -3. **Error Handling:** - - [ ] Stripe API errors handled gracefully - - [ ] Invalid inputs rejected - - [ ] Rate limits enforced - - [ ] User feedback clear - ---- - -## 📝 Usage Examples - -### Update Subscription Quantity -``` -1. Navigate to Admin → Customers -2. Click "View" on desired organization -3. In Subscription Management section: - - Enter new quantity in "Seats" field - - Click "Update" -4. Confirmation message shows old → new quantity -5. Change reflected in Stripe immediately -``` - -### Create Refund -``` -1. Navigate to customer detail page -2. Scroll to "Recent Invoices" section -3. Click refund button (undo icon) on desired invoice -4. In refund modal: - - Enter amount (or leave empty for full refund) - - Select reason - - Click "Create Refund" -5. Refund processed in Stripe -6. Event logged in subscription events -``` - -### Suspend Organization -``` -1. Navigate to customer detail page -2. In "Organization Status" card: - - Click "Suspend Organization" - - Confirm action -3. Organization status changes to "Suspended" -4. Members lose access -5. Event logged with reason -``` - -### Check Billing Sync -``` -1. Navigate to Admin → Billing Sync -2. View summary: Total, Synced, Discrepancies, Errors -3. Review organization-by-organization results -4. Click "View Details" on orgs with discrepancies -5. Click "Re-sync" to manually trigger sync -6. Discrepancies auto-corrected -``` - ---- - -## 🔄 Integration Points - -### Stripe Webhooks -The billing routes (`app/routes/billing.py`) handle incoming webhooks: -- `invoice.paid` -- `invoice.payment_failed` -- `invoice.payment_action_required` -- `customer.subscription.created` -- `customer.subscription.updated` -- `customer.subscription.deleted` -- `customer.subscription.trial_will_end` - -All webhook events are logged to `subscription_events` table and viewable in admin dashboard. - -### Organization Model -Integrates with existing `Organization` model fields: -- `stripe_customer_id` -- `stripe_subscription_id` -- `stripe_subscription_status` -- `subscription_quantity` -- `status` (for suspension) - -### Membership Model -Displays organization members with: -- User information -- Roles -- Status -- Last activity - ---- - -## 📈 Benefits - -### For Support Staff -1. **Self-service tools** for common operations -2. **Quick access** to customer info -3. **Audit trail** for all actions -4. **Reduced Stripe dashboard dependence** - -### For Administrators -1. **Centralized management** interface -2. **Real-time sync monitoring** -3. **Billing health visibility** -4. **Webhook debugging** capabilities - -### For Business -1. **Revenue visibility** across all customers -2. **Subscription metrics** at a glance -3. **Refund tracking** and reporting -4. **Billing issue** early detection - ---- - -## 🚀 Future Enhancements - -### Potential Additions -1. **Revenue analytics dashboard** - - MRR (Monthly Recurring Revenue) - - Churn rate - - Revenue graphs - -2. **Bulk operations** - - Bulk suspend/activate - - Bulk subscription updates - - CSV export - -3. **Automated dunning** - - Email sequences for failed payments - - Grace periods - - Auto-suspension policies - -4. **Customer notifications** - - Email when subscription changed - - Notification for suspensions - - Billing issue alerts - -5. **Advanced search** - - Full-text search on organizations - - Filter by subscription status - - Date range filters - -6. **Reports** - - PDF reports for billing - - Monthly revenue summaries - - Subscription trends - ---- - -## 📁 Files Created/Modified - -### New Files Created -- `templates/admin/customers.html` - Customer list view -- `templates/admin/customer_detail.html` - Customer detail view -- `templates/admin/billing_reconciliation.html` - Billing sync view -- `templates/admin/webhook_logs.html` - Webhook list view -- `templates/admin/webhook_detail.html` - Webhook detail view -- `ADMIN_TOOLS_IMPLEMENTATION_SUMMARY.md` - This document - -### Modified Files -- `app/models/subscription_event.py` - Enhanced with new fields and methods -- `app/utils/stripe_service.py` - Added refund and sync methods -- `app/routes/admin.py` - Added ~400 lines of new routes -- `templates/admin/dashboard.html` - Added customer management section - ---- - -## 🎯 Acceptance Criteria Review - -### ✅ Requirement 1: Admin Dashboard -- [x] Customer list with organizations -- [x] Subscription status display -- [x] Invoice access -- [x] Active user counts -- [x] Last login tracking -- [x] Ability to change subscription (seats) -- [x] Ability to pause/cancel subscriptions -- [x] Ability to suspend/reactivate accounts -- [x] Logs for billing events -- [x] Webhook history - -### ✅ Requirement 2: Billing Reconciliation -- [x] Stripe → local DB sync health check -- [x] Discrepancy detection -- [x] Automatic correction -- [x] Manual re-sync capability -- [x] Comprehensive reporting - -### ✅ Acceptance Criteria 1 -**"Admin can view and edit subscription quantity; changes reflect in Stripe"** -- [x] View current subscription quantity -- [x] Edit subscription quantity via form -- [x] Changes sync to Stripe in real-time -- [x] Confirmation message with before/after values -- [x] Changes logged in subscription events - -### ✅ Acceptance Criteria 2 -**"Support staff can view invoices, download logs, and create refunds"** -- [x] View invoices for any organization -- [x] Access hosted invoice URLs (can download) -- [x] View webhook logs (downloadable via browser) -- [x] Create full refunds -- [x] Create partial refunds -- [x] View refund history - ---- - -## 🎉 Summary - -The Admin Tools & Internal Dashboard implementation is **complete** and provides a comprehensive solution for managing customers, subscriptions, and billing operations. The system includes: - -- **5 new templates** with modern, responsive UI -- **14 new admin routes** for complete CRUD operations -- **Enhanced database model** with comprehensive event tracking -- **Extended Stripe service** with refund and sync capabilities -- **Built-in rate limiting** and security -- **Comprehensive error handling** and user feedback - -All acceptance criteria have been met, and the system is ready for testing and deployment. - diff --git a/ADMIN_TOOLS_QUICK_START.md b/ADMIN_TOOLS_QUICK_START.md deleted file mode 100644 index d66ef53..0000000 --- a/ADMIN_TOOLS_QUICK_START.md +++ /dev/null @@ -1,378 +0,0 @@ -# Admin Tools & Internal Dashboard - Quick Start Guide - -## 🚀 Getting Started - -The Admin Tools & Internal Dashboard provides a comprehensive interface for managing customers, subscriptions, billing, and support operations. - ---- - -## 🔑 Access - -### Prerequisites -- Must be logged in as a user with **admin role** (`user.is_admin == True`) -- Stripe must be configured (for billing features) - -### URL Access Points -- **Main Admin Dashboard**: `/admin` -- **Customer Management**: `/admin/customers` -- **Billing Reconciliation**: `/admin/billing/reconciliation` -- **Webhook Logs**: `/admin/webhooks` - ---- - -## 📊 Main Features - -### 1. Customer Management (`/admin/customers`) - -View all organizations with key metrics at a glance. - -**What you see:** -- Total organizations -- Active organizations -- Total active users -- Paying customers -- Detailed table with subscription status, users, invoices, last activity - -**Actions:** -- Click **"View"** on any organization to see detailed information - ---- - -### 2. Customer Detail (`/admin/customers/`) - -Comprehensive view of a single customer/organization. - -#### Organization Status Section -- View: Status, created date, active members, contact email -- **Actions:** - - **Suspend Organization**: Temporarily disable access (with reason) - - **Activate Organization**: Restore access - -#### Subscription Section -- View: Plan, status, seats, next billing date -- **Actions:** - - **Update Seats**: Change subscription quantity (syncs to Stripe) - - **Cancel at Period End**: Schedule cancellation - - **Cancel Immediately**: Instant cancellation - - **Reactivate Subscription**: Undo scheduled cancellation - -#### Members Section -- View all organization members -- See: User, email, role, status, last activity, join date - -#### Invoices Section -- View recent invoices with amounts and status -- **Actions:** - - **View invoice** (external link to Stripe) - - **Create refund** (full or partial) - -#### Events Section -- Timeline of all billing and subscription events -- See: Date, event type, status, notes - ---- - -### 3. Billing Reconciliation (`/admin/billing/reconciliation`) - -Monitor sync health between Stripe and your local database. - -**What it checks:** -- Subscription status mismatches -- Quantity differences (seats) -- Missing subscriptions -- Billing cycle inconsistencies - -**Summary Stats:** -- Total organizations with Stripe -- Successfully synced -- Organizations with discrepancies -- Sync errors - -**Actions:** -- **View Details**: See specific discrepancies -- **Re-sync**: Manually trigger sync for an organization - -**Note:** Discrepancies are automatically corrected when detected! - ---- - -### 4. Webhook Logs (`/admin/webhooks`) - -View and manage Stripe webhook events. - -**Filters:** -- Event Type (e.g., `invoice.paid`, `subscription.updated`) -- Organization -- Processing Status (processed/pending) - -**What you see:** -- Date and time -- Event type -- Organization -- Processing status -- Amount (if applicable) -- Notes -- Retry count - -**Actions:** -- **View Detail**: See complete event information -- **Reprocess**: Retry failed webhook events - ---- - -## 🛠️ Common Tasks - -### How to Update Subscription Seats - -``` -1. Go to Admin → Customers -2. Click "View" on the organization -3. In the "Subscription" card, find the "Seats" input -4. Enter new quantity (must be ≥ 1) -5. Click "Update" -6. ✅ Confirmation shows: "Subscription updated: 5 → 10 seats" -7. Change is immediately reflected in Stripe -``` - -### How to Cancel a Subscription - -``` -Option 1: Cancel at Period End (Recommended) -1. Go to customer detail page -2. In "Subscription Management" section -3. Click "Cancel at Period End" -4. Confirm the action -5. ✅ User keeps access until billing cycle ends - -Option 2: Cancel Immediately -1. Same steps but click "Cancel Immediately" -2. Confirm the PERMANENT action -3. ⚠️ User loses access instantly -``` - -### How to Create a Refund - -``` -1. Go to customer detail page -2. Scroll to "Recent Invoices" -3. Click the refund icon (undo) on the invoice -4. In the modal: - - Amount: Leave empty for full refund, or enter partial amount - - Reason: Select from dropdown -5. Click "Create Refund" -6. ✅ Refund created in Stripe -7. Appears in "Recent Refunds" section -``` - -### How to Suspend an Organization - -``` -1. Go to customer detail page -2. In "Organization Status" card -3. Click "Suspend Organization" -4. Optionally provide a reason -5. Confirm action -6. ✅ Organization status changes to "Suspended" -7. All members lose access -8. Event is logged with reason -``` - -### How to Check Billing Sync Health - -``` -1. Go to Admin → Billing Reconciliation -2. View summary stats at the top -3. Review organization-by-organization results -4. For orgs with discrepancies: - - Click "View Details" to see what's wrong - - Discrepancies are shown with local vs. Stripe values -5. Click "Re-sync" to manually check again -6. ✅ Discrepancies are automatically corrected -``` - -### How to Investigate a Failed Webhook - -``` -1. Go to Admin → Webhook Logs -2. Filter by "Status" → "Pending" or look for red error badges -3. Click the eye icon to view details -4. In webhook detail page: - - Review error message - - Check processing status - - View raw payload for debugging -5. Click "Reprocess" to retry -6. Event is queued for reprocessing -``` - ---- - -## 🎯 Dashboard Navigation - -### From Main Admin Dashboard (`/admin`) - -The dashboard has 3 main sections: - -#### 1. Customer Management (Left) -- **Manage Customers** → Customer list -- **Billing Sync** → Reconciliation view -- **Webhook Logs** → Event logs - -#### 2. User Management (Center) -- **Manage Users** → User list -- **Create New User** → User creation form - -#### 3. System Settings (Right) -- **Configure Settings** → System settings -- **Create Backup** → Database backup - ---- - -## 🔐 Permissions & Rate Limits - -### Access Control -All admin routes require: -- ✅ Authenticated user (`@login_required`) -- ✅ Admin role (`@admin_required`) - -### Rate Limits -To prevent abuse, certain operations have rate limits: - -| Operation | Limit | -|-----------|-------| -| Update subscription quantity | 10/minute | -| Cancel/reactivate subscription | 5/minute | -| Suspend/activate organization | 5/minute | -| Create refund | 3/minute | -| Manual sync | 10/minute | -| Reprocess webhook | 10/minute | - -If you hit a rate limit, wait 1 minute and try again. - ---- - -## 💡 Tips & Best Practices - -### Subscription Management -- ✅ **DO** use "Cancel at Period End" for graceful cancellations -- ✅ **DO** communicate with customers before suspending -- ⚠️ **CAUTION** with "Cancel Immediately" - it's permanent -- ✅ **DO** check billing reconciliation regularly - -### Refunds -- ✅ **DO** provide a reason for all refunds -- ✅ **DO** verify invoice amount before refunding -- ℹ️ Partial refunds are supported (enter amount) -- ℹ️ Leave amount empty for full refund - -### Webhook Management -- ✅ **DO** investigate failed webhooks promptly -- ✅ **DO** reprocess failed events after fixing issues -- ℹ️ Events automatically retry up to 3 times -- ℹ️ Check raw payload for debugging - -### Billing Reconciliation -- ✅ **DO** run reconciliation weekly -- ✅ **DO** investigate errors immediately -- ℹ️ Discrepancies are auto-corrected -- ℹ️ Use re-sync to force a check - ---- - -## 🐛 Troubleshooting - -### "Stripe is not configured" warning - -**Problem:** Stripe service is not initialized. - -**Solution:** -1. Check environment variables: - - `STRIPE_SECRET_KEY` - - `STRIPE_PUBLISHABLE_KEY` - - `STRIPE_WEBHOOK_SECRET` -2. Restart application -3. Verify in Settings that Stripe is configured - -### "Organization does not have a Stripe customer" error - -**Problem:** Organization not linked to Stripe. - -**Solution:** -1. Organization must create a subscription first -2. Or manually create Stripe customer -3. Link via `organization.stripe_customer_id` - -### Webhook shows "Pending" indefinitely - -**Problem:** Webhook processing failed silently. - -**Solution:** -1. View webhook detail -2. Check for processing errors -3. Review raw payload -4. Click "Reprocess" -5. Check server logs for errors - -### Sync shows discrepancies but doesn't fix them - -**Problem:** Auto-correction may have failed. - -**Solution:** -1. Click "Re-sync" to try again -2. If still failing, check server logs -3. Verify Stripe API access -4. Check organization has valid Stripe IDs - -### Can't create refund - -**Problem:** Various causes. - -**Common Solutions:** -1. Verify invoice is paid (can't refund unpaid) -2. Check refund amount ≤ invoice amount -3. Verify charge exists on invoice -4. Check Stripe API access -5. Review rate limits (3/minute) - ---- - -## 📚 Additional Resources - -- **Implementation Summary**: `ADMIN_TOOLS_IMPLEMENTATION_SUMMARY.md` -- **Stripe Documentation**: https://stripe.com/docs/api -- **Application Logs**: Check `/logs/timetracker.log` - ---- - -## 🆘 Need Help? - -### For Developers -1. Check server logs: `logs/timetracker.log` -2. Review implementation: `ADMIN_TOOLS_IMPLEMENTATION_SUMMARY.md` -3. Check Stripe dashboard for API errors -4. Review webhook signatures - -### For Support Staff -1. Use webhook logs for debugging -2. Check billing reconciliation first -3. Verify organization status -4. Document issues for developers - ---- - -## ✅ Quick Reference - -| Task | URL | Action | -|------|-----|--------| -| View all customers | `/admin/customers` | Click "View" | -| Update seats | Customer detail | Enter quantity → Update | -| Cancel subscription | Customer detail | Cancel at Period End / Immediately | -| Create refund | Customer detail | Click refund icon on invoice | -| Suspend org | Customer detail | Click "Suspend Organization" | -| Check sync | `/admin/billing/reconciliation` | Review and re-sync | -| View webhooks | `/admin/webhooks` | Filter and investigate | -| Reprocess webhook | Webhook detail | Click "Reprocess" | - ---- - -**Enjoy managing your customers efficiently! 🚀** - diff --git a/AUTH_IMPLEMENTATION_GUIDE.md b/AUTH_IMPLEMENTATION_GUIDE.md deleted file mode 100644 index c13f049..0000000 --- a/AUTH_IMPLEMENTATION_GUIDE.md +++ /dev/null @@ -1,609 +0,0 @@ -# Authentication & User/Team Management Implementation Guide - -## Overview - -This document describes the comprehensive authentication and user/team management system implemented for TimeTracker. The system supports secure user authentication, team collaboration, role-based access control, and billing integration. - -## Features Implemented - -### ✅ 1. User Authentication - -**Password-Based Authentication:** -- Secure password hashing using PBKDF2-SHA256 -- Minimum 8-character password requirement -- Support for both username and email login -- Password reset via email tokens -- Email verification for new accounts - -**JWT Token Support:** -- Access tokens (15-minute expiry) for API authentication -- Refresh tokens (30-day expiry) for token renewal -- Device tracking for session management -- Token revocation support - -**OIDC/SSO Support:** -- Maintained existing OIDC integration -- Works alongside password authentication -- Configurable via `AUTH_METHOD` environment variable - -### ✅ 2. User Registration & Signup - -**Self-Registration:** -- Email + password signup flow -- Automatic organization creation for new users -- Email verification workflow -- Configurable via `ALLOW_SELF_REGISTER` setting - -**Features:** -- Username uniqueness validation -- Email uniqueness validation -- Password confirmation -- Optional full name field - -### ✅ 3. Two-Factor Authentication (2FA) - -**TOTP-Based 2FA:** -- QR code generation for authenticator app setup -- Support for Google Authenticator, Authy, Microsoft Authenticator, etc. -- 6-digit verification codes -- Configurable time window for code validation - -**Backup Codes:** -- 10 single-use backup codes generated on 2FA setup -- Hashed storage for security -- Can be used if authenticator unavailable - -**2FA Workflow:** -- User enables 2FA in account settings -- Scans QR code with authenticator app -- Verifies with initial code -- Receives backup codes -- Future logins require TOTP code after password - -### ✅ 4. Organization Invitations - -**Invitation Flow:** -- Admins can invite users by email -- Invitation tokens with 7-day expiry -- Email notifications with invitation links -- Role assignment during invitation (admin/member/viewer) - -**New User Acceptance:** -- Invited users without accounts can sign up via invitation -- Email pre-verified through invitation -- Automatic organization membership upon acceptance - -**Existing User Acceptance:** -- One-click acceptance for existing users -- Multi-organization support -- User can belong to multiple organizations - -**Seat Management:** -- Organization user limits enforced -- Seat count updated on invitation acceptance -- Seat decremented on user removal -- Configurable via `max_users` field - -### ✅ 5. Role-Based Access Control - -**Three Role Types:** - -1. **Admin:** - - Full organization access - - Can invite/remove members - - Can manage projects - - Can edit all data - - Can change organization settings - -2. **Member:** - - Can view organization data - - Can create and edit projects - - Can track time - - Cannot manage other members - -3. **Viewer:** - - Read-only access - - Can view projects and reports - - Cannot edit data - - Cannot manage anything - -**Permission Decorators:** -```python -from app.utils.permissions import ( - login_required, - admin_required, - organization_member_required, - organization_admin_required, - can_edit_data, - require_permission -) - -# Example usage: -@app.route('/admin/dashboard') -@admin_required -def admin_dashboard(): - pass - -@app.route('/org//projects') -@organization_member_required -def view_projects(org_slug, organization): - # organization object automatically injected - pass -``` - -### ✅ 6. Account Settings - -**Profile Management:** -- Update full name -- Change preferred language -- Update theme preference -- View account information - -**Email Management:** -- Change email address -- Email verification required -- Password confirmation for security - -**Password Management:** -- Change password -- Current password verification required -- All other sessions logged out on password change - -**Session Management:** -- View active devices/sessions -- Session details (IP address, last active time, device name) -- Revoke individual sessions -- See which device is current - -### ✅ 7. Stripe Integration - -**Organization Fields:** -- `stripe_customer_id`: Stripe customer identifier -- `stripe_subscription_id`: Active subscription ID -- `stripe_subscription_status`: Subscription status -- `trial_ends_at`: Trial period end date -- `subscription_ends_at`: Subscription end date - -**Billing Features Ready:** -- Customer creation on organization setup -- Subscription management hooks -- Seat-based billing support -- Trial period tracking - -## Database Schema - -### New Tables - -**password_reset_tokens:** -- Token-based password reset -- IP address tracking -- 24-hour expiry -- One-time use enforcement - -**email_verification_tokens:** -- Email verification for new signups -- Email change verification -- 48-hour expiry -- One-time use - -**refresh_tokens:** -- JWT refresh token storage -- Device tracking -- 30-day default expiry -- Revocation support - -### Updated Tables - -**users:** -- `password_hash`: Bcrypt password hash -- `email_verified`: Email verification status -- `totp_secret`: 2FA secret key -- `totp_enabled`: 2FA enabled flag -- `backup_codes`: JSON array of backup codes - -**organizations:** -- `stripe_customer_id`: Stripe customer ID -- `stripe_subscription_id`: Subscription ID -- `stripe_subscription_status`: Subscription status -- `trial_ends_at`: Trial end date -- `subscription_ends_at`: Subscription end date - -**memberships:** -- Existing fields support invitation flow -- `status`: 'active', 'invited', 'suspended', 'removed' -- `invitation_token`: Unique invitation token -- `invited_by`: ID of inviter -- `invited_at`: Invitation timestamp - -## API Endpoints - -### Authentication APIs - -**POST /api/auth/token** -```json -// Request -{ - "username": "john", - "password": "mypassword", - "totp_token": "123456" // Required if 2FA enabled -} - -// Response -{ - "access_token": "eyJ...", - "refresh_token": "abc...", - "token_type": "Bearer", - "expires_in": 900, - "user": { ... } -} -``` - -**POST /api/auth/refresh** -```json -// Request -{ - "refresh_token": "abc..." -} - -// Response -{ - "access_token": "eyJ...", - "refresh_token": "abc...", - "token_type": "Bearer", - "expires_in": 900 -} -``` - -**POST /api/auth/logout** -```json -// Request -{ - "refresh_token": "abc..." -} - -// Response -{ - "message": "Logged out successfully" -} -``` - -### Using JWT in API Requests - -```bash -# Include in Authorization header -curl -H "Authorization: Bearer " \ - https://api.example.com/api/endpoint -``` - -## Web Routes - -### Public Routes -- `GET/POST /signup` - User registration -- `GET/POST /login` - User login -- `GET/POST /forgot-password` - Password reset request -- `GET/POST /reset-password/` - Reset password -- `GET /verify-email/` - Email verification -- `GET/POST /accept-invitation/` - Accept org invitation -- `GET/POST /2fa/verify` - 2FA verification during login - -### Protected Routes -- `GET /settings` - Account settings page -- `GET/POST /settings/change-email` - Change email -- `POST /settings/change-password` - Change password -- `GET/POST /settings/2fa/enable` - Enable 2FA -- `POST /settings/2fa/disable` - Disable 2FA -- `POST /settings/sessions//revoke` - Revoke session -- `POST /invite` - Send organization invitation (admin only) - -## Environment Variables - -Add these to your `.env` file: - -```bash -# Email Configuration (required for invitations and password reset) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@gmail.com -SMTP_PASSWORD=your-app-password -SMTP_USE_TLS=true -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker - -# Self-registration (default: true) -ALLOW_SELF_REGISTER=true - -# Session configuration -PERMANENT_SESSION_LIFETIME=86400 # 24 hours -REMEMBER_COOKIE_DAYS=365 - -# Strong secret key for production -SECRET_KEY=your-very-long-random-secret-key-here -``` - -## Usage Examples - -### 1. Sign Up Flow - -```python -# User fills out signup form -# POST to /signup with: -# - username -# - email -# - password -# - password_confirm -# - full_name (optional) - -# System: -# - Creates user account -# - Creates default organization -# - Adds user as admin of organization -# - Sends verification email -# - Logs user in -# - Redirects to dashboard -``` - -### 2. Invitation Flow - -```python -# Admin invites user -# POST to /invite with: -# - email -# - role (admin/member/viewer) -# - organization context - -# System sends email with invitation link - -# New user clicks link: -# - Taken to /accept-invitation/ -# - Fills out password and name -# - Account created and activated -# - Membership accepted -# - Redirected to dashboard - -# Existing user clicks link: -# - Taken to /accept-invitation/ -# - Membership automatically accepted -# - Redirected to login or dashboard -``` - -### 3. Password Reset Flow - -```python -# User requests reset -# POST to /forgot-password with email - -# System: -# - Creates reset token -# - Sends email with reset link - -# User clicks link: -# - Taken to /reset-password/ -# - Enters new password -# - Password updated -# - All sessions revoked -# - Redirected to login -``` - -### 4. 2FA Setup Flow - -```python -# User enables 2FA -# GET /settings/2fa/enable - -# System: -# - Generates TOTP secret -# - Creates QR code -# - Shows QR code to user - -# User scans QR code -# Enters verification code -# POST to /settings/2fa/enable with code - -# System: -# - Verifies code -# - Enables 2FA -# - Generates backup codes -# - Shows backup codes to user -``` - -### 5. API Authentication Flow - -```python -# Get tokens -access_token, refresh_token = get_tokens(username, password) - -# Make API requests -response = requests.get( - 'https://api.example.com/api/projects', - headers={'Authorization': f'Bearer {access_token}'} -) - -# Refresh when expired -new_access_token = refresh_access_token(refresh_token) - -# Logout -revoke_refresh_token(refresh_token) -``` - -## Security Features - -1. **Password Security:** - - PBKDF2-SHA256 hashing - - Minimum length enforcement - - No password stored in plain text - -2. **Token Security:** - - Cryptographically secure random tokens - - Short expiry times - - One-time use for reset/verification tokens - - Token revocation support - -3. **Session Security:** - - HTTP-only cookies - - SameSite protection - - Secure flag in production - - Session timeout - -4. **Rate Limiting:** - - Login attempts limited - - Password reset limited - - Signup limited - - Prevents brute force attacks - -5. **2FA Security:** - - TOTP standard (RFC 6238) - - Backup codes hashed - - Time-based validation - - Resistant to replay attacks - -## Migration - -Run the migration to add all new fields and tables: - -```bash -# Using Alembic -flask db upgrade - -# Or manually -psql -U timetracker -d timetracker < migrations/versions/019_add_auth_features.py -``` - -## Testing - -### Test User Authentication - -```python -from app.models import User -from app import db - -# Create test user -user = User(username='testuser', email='test@example.com') -user.set_password('testpassword123') -db.session.add(user) -db.session.commit() - -# Test password verification -assert user.check_password('testpassword123') -assert not user.check_password('wrongpassword') -``` - -### Test 2FA - -```python -from app.utils.totp import generate_totp_secret, verify_totp_token, get_current_totp_token - -# Setup 2FA -secret = generate_totp_secret() -user.totp_secret = secret -user.totp_enabled = True -db.session.commit() - -# Get current token -token = get_current_totp_token(secret) - -# Verify token -assert user.verify_totp(token) -``` - -### Test Invitations - -```python -from app.models import Membership - -# Create invitation -membership = Membership( - user_id=invitee.id, - organization_id=org.id, - role='member', - status='invited', - invited_by=admin.id -) -db.session.add(membership) -db.session.commit() - -# Accept invitation -membership.accept_invitation() - -assert membership.status == 'active' -assert membership.is_active -``` - -## Acceptance Criteria - -✅ **Signup + login + invite flows work end-to-end** -- Self-registration with email/password ✓ -- Email verification ✓ -- Login with username or email ✓ -- Password authentication ✓ -- 2FA verification ✓ -- Invitation creation and acceptance ✓ - -✅ **Seats are incremented on acceptance; seats decrement on removal** -- Seat counting via `Organization.member_count` ✓ -- Limit checking via `Organization.has_reached_user_limit` ✓ -- Enforced in invitation route ✓ -- Membership status tracking ✓ - -✅ **Role permissions enforced (admins can invite, members cannot)** -- Permission decorators implemented ✓ -- `organization_admin_required` decorator ✓ -- Role-based access control ✓ -- Membership permission checks ✓ - -## Additional Features Implemented - -Beyond the original requirements: - -1. **JWT API Authentication** - Full token-based API access -2. **Session Management** - View and revoke active sessions -3. **Email Services** - Comprehensive email system -4. **Password Reset** - Secure token-based reset -5. **Email Verification** - Verify email addresses -6. **2FA with Backup Codes** - Complete TOTP implementation -7. **Account Settings** - Full profile management -8. **Stripe Integration Fields** - Ready for billing -9. **Multi-Organization Support** - Users can join multiple orgs -10. **Device Tracking** - Track where users are logged in - -## Next Steps - -1. **Stripe Integration:** - - Implement webhook handlers - - Add subscription creation - - Handle payment failures - - Implement seat-based pricing - -2. **Email Templates:** - - Create branded HTML email templates - - Add logo and styling - - Localization support - -3. **Admin Dashboard:** - - User management interface - - Organization management - - Billing overview - - Analytics - -4. **Rate Limiting:** - - Configure production limits - - Add Redis backend for distributed rate limiting - - Monitor and adjust limits - -5. **Testing:** - - Unit tests for all auth functions - - Integration tests for flows - - Security testing - - Load testing - -## Support - -For questions or issues: -- Check the logs: `logs/timetracker.log` -- Review environment variables -- Check database migrations are applied -- Verify email service configuration - -## License - -This implementation is part of the TimeTracker project. - diff --git a/AUTH_IMPLEMENTATION_SUMMARY.md b/AUTH_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index c66a59c..0000000 --- a/AUTH_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,529 +0,0 @@ -# Authentication & Team Management - Implementation Summary - -## ✅ Implementation Complete - -All requirements from the high-priority "Auth & User / Team Management" feature have been successfully implemented. - ---- - -## 📊 Acceptance Criteria Status - -### ✅ Signup + login + invite flows work end-to-end - -**Implemented:** -- ✅ User registration with email/password (`/signup`) -- ✅ Email verification workflow -- ✅ Login with username or email (`/login`) -- ✅ Password authentication with bcrypt hashing -- ✅ 2FA verification flow -- ✅ Organization invitation system -- ✅ Invitation acceptance for new and existing users -- ✅ Automatic organization creation on signup -- ✅ Default admin role assignment - -**Files:** -- `app/routes/auth.py` - Core login/logout -- `app/routes/auth_extended.py` - Signup, reset, 2FA, invitations -- `app/models/user.py` - User authentication methods -- `app/models/membership.py` - Invitation system -- `app/templates/auth/` - All UI templates - -### ✅ Seats are incremented on acceptance; seats decrement on removal - -**Implemented:** -- ✅ `Organization.member_count` property for current member count -- ✅ `Organization.has_reached_user_limit` to check limits -- ✅ Seat limit enforcement in invitation route -- ✅ Membership status tracking (active/invited/removed) -- ✅ Automatic seat management on membership changes -- ✅ `max_users` field on Organization model -- ✅ Seat-based billing integration ready - -**Files:** -- `app/models/organization.py` - Seat management logic -- `app/models/membership.py` - Status tracking -- `app/routes/auth_extended.py` - Enforcement in invitation route - -### ✅ Role permissions enforced (admins can invite, members cannot) - -**Implemented:** -- ✅ Three role types: Owner/Admin, Member, Viewer -- ✅ Permission decorators (`@admin_required`, `@organization_admin_required`, etc.) -- ✅ Role-based route protection -- ✅ Membership permission checks -- ✅ Admin-only invitation endpoint -- ✅ Role assignment during invitation -- ✅ Permission validation utilities - -**Files:** -- `app/utils/permissions.py` - Permission decorators and utilities -- `app/models/membership.py` - Role properties and checks -- `app/routes/auth_extended.py` - Protected invitation route - ---- - -## 🎯 Core Features Implemented - -### 1. User Authentication - -#### Password Authentication -- ✅ PBKDF2-SHA256 password hashing -- ✅ Minimum 8-character requirement -- ✅ Password strength validation -- ✅ Login with username or email -- ✅ "Remember me" functionality - -#### JWT Token System -- ✅ Access tokens (15-minute expiry) -- ✅ Refresh tokens (30-day expiry) -- ✅ Token generation utilities -- ✅ Token validation and verification -- ✅ Token revocation support -- ✅ Device tracking - -#### Password Reset -- ✅ Secure token generation -- ✅ Email delivery -- ✅ 24-hour token expiry -- ✅ One-time use enforcement -- ✅ IP address tracking -- ✅ Session revocation on reset - -**Key Files:** -``` -app/models/user.py # User model with password methods -app/models/password_reset.py # Password reset tokens -app/models/refresh_token.py # JWT refresh tokens -app/utils/jwt_utils.py # JWT generation/validation -app/routes/auth.py # Login/logout routes -app/routes/auth_extended.py # Password reset routes -``` - -### 2. Two-Factor Authentication - -#### TOTP Implementation -- ✅ QR code generation -- ✅ Secret key management -- ✅ 6-digit code verification -- ✅ Time-based validation (RFC 6238) -- ✅ Configurable time window - -#### Backup Codes -- ✅ 10 single-use backup codes -- ✅ Secure hashing -- ✅ Code consumption tracking -- ✅ Regeneration capability - -#### 2FA Workflow -- ✅ Setup flow with QR code -- ✅ Verification during setup -- ✅ Login verification step -- ✅ Backup code usage -- ✅ Enable/disable functionality - -**Key Files:** -``` -app/utils/totp.py # TOTP utilities -app/routes/auth_extended.py # 2FA routes -app/templates/auth/enable_2fa.html # Setup UI -app/templates/auth/verify_2fa.html # Login verification UI -``` - -### 3. Organization Invitations - -#### Invitation System -- ✅ Email-based invitations -- ✅ Unique invitation tokens -- ✅ 7-day token expiry -- ✅ Role assignment (admin/member/viewer) -- ✅ Admin-only invitation creation - -#### Acceptance Flow -- ✅ New user signup via invitation -- ✅ Existing user one-click acceptance -- ✅ Email pre-verification -- ✅ Automatic membership activation -- ✅ Organization assignment - -#### Seat Management -- ✅ User limit enforcement -- ✅ Seat count tracking -- ✅ Billing integration ready -- ✅ Status-based filtering - -**Key Files:** -``` -app/models/membership.py # Invitation logic -app/routes/auth_extended.py # Invitation routes -app/utils/email_service.py # Invitation emails -app/templates/auth/accept_invitation.html # Acceptance UI -``` - -### 4. Role-Based Access Control - -#### Roles Implemented -- ✅ **Admin:** Full access, can manage members -- ✅ **Member:** Can view and edit data, create projects -- ✅ **Viewer:** Read-only access - -#### Permission System -- ✅ `@login_required` - Require authentication -- ✅ `@admin_required` - Global admin only -- ✅ `@organization_member_required` - Org membership required -- ✅ `@organization_admin_required` - Org admin only -- ✅ `@can_edit_data` - Edit permission required -- ✅ `@require_permission(perm)` - Custom permission - -#### Permission Utilities -- ✅ `check_user_permission()` - Check specific permission -- ✅ `get_current_user()` - Get user from session or JWT -- ✅ `get_user_organizations()` - List user's orgs -- ✅ `get_user_role_in_organization()` - Get user's role - -**Key Files:** -``` -app/utils/permissions.py # All permission logic -app/models/membership.py # Role properties -``` - -### 5. Account Settings - -#### Profile Management -- ✅ Update full name -- ✅ Change preferred language -- ✅ Update theme preference -- ✅ View account info - -#### Email Management -- ✅ Change email address -- ✅ Email verification -- ✅ Password confirmation required - -#### Password Management -- ✅ Change password -- ✅ Current password verification -- ✅ Session revocation on change - -#### Session Management -- ✅ View active sessions -- ✅ Device/location information -- ✅ Last activity tracking -- ✅ Individual session revocation -- ✅ Revoke all sessions - -**Key Files:** -``` -app/routes/auth_extended.py # Settings routes -app/templates/auth/settings.html # Settings UI -app/models/refresh_token.py # Session tracking -``` - -### 6. Email Service - -#### Email Infrastructure -- ✅ SMTP configuration -- ✅ HTML and plain text emails -- ✅ Email templates -- ✅ Error handling -- ✅ Configuration validation - -#### Email Types -- ✅ **Password Reset:** Secure reset links -- ✅ **Invitation:** Organization invitations -- ✅ **Email Verification:** Verify email addresses -- ✅ **Welcome:** New user welcome emails - -#### Email Features -- ✅ Branded HTML templates -- ✅ Plain text fallback -- ✅ Link expiry information -- ✅ Professional formatting -- ✅ Configurable sender info - -**Key Files:** -``` -app/utils/email_service.py # Email service -app/config.py # SMTP configuration -``` - -### 7. Stripe Integration (Ready) - -#### Database Fields -- ✅ `stripe_customer_id` on Organization -- ✅ `stripe_subscription_id` on Organization -- ✅ `stripe_subscription_status` on Organization -- ✅ `trial_ends_at` on Organization -- ✅ `subscription_ends_at` on Organization - -#### Billing Features Ready -- ✅ Seat-based billing structure -- ✅ Subscription plan tiers -- ✅ User limit enforcement -- ✅ Trial period tracking -- ✅ Billing email fields - -**Ready for:** -- Stripe webhook handlers -- Subscription creation -- Payment processing -- Seat-based pricing -- Plan upgrades/downgrades - -**Key Files:** -``` -app/models/organization.py # Stripe fields -``` - ---- - -## 📁 Files Created/Modified - -### New Files (26) - -**Models:** -1. `app/models/password_reset.py` - Password reset tokens -2. `app/models/refresh_token.py` - JWT refresh tokens - -**Routes:** -3. `app/routes/auth_extended.py` - Extended auth routes (signup, reset, 2FA, invitations) - -**Utilities:** -4. `app/utils/jwt_utils.py` - JWT token generation/validation -5. `app/utils/email_service.py` - Email sending service -6. `app/utils/totp.py` - TOTP/2FA utilities -7. `app/utils/permissions.py` - Permission decorators - -**Templates:** -8. `app/templates/auth/signup.html` - Registration form -9. `app/templates/auth/forgot_password.html` - Password reset request -10. `app/templates/auth/reset_password.html` - Password reset form -11. `app/templates/auth/settings.html` - Account settings -12. `app/templates/auth/enable_2fa.html` - 2FA setup -13. `app/templates/auth/verify_2fa.html` - 2FA verification -14. `app/templates/auth/2fa_backup_codes.html` - Backup codes display -15. `app/templates/auth/accept_invitation.html` - Invitation acceptance - -**Migrations:** -16. `migrations/versions/019_add_auth_features.py` - Database migration - -**Documentation:** -17. `AUTH_IMPLEMENTATION_GUIDE.md` - Complete implementation guide -18. `AUTH_QUICK_START.md` - Quick start guide -19. `AUTH_IMPLEMENTATION_SUMMARY.md` - This file -20. `env.auth.example` - Environment variables example - -### Modified Files (9) - -1. ✏️ `app/models/user.py` - Added password, 2FA, email verification -2. ✏️ `app/models/organization.py` - Added Stripe integration fields -3. ✏️ `app/models/membership.py` - Already had invitation support -4. ✏️ `app/models/__init__.py` - Export new models -5. ✏️ `app/routes/auth.py` - Updated login to support passwords and 2FA -6. ✏️ `app/__init__.py` - Register new blueprint, initialize email service -7. ✏️ `app/config.py` - Added SMTP configuration -8. ✏️ `requirements.txt` - Added PyJWT, pyotp, qrcode -9. ✏️ `env.example` - Would add email config (file was blocked) - ---- - -## 🔢 Statistics - -- **New Python files:** 7 -- **New HTML templates:** 8 -- **Modified Python files:** 9 -- **Total lines of code added:** ~3,500 -- **New database tables:** 3 -- **New database fields:** 11 -- **API endpoints added:** 4 -- **Web routes added:** 14 -- **Permission decorators:** 6 - ---- - -## 🎯 Acceptance Criteria Verification - -| Criterion | Status | Evidence | -|-----------|--------|----------| -| Signup flow works | ✅ | `/signup` route, `User` model, templates | -| Login flow works | ✅ | `/login` route, password auth, 2FA support | -| Invite flow works | ✅ | `/invite` route, email service, acceptance route | -| JWT tokens implemented | ✅ | `jwt_utils.py`, refresh tokens, API endpoints | -| Password reset works | ✅ | Reset tokens, email service, routes | -| 2FA implemented | ✅ | TOTP support, QR codes, backup codes | -| Seats increment on acceptance | ✅ | `member_count`, invitation acceptance | -| Seats decrement on removal | ✅ | Status tracking, `member_count` calculation | -| Admin can invite | ✅ | `@organization_admin_required` decorator | -| Member cannot invite | ✅ | Permission check in invitation route | -| Roles enforced | ✅ | Permission decorators, membership checks | -| Stripe integration ready | ✅ | Customer ID, subscription fields | - -**Result: 12/12 criteria met** ✅ - ---- - -## 🚀 Ready for Production - -### Prerequisites Completed -- ✅ Database schema designed and migrated -- ✅ Security best practices implemented -- ✅ Password hashing (PBKDF2-SHA256) -- ✅ Token security (secure random, expiry, one-time use) -- ✅ Rate limiting configured -- ✅ Session security (HTTP-only, SameSite, Secure flags) -- ✅ CSRF protection maintained -- ✅ Input validation -- ✅ Error handling -- ✅ Logging - -### Production Checklist -- ⚠️ Configure SMTP for email delivery -- ⚠️ Set strong `SECRET_KEY` in production -- ⚠️ Enable `SESSION_COOKIE_SECURE=true` with HTTPS -- ⚠️ Configure rate limiting with Redis (optional) -- ⚠️ Set up monitoring for failed login attempts -- ⚠️ Customize email templates with branding -- ⚠️ Set up Stripe webhooks for billing - ---- - -## 📖 Documentation - -### User Documentation -- Quick Start Guide: `AUTH_QUICK_START.md` -- Complete Guide: `AUTH_IMPLEMENTATION_GUIDE.md` -- Environment Setup: `env.auth.example` - -### Developer Documentation -- Models: Inline docstrings -- Utilities: Comprehensive function documentation -- Routes: Endpoint descriptions -- Migration: Database schema changes - -### API Documentation -- JWT authentication endpoints -- Token refresh flow -- Error responses -- Request/response examples - ---- - -## 🎓 Key Concepts - -### Multi-Tenant Architecture -- Users can belong to multiple organizations -- Each membership has a role -- Data isolation per organization -- Row-level security ready - -### Security Features -- Password hashing with PBKDF2-SHA256 -- JWT tokens with short expiry -- 2FA with TOTP standard -- Backup codes for account recovery -- Session tracking and revocation -- Rate limiting on sensitive endpoints -- CSRF protection -- Email verification - -### Role Hierarchy -``` -Owner/Admin (full access) - ├── Can invite users - ├── Can manage members - ├── Can manage projects - ├── Can edit all data - └── Can change settings - -Member (standard access) - ├── Can view data - ├── Can edit data - ├── Can create projects - └── Can track time - -Viewer (read-only) - └── Can view data only -``` - -### Invitation Flow -``` -1. Admin invites user by email -2. System creates membership with 'invited' status -3. Email sent with invitation token -4. User clicks link in email -5. New user: create account - Existing user: accept invitation -6. Membership status → 'active' -7. Seat count incremented -8. User gains access to organization -``` - ---- - -## 🔮 Future Enhancements - -### Planned Features -- Stripe webhook handlers -- Subscription management UI -- Payment method management -- Billing history -- Usage analytics -- Audit logs -- OAuth providers (Google, GitHub) -- SSO with SAML -- Advanced 2FA (WebAuthn, hardware keys) -- IP whitelisting -- Session policies -- Password policies - -### Optimization Opportunities -- Redis for rate limiting -- Redis for session storage -- Background jobs for emails -- Email templates in database -- Localized email templates -- Branded email designs - ---- - -## ✨ Highlights - -### What Makes This Implementation Special - -1. **Comprehensive:** Covers all authentication scenarios -2. **Secure:** Industry best practices throughout -3. **Flexible:** Multiple auth methods supported -4. **Scalable:** Multi-tenant architecture -5. **User-Friendly:** Modern, clean UI -6. **Developer-Friendly:** Well-documented, reusable utilities -7. **Production-Ready:** Error handling, logging, validation -8. **Extensible:** Easy to add new features - ---- - -## 🙏 Summary - -A complete, production-ready authentication and team management system has been implemented for TimeTracker. All acceptance criteria have been met, and the system is ready for immediate use. - -**Key Achievements:** -- ✅ Secure user authentication -- ✅ Team collaboration features -- ✅ Role-based access control -- ✅ Billing integration ready -- ✅ Modern user experience -- ✅ Comprehensive documentation - -**Next Steps:** -1. Configure email service for production -2. Set up Stripe for billing -3. Deploy and test end-to-end -4. Train users on new features -5. Monitor and optimize - ---- - -**Implementation Date:** October 7, 2025 -**Status:** ✅ Complete -**Documentation:** ✅ Complete -**Testing:** Ready for QA -**Production:** Ready to deploy with configuration - diff --git a/AUTH_QUICK_START.md b/AUTH_QUICK_START.md deleted file mode 100644 index 4ac7dbf..0000000 --- a/AUTH_QUICK_START.md +++ /dev/null @@ -1,273 +0,0 @@ -# Authentication System - Quick Start Guide - -## 🚀 Overview - -Your TimeTracker application now has a comprehensive authentication and team management system with: - -- ✅ Password-based authentication -- ✅ JWT tokens for API access -- ✅ Two-factor authentication (2FA) -- ✅ Password reset via email -- ✅ Organization invitations -- ✅ Role-based access control (Admin, Member, Viewer) -- ✅ Stripe integration ready -- ✅ Session management - -## 📋 Quick Setup (5 minutes) - -### 1. Install Dependencies - -```bash -pip install -r requirements.txt -``` - -New packages added: -- `PyJWT==2.8.0` - JWT token support -- `pyotp==2.9.0` - TOTP for 2FA -- `qrcode==7.4.2` - QR code generation - -### 2. Configure Email (Required for invitations) - -Add to your `.env` file: - -```bash -# Using Gmail (recommended for testing) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@gmail.com -SMTP_PASSWORD=your-app-password -SMTP_USE_TLS=true -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker -``` - -**Gmail App Password:** https://myaccount.google.com/apppasswords - -### 3. Run Database Migration - -```bash -# Using Flask-Migrate -flask db upgrade - -# Or if you prefer Alembic directly -alembic upgrade head -``` - -### 4. Start the Application - -```bash -python app.py -``` - -## 🎯 Test the Features - -### Sign Up - -1. Go to `http://localhost:5000/signup` -2. Create an account with email and password -3. Check your email for verification link -4. Click link to verify email - -### Enable 2FA - -1. Log in and go to Settings -2. Click "Enable 2FA" -3. Scan QR code with Google Authenticator or Authy -4. Enter verification code -5. Save your backup codes! - -### Invite Team Members - -1. As an admin, go to your organization settings -2. Click "Invite Member" -3. Enter email address and select role -4. Invitee receives email with invitation link -5. They can accept and join your organization - -### Password Reset - -1. Go to login page -2. Click "Forgot Password?" -3. Enter email -4. Check email for reset link -5. Set new password - -### API Access (JWT) - -```bash -# Get tokens -curl -X POST http://localhost:5000/api/auth/token \ - -H "Content-Type: application/json" \ - -d '{ - "username": "your-username", - "password": "your-password" - }' - -# Use access token -curl http://localhost:5000/api/endpoint \ - -H "Authorization: Bearer YOUR_ACCESS_TOKEN" - -# Refresh token -curl -X POST http://localhost:5000/api/auth/refresh \ - -H "Content-Type: application/json" \ - -d '{"refresh_token": "YOUR_REFRESH_TOKEN"}' -``` - -## 🔐 Role Permissions - -| Feature | Admin | Member | Viewer | -|---------|-------|--------|--------| -| View data | ✅ | ✅ | ✅ | -| Edit data | ✅ | ✅ | ❌ | -| Create projects | ✅ | ✅ | ❌ | -| Invite users | ✅ | ❌ | ❌ | -| Remove users | ✅ | ❌ | ❌ | -| Manage billing | ✅ | ❌ | ❌ | -| Change settings | ✅ | ❌ | ❌ | - -## 📱 2FA Authenticator Apps - -Recommended apps for 2FA: -- **Google Authenticator** (iOS/Android) - Simple and reliable -- **Authy** (iOS/Android/Desktop) - Sync across devices -- **Microsoft Authenticator** (iOS/Android) - Good for Microsoft users -- **1Password** (All platforms) - If you use 1Password already - -## 🎨 UI Features - -All authentication pages have a modern, clean design with: -- Light color scheme (as preferred) -- Responsive layout (mobile-friendly) -- Clear error messages -- Loading states -- Success confirmations - -## ⚙️ Configuration Options - -### Security Settings - -```bash -# Session duration (seconds) -PERMANENT_SESSION_LIFETIME=86400 # 24 hours - -# Remember me duration (days) -REMEMBER_COOKIE_DAYS=365 - -# Allow self-registration -ALLOW_SELF_REGISTER=true - -# Admin users (auto-promoted) -ADMIN_USERNAMES=admin,superuser -``` - -### Rate Limiting - -```bash -# Prevent brute force attacks -RATELIMIT_DEFAULT=200 per day;50 per hour -``` - -## 🐛 Troubleshooting - -### Email not sending? - -1. Check SMTP credentials in `.env` -2. For Gmail, use App Password not regular password -3. Enable "Less secure app access" if needed -4. Check firewall allows port 587 -5. Check logs: `tail -f logs/timetracker.log` - -### Migration errors? - -```bash -# Check current migration status -flask db current - -# If needed, stamp the database -flask db stamp head - -# Then try upgrade again -flask db upgrade -``` - -### Can't log in? - -1. Check if email is verified (if required) -2. Verify password meets requirements (8+ chars) -3. If 2FA enabled, use authenticator code -4. Check if account is active: `SELECT * FROM users WHERE username='yourname';` - -### Invitation not working? - -1. Verify email service is configured -2. Check organization hasn't reached user limit -3. Verify inviter has admin role -4. Check invitation hasn't expired (7 days) - -## 📚 More Information - -- **Full Documentation:** `AUTH_IMPLEMENTATION_GUIDE.md` -- **Environment Variables:** `env.auth.example` -- **Database Schema:** `migrations/versions/019_add_auth_features.py` - -## 🎉 What's New? - -### For Users: -- Sign up with email and password -- Secure password reset -- Two-factor authentication -- Join multiple organizations -- Manage account settings -- View and revoke active sessions - -### For Developers: -- JWT API authentication -- Permission decorators -- Email service -- Token management -- Role-based access control -- Comprehensive utilities - -### For Admins: -- Invite users by email -- Assign roles -- Manage organization members -- Track active sessions -- Stripe integration ready - -## 🔜 Next Steps - -1. **Configure email service** for production -2. **Set strong SECRET_KEY** in production -3. **Enable HTTPS** for secure cookies -4. **Set up Stripe** for billing -5. **Customize email templates** with your branding -6. **Add Redis** for distributed rate limiting -7. **Set up monitoring** for failed login attempts - -## 💡 Tips - -- **Backup codes:** Always save your 2FA backup codes -- **Password strength:** Use a password manager -- **Email verification:** Required for password reset -- **Organization limits:** Set `max_users` for billing tiers -- **Session security:** Users can revoke suspicious sessions -- **API tokens:** Refresh tokens expire after 30 days - -## 🆘 Need Help? - -Check these files: -- `AUTH_IMPLEMENTATION_GUIDE.md` - Complete documentation -- `logs/timetracker.log` - Application logs -- Database: Check `users`, `memberships`, `organizations` tables - -Common issues: -- Email not configured → Invitations won't send -- Weak SECRET_KEY → Sessions may fail -- Migration not run → Database errors -- Rate limit hit → Wait or adjust limits - ---- - -**Congratulations!** 🎊 Your TimeTracker now has enterprise-grade authentication! - diff --git a/AUTH_README.md b/AUTH_README.md deleted file mode 100644 index 9e72b84..0000000 --- a/AUTH_README.md +++ /dev/null @@ -1,337 +0,0 @@ -# Authentication System - README - -## 🎯 Quick Reference - -### What's New -Your TimeTracker now has: -- 🔐 Password authentication -- 📧 Email invitations -- 🔑 Two-factor authentication (2FA) -- 🎫 JWT API tokens -- 👥 Team management with roles -- 💳 Stripe billing ready - -### Getting Started -1. **Install dependencies:** `pip install -r requirements.txt` -2. **Configure email:** Add SMTP settings to `.env` (see `env.auth.example`) -3. **Run migration:** `flask db upgrade` -4. **Start app:** `python app.py` -5. **Sign up:** Visit `http://localhost:5000/signup` - -### Email Configuration (Required) -```bash -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@gmail.com -SMTP_PASSWORD=your-app-password # Get from https://myaccount.google.com/apppasswords -SMTP_USE_TLS=true -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker -``` - -### New Routes - -**Authentication:** -- `GET/POST /signup` - User registration -- `GET/POST /login` - User login (now supports passwords) -- `GET/POST /forgot-password` - Request password reset -- `GET/POST /reset-password/` - Reset password -- `GET /verify-email/` - Verify email address -- `GET/POST /2fa/verify` - 2FA verification - -**Account Settings:** -- `GET /settings` - Account settings dashboard -- `POST /settings/change-email` - Change email -- `POST /settings/change-password` - Change password -- `GET/POST /settings/2fa/enable` - Enable 2FA -- `POST /settings/2fa/disable` - Disable 2FA -- `POST /settings/sessions//revoke` - Revoke session - -**Team Management:** -- `POST /invite` - Invite user to organization (admin only) -- `GET/POST /accept-invitation/` - Accept invitation - -**API (JWT):** -- `POST /api/auth/token` - Login and get tokens -- `POST /api/auth/refresh` - Refresh access token -- `POST /api/auth/logout` - Logout and revoke token - -### User Roles - -| Role | Can View | Can Edit | Can Invite | Can Manage | -|------|----------|----------|------------|------------| -| **Admin** | ✅ | ✅ | ✅ | ✅ | -| **Member** | ✅ | ✅ | ❌ | ❌ | -| **Viewer** | ✅ | ❌ | ❌ | ❌ | - -### Using Permission Decorators - -```python -from app.utils.permissions import ( - login_required, - admin_required, - organization_admin_required -) - -@app.route('/admin') -@admin_required -def admin_page(): - # Only global admins can access - pass - -@app.route('/org//invite') -@organization_admin_required -def invite_member(org_slug, organization): - # Only organization admins can access - # 'organization' object is automatically provided - pass -``` - -### API Authentication - -```python -# Get tokens -import requests - -response = requests.post('http://localhost:5000/api/auth/token', json={ - 'username': 'myuser', - 'password': 'mypassword' -}) - -tokens = response.json() -access_token = tokens['access_token'] - -# Use token -response = requests.get( - 'http://localhost:5000/api/projects', - headers={'Authorization': f'Bearer {access_token}'} -) - -# Refresh token -response = requests.post('http://localhost:5000/api/auth/refresh', json={ - 'refresh_token': tokens['refresh_token'] -}) -``` - -### Database Tables - -**New:** -- `password_reset_tokens` - Password reset tokens -- `email_verification_tokens` - Email verification tokens -- `refresh_tokens` - JWT refresh tokens - -**Updated:** -- `users` - Added password_hash, email_verified, totp_secret, totp_enabled, backup_codes -- `organizations` - Added Stripe fields (customer_id, subscription_id, etc.) -- `memberships` - Already had invitation support - -### Environment Variables - -**Required:** -```bash -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email -SMTP_PASSWORD=your-password -SMTP_FROM_EMAIL=noreply@timetracker.com -SECRET_KEY=your-strong-secret-key -``` - -**Optional:** -```bash -ALLOW_SELF_REGISTER=true -PERMANENT_SESSION_LIFETIME=86400 -REMEMBER_COOKIE_DAYS=365 -RATELIMIT_DEFAULT=200 per day;50 per hour -``` - -### Files to Review - -**📚 Documentation:** -- `AUTH_QUICK_START.md` - 5-minute setup guide -- `AUTH_IMPLEMENTATION_GUIDE.md` - Complete documentation -- `AUTH_IMPLEMENTATION_SUMMARY.md` - What was built -- `env.auth.example` - Environment variables - -**🔧 Key Code Files:** -- `app/models/user.py` - User model with auth -- `app/routes/auth.py` - Core auth routes -- `app/routes/auth_extended.py` - Extended auth features -- `app/utils/permissions.py` - Permission system -- `app/utils/jwt_utils.py` - JWT utilities -- `app/utils/email_service.py` - Email service -- `migrations/versions/019_add_auth_features.py` - Database changes - -### Common Tasks - -**Enable 2FA:** -1. Log in and go to Settings -2. Click "Enable 2FA" -3. Scan QR code with Google Authenticator -4. Enter verification code -5. Save backup codes! - -**Invite Team Member:** -1. Log in as admin -2. Go to organization settings -3. Click "Invite Member" -4. Enter email and select role -5. User receives email with link - -**Reset Password:** -1. Go to login page -2. Click "Forgot Password?" -3. Enter email -4. Check email for reset link -5. Set new password - -**Revoke Session:** -1. Go to Settings → Sessions -2. Find the session to revoke -3. Click "Revoke" - -### Troubleshooting - -**Email not sending?** -- Check SMTP credentials in `.env` -- For Gmail, use App Password -- Check port 587 is not blocked -- Look at logs: `tail -f logs/timetracker.log` - -**Can't log in?** -- Verify email if verification is enabled -- Check password is 8+ characters -- If 2FA is enabled, use authenticator code -- Try password reset - -**Invitation not working?** -- Verify email service is configured -- Check organization hasn't reached user limit -- Verify you're an admin -- Check invitation hasn't expired (7 days) - -**Migration failed?** -```bash -flask db current # Check current version -flask db upgrade # Apply migrations -``` - -### Security Notes - -- ✅ Passwords hashed with PBKDF2-SHA256 -- ✅ JWT tokens expire after 15 minutes -- ✅ Refresh tokens expire after 30 days -- ✅ Rate limiting on sensitive endpoints -- ✅ CSRF protection enabled -- ✅ Session cookies are HTTP-only -- ✅ 2FA uses TOTP standard (RFC 6238) -- ✅ Backup codes are hashed - -**For Production:** -- Set strong `SECRET_KEY` -- Enable `SESSION_COOKIE_SECURE=true` with HTTPS -- Configure production SMTP service -- Set up monitoring for failed logins -- Use Redis for rate limiting - -### Support - -**Documentation:** -- Quick Start: `AUTH_QUICK_START.md` -- Full Guide: `AUTH_IMPLEMENTATION_GUIDE.md` -- Summary: `AUTH_IMPLEMENTATION_SUMMARY.md` - -**Logs:** -- Application: `logs/timetracker.log` -- Startup: `logs/timetracker_startup.log` - -**Database:** -```sql --- Check users -SELECT id, username, email, email_verified, totp_enabled FROM users; - --- Check memberships -SELECT u.username, o.name, m.role, m.status -FROM memberships m -JOIN users u ON m.user_id = u.id -JOIN organizations o ON m.organization_id = o.id; - --- Check active tokens -SELECT user_id, device_name, last_used_at, revoked -FROM refresh_tokens -WHERE revoked = false; -``` - -### Features at a Glance - -✅ Password authentication with secure hashing -✅ JWT tokens for API access -✅ Two-factor authentication (2FA) with TOTP -✅ Backup codes for 2FA recovery -✅ Password reset via email -✅ Email verification -✅ Organization invitations -✅ Role-based access control (Admin, Member, Viewer) -✅ Session management and revocation -✅ Account settings (email, password, 2FA) -✅ Stripe integration ready -✅ Modern, responsive UI -✅ Rate limiting -✅ Comprehensive documentation - -### API Response Examples - -**Login Success:** -```json -{ - "access_token": "eyJ0eXAiOiJKV1QiLCJhbG...", - "refresh_token": "abc123...", - "token_type": "Bearer", - "expires_in": 900, - "user": { - "id": 1, - "username": "john", - "email": "john@example.com", - "role": "user" - } -} -``` - -**Error Response:** -```json -{ - "error": "Invalid credentials" -} -``` - -### Quick Commands - -```bash -# Install dependencies -pip install -r requirements.txt - -# Run migration -flask db upgrade - -# Start application -python app.py - -# Check migration status -flask db current - -# Create admin user manually (if needed) -flask shell ->>> from app.models import User ->>> from app import db ->>> user = User(username='admin', email='admin@example.com', role='admin') ->>> user.set_password('admin123') ->>> db.session.add(user) ->>> db.session.commit() -``` - ---- - -**Version:** 1.0 -**Date:** October 7, 2025 -**Status:** ✅ Production Ready (with configuration) - diff --git a/FIX_MIGRATION_NOW.md b/FIX_MIGRATION_NOW.md deleted file mode 100644 index 46babe0..0000000 --- a/FIX_MIGRATION_NOW.md +++ /dev/null @@ -1,181 +0,0 @@ -# Fix Migration Issue - Step by Step - -## The Problem - -Migration 019 references migration 018, but migration 018 isn't in your database's `alembic_version` table, even though the tables it creates (organizations, memberships) already exist. - -## Quick Fix (Choose One) - -### Option 1: Run from Docker Container (Recommended) - -```bash -# Enter your running Docker container -docker-compose exec app bash - -# Once inside the container, run: -python fix_migration_chain.py - -# Then upgrade -flask db upgrade - -# Exit container -exit -``` - -### Option 2: Manual SQL Fix (If Python script doesn't work) - -```bash -# Enter your Docker container -docker-compose exec app bash - -# Connect to PostgreSQL -psql -U timetracker -d timetracker - -# Check current version -SELECT version_num FROM alembic_version; - -# If it shows 017 or anything other than 018, update it: -UPDATE alembic_version SET version_num = '018'; - -# Verify -SELECT version_num FROM alembic_version; -# Should now show: 018 - -# Exit psql -\q - -# Now run the upgrade -flask db upgrade - -# Exit container -exit -``` - -### Option 3: Use Flask-Migrate Stamp - -```bash -# Enter container -docker-compose exec app bash - -# Stamp the database with migration 018 -flask db stamp 018 - -# Then upgrade to 019 -flask db upgrade - -# Exit -exit -``` - -## Verification - -After running one of the above, verify success: - -```bash -# Check migration version -docker-compose exec app flask db current -# Should show: 019_add_auth_features (head) - -# Check that new tables exist -docker-compose exec app psql -U timetracker -d timetracker -c "\dt" | grep -E "(password_reset|email_verification|refresh_tokens|subscription_events)" -``` - -You should see: -- `password_reset_tokens` -- `email_verification_tokens` -- `refresh_tokens` -- `subscription_events` - -## If Still Having Issues - -If the above doesn't work, the issue might be that migration 018 file has a different revision ID than expected. Let's check: - -```bash -# Check what's in migration 018 file -docker-compose exec app head -20 /app/migrations/versions/018_add_multi_tenant_support.py -``` - -Look for the line that says `revision = '...'`. It should be `'018'`. - -If it's something else (like `'018_add_multi_tenant_support'`), then update it: - -```bash -# Inside container -cd /app/migrations/versions - -# Edit the file (if vi is available) -vi 018_add_multi_tenant_support.py - -# Change: -# revision = '018_add_multi_tenant_support' -# To: -# revision = '018' -``` - -Then run `flask db upgrade` again. - -## Complete Commands (Copy-Paste) - -Here's the complete set of commands to run: - -```bash -# Stop the container if running -docker-compose down - -# Start it again -docker-compose up -d - -# Enter the container -docker-compose exec app bash - -# Inside container - try the stamp method first -flask db stamp 018 - -# Then upgrade -flask db upgrade - -# Check it worked -flask db current - -# Exit -exit - -# View logs to confirm app started -docker-compose logs -f app -``` - -## What This Does - -1. **Stamps migration 018**: Tells Alembic that migration 018 is already applied (which it is - the tables exist) -2. **Upgrades to 019**: Applies the auth features migration -3. **Creates new tables**: Adds password_reset_tokens, email_verification_tokens, refresh_tokens, subscription_events -4. **Adds new columns**: Updates users and organizations tables with auth and billing fields - -## After Migration Success - -Once migrations are complete, your app will have: -- ✅ Password authentication -- ✅ JWT tokens -- ✅ 2FA support -- ✅ Email verification -- ✅ Password reset -- ✅ Stripe billing fields -- ✅ Subscription event tracking - -Restart your app: -```bash -docker-compose restart app -``` - -## Still Stuck? - -If you're still getting errors, share the output of: - -```bash -docker-compose exec app flask db current -docker-compose exec app psql -U timetracker -d timetracker -c "SELECT version_num FROM alembic_version;" -docker-compose exec app psql -U timetracker -d timetracker -c "\dt" | head -20 -``` - -This will help me see exactly what state your database is in. - diff --git a/HTTPS_ENFORCEMENT_COMPLETE.md b/HTTPS_ENFORCEMENT_COMPLETE.md deleted file mode 100644 index c6e1a29..0000000 --- a/HTTPS_ENFORCEMENT_COMPLETE.md +++ /dev/null @@ -1,546 +0,0 @@ -# ✅ HTTPS Enforcement - Implementation Complete - -## Overview - -HTTPS enforcement has been implemented in TimeTracker to ensure all traffic is encrypted in production. - -**Implementation Date**: January 7, 2025 -**Status**: ✅ **COMPLETE** - ---- - -## What Was Implemented - -### 1. ✅ Automatic HTTPS Redirect Middleware - -**Location**: `app/__init__.py` - -**Features:** -- Automatic HTTP → HTTPS redirect (301 permanent) -- Respects `X-Forwarded-Proto` header from reverse proxy -- Skips redirect for local development -- Exempts health check endpoints -- Configurable via `FORCE_HTTPS` environment variable - -**Code:** -```python -@app.before_request -def enforce_https(): - """Redirect HTTP to HTTPS in production""" - # Skip for local development and health checks - if app.config.get('FLASK_ENV') == 'development': - return None - - # Skip for health check endpoints - if request.path in ['/_health', '/health', '/metrics']: - return None - - # Check if HTTPS enforcement is enabled - if not app.config.get('FORCE_HTTPS', True): - return None - - # Check if request is already HTTPS - if request.is_secure: - return None - - # Check X-Forwarded-Proto header (from reverse proxy) - if request.headers.get('X-Forwarded-Proto', 'http') == 'https': - return None - - # Redirect to HTTPS - url = request.url.replace('http://', 'https://', 1) - return redirect(url, code=301) -``` - -### 2. ✅ Production Configuration Hardening - -**Location**: `app/config.py` - -**Changes:** -```python -class ProductionConfig(Config): - """Production configuration""" - FLASK_DEBUG = False - - # Force HTTPS and secure cookies - FORCE_HTTPS = True - SESSION_COOKIE_SECURE = True - SESSION_COOKIE_HTTPONLY = True - REMEMBER_COOKIE_SECURE = True - PREFERRED_URL_SCHEME = 'https' -``` - -### 3. ✅ Security Headers (Already Implemented) - -**Headers Applied:** -- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` -- `X-Frame-Options: DENY` -- `X-Content-Type-Options: nosniff` -- `X-XSS-Protection: 1; mode=block` -- `Referrer-Policy: strict-origin-when-cross-origin` -- `Permissions-Policy: geolocation=(), microphone=(), camera=()` -- `Content-Security-Policy: ` - -### 4. ✅ ProxyFix Middleware (Already Implemented) - -**Location**: `app/__init__.py` - -**Purpose:** Correctly detect HTTPS when behind a reverse proxy - -```python -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) -``` - -### 5. ✅ Environment Configuration - -**Location**: `env.example` - -**Added:** -```bash -# Security settings -FORCE_HTTPS=true # Redirect HTTP to HTTPS (disable for local dev) -REMEMBER_COOKIE_SECURE=false # Set to 'true' in production with HTTPS -``` - -### 6. ✅ Comprehensive Documentation - -**Created**: `docs/HTTPS_SETUP_GUIDE.md` - -**Covers:** -- Quick start guide -- Nginx configuration with Let's Encrypt -- Apache configuration -- Cloud load balancer setup (AWS, GCP, Azure) -- Verification procedures -- Troubleshooting -- Best practices - ---- - -## How It Works - -### Flow Diagram - -``` -HTTP Request → Reverse Proxy → Application - ↓ ↓ - TLS Termination HTTPS Check - ↓ ↓ - Set Headers Redirect if HTTP - ↓ ↓ - Application ← 301 Redirect -``` - -### Request Processing - -1. **Client makes HTTP request** to `http://timetracker.com` -2. **Reverse proxy** (nginx/Apache) redirects to HTTPS -3. **Client makes HTTPS request** to `https://timetracker.com` -4. **Reverse proxy** terminates TLS and forwards to application -5. **Application checks** `X-Forwarded-Proto` header -6. **If HTTPS**, process normally -7. **If HTTP**, redirect to HTTPS (301) - -### Security Layers - -**Layer 1: Reverse Proxy** -- TLS termination -- HTTP → HTTPS redirect -- Strong cipher configuration - -**Layer 2: Application** -- Additional HTTP → HTTPS redirect (defense in depth) -- Secure cookie flags -- HSTS header - -**Layer 3: Browser** -- HSTS preload (optional) -- Remembers HTTPS preference -- Blocks mixed content - ---- - -## Configuration - -### Production Setup - -**Required environment variables:** - -```bash -# Enable HTTPS enforcement -FORCE_HTTPS=true -FLASK_ENV=production - -# Secure cookies (REQUIRED for HTTPS) -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# URL scheme -PREFERRED_URL_SCHEME=https -``` - -### Development Setup - -**Local development:** - -```bash -# Disable HTTPS enforcement -FORCE_HTTPS=false -FLASK_ENV=development - -# Allow cookies over HTTP -SESSION_COOKIE_SECURE=false -REMEMBER_COOKIE_SECURE=false -``` - -### Docker Compose - -**Production:** - -```yaml -services: - app: - environment: - - FORCE_HTTPS=true - - SESSION_COOKIE_SECURE=true - - REMEMBER_COOKIE_SECURE=true - - FLASK_ENV=production -``` - -**Development:** - -```yaml -services: - app: - environment: - - FORCE_HTTPS=false - - SESSION_COOKIE_SECURE=false - - REMEMBER_COOKIE_SECURE=false - - FLASK_ENV=development -``` - ---- - -## Deployment Checklist - -### Pre-Deployment - -- [ ] TLS certificate obtained (Let's Encrypt recommended) -- [ ] Reverse proxy configured (nginx/Apache/ALB) -- [ ] Environment variables set correctly -- [ ] `FORCE_HTTPS=true` in production -- [ ] `SESSION_COOKIE_SECURE=true` in production -- [ ] `REMEMBER_COOKIE_SECURE=true` in production - -### Post-Deployment Verification - -1. **Test HTTP redirect:** - ```bash - curl -I http://your-domain.com - # Should return 301 Moved Permanently - # Location: https://your-domain.com - ``` - -2. **Test HTTPS works:** - ```bash - curl -I https://your-domain.com - # Should return 200 OK - ``` - -3. **Verify security headers:** - ```bash - curl -I https://your-domain.com | grep -i "strict-transport-security" - # Should show: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload - ``` - -4. **Check SSL Labs:** - - Visit: https://www.ssllabs.com/ssltest/ - - Enter your domain - - Target grade: A or A+ - -5. **Check Security Headers:** - - Visit: https://securityheaders.com - - Enter your domain - - Target grade: A or A+ - -6. **Test application:** - - Open https://your-domain.com in browser - - Verify padlock icon appears - - Test login functionality - - Test WebSocket connections (if applicable) - ---- - -## Troubleshooting - -### Issue: "Too many redirects" error - -**Symptom:** Browser shows redirect loop error - -**Cause:** Both reverse proxy and application are redirecting - -**Solution 1:** Let reverse proxy handle redirect only -```bash -FORCE_HTTPS=false -``` - -**Solution 2:** Ensure reverse proxy sets correct headers -```nginx -# Nginx -proxy_set_header X-Forwarded-Proto $scheme; -``` - -### Issue: Cookies not working - -**Symptom:** Users can't log in or stay logged in - -**Cause:** Secure cookies require HTTPS - -**Solution:** Ensure both are true: -```bash -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true -``` - -### Issue: Mixed content warnings - -**Symptom:** Browser console shows mixed content errors - -**Cause:** Loading HTTP resources on HTTPS page - -**Solution:** -- Update all resource URLs to HTTPS -- Check Content-Security-Policy configuration -- Use protocol-relative URLs: `//example.com/resource.js` - -### Issue: Application behind reverse proxy not detecting HTTPS - -**Symptom:** Still getting HTTP redirects - -**Cause:** Missing `X-Forwarded-Proto` header - -**Solution:** - -**Nginx:** -```nginx -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Host $host; -proxy_set_header X-Forwarded-Port $server_port; -``` - -**Apache:** -```apache -RequestHeader set X-Forwarded-Proto "https" -``` - ---- - -## Best Practices - -### 1. Always Use HTTPS in Production - -✅ **Do:** -- Set `FORCE_HTTPS=true` -- Use secure cookies -- Obtain valid TLS certificate - -❌ **Don't:** -- Run production over HTTP -- Use self-signed certificates in production -- Disable HTTPS enforcement - -### 2. Use Let's Encrypt for Free Certificates - -✅ **Benefits:** -- Free -- Automatic renewal -- Widely trusted -- Easy setup with certbot - -```bash -sudo certbot --nginx -d your-domain.com -``` - -### 3. Enable HSTS Preload - -Add your domain to browser HSTS preload lists: - -1. Meet requirements at https://hstspreload.org -2. Submit your domain -3. Wait for inclusion (can take months) - -### 4. Monitor Certificate Expiry - -Set up monitoring: - -```bash -# Check expiry date -openssl s_client -connect your-domain.com:443 -servername your-domain.com 2>/dev/null | openssl x509 -noout -dates - -# Set up alerts 30 days before expiry -``` - -### 5. Use Strong TLS Configuration - -**Minimum:** -- TLSv1.2 and TLSv1.3 only -- Strong ciphers only -- Disable TLSv1.0 and TLSv1.1 -- Enable Perfect Forward Secrecy - -### 6. Regular Security Testing - -**Weekly:** -- Check certificate expiry - -**Monthly:** -- Run SSL Labs test -- Check security headers - -**Quarterly:** -- Review TLS configuration -- Update to latest best practices - ---- - -## Security Grades - -### Target Grades - -After proper HTTPS setup, you should achieve: - -- **SSL Labs**: A or A+ (https://www.ssllabs.com/ssltest/) -- **Security Headers**: A or A+ (https://securityheaders.com) -- **Mozilla Observatory**: A or A+ (https://observatory.mozilla.org) - -### What Each Grade Tests - -**SSL Labs:** -- TLS protocol versions -- Cipher suites -- Certificate validity -- Vulnerabilities (POODLE, BEAST, etc.) - -**Security Headers:** -- HSTS -- CSP -- X-Frame-Options -- X-Content-Type-Options -- Referrer-Policy - -**Mozilla Observatory:** -- Overall security posture -- Headers, cookies, subresource integrity -- Recommendations for improvement - ---- - -## Examples - -### Nginx Configuration (Complete) - -See `docs/HTTPS_SETUP_GUIDE.md` for complete configuration. - -Key sections: -```nginx -# HTTP → HTTPS redirect -server { - listen 80; - server_name your-domain.com; - return 301 https://$server_name$request_uri; -} - -# HTTPS server -server { - listen 443 ssl http2; - server_name your-domain.com; - - ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; - - # Proxy to application - location / { - proxy_pass http://localhost:8080; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } -} -``` - -### Docker Compose (Complete) - -```yaml -version: '3.8' - -services: - app: - image: timetracker:latest - environment: - - FORCE_HTTPS=true - - SESSION_COOKIE_SECURE=true - - REMEMBER_COOKIE_SECURE=true - - FLASK_ENV=production - - SECRET_KEY=${SECRET_KEY} - expose: - - "8080" # Don't expose publicly, only to nginx - networks: - - app-network - - nginx: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - - /etc/letsencrypt:/etc/letsencrypt:ro - networks: - - app-network - depends_on: - - app - -networks: - app-network: -``` - ---- - -## Summary - -### ✅ What You Get - -1. **Automatic HTTPS enforcement** in production -2. **Secure cookie handling** with HTTPS-only flags -3. **HSTS header** for browser-level security -4. **Flexible configuration** for dev/staging/production -5. **Reverse proxy compatibility** (nginx, Apache, ALB, etc.) -6. **Comprehensive documentation** for setup and troubleshooting - -### 🚀 Quick Start - -```bash -# 1. Update environment -FORCE_HTTPS=true -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# 2. Restart application -docker-compose restart - -# 3. Verify -curl -I http://your-domain.com # Should redirect to HTTPS -curl -I https://your-domain.com # Should return 200 OK -``` - -### 📚 Documentation - -- **Setup Guide**: `docs/HTTPS_SETUP_GUIDE.md` -- **Security Guide**: `docs/SECURITY_COMPLIANCE_README.md` -- **Quick Start**: `SECURITY_QUICK_START.md` - ---- - -**🔒 Your TimeTracker application now enforces HTTPS! 🚀** - -**Status**: ✅ Production Ready - diff --git a/IMPORT_ERROR_FIX.md b/IMPORT_ERROR_FIX.md deleted file mode 100644 index 4340cf9..0000000 --- a/IMPORT_ERROR_FIX.md +++ /dev/null @@ -1,139 +0,0 @@ -# Import Error Fix - Complete Summary - -## Problem - -The application was failing to start with an import error. The issue was: - -```python -from app.utils.email_service import send_email # ❌ This doesn't exist! -``` - -The `send_email` function doesn't exist in `email_service.py`. Instead, there's an `email_service` singleton instance. - -## What Was Fixed - -### 1. Fixed Import in `app/routes/billing.py` - -**Before:** -```python -from app.utils.email_service import send_email # ❌ ImportError! -``` - -**After:** -```python -from app.utils.email_service import email_service # ✅ Correct import -``` - -### 2. Replaced send_email() Calls - -The code was calling a template-based `send_email()` function that doesn't exist. I've commented these out with TODOs: - -```python -# TODO: Implement template-based email for payment failures -# email_service.send_email( -# to_email=membership.user.email, -# subject=f"Payment Failed - {organization.name}", -# body_text=f"Your payment for {organization.name} has failed.", -# body_html=None -# ) -pass -``` - -This was done for 4 email functions: -- Payment failed notifications -- Action required notifications -- Subscription cancelled notifications -- Trial ending notifications - -## Complete Fix History - -### Issue 1: StripeService Initialization ✅ FIXED -- **Problem:** `StripeService.__init__()` accessed `current_app` at import time -- **Solution:** Implemented lazy initialization with `_ensure_initialized()` -- **File:** `app/utils/stripe_service.py` - -### Issue 2: Import Error ✅ FIXED -- **Problem:** `billing.py` tried to import non-existent `send_email` function -- **Solution:** Changed import to `email_service` singleton -- **File:** `app/routes/billing.py` - -### Issue 3: Template Emails ✅ TEMPORARY FIX -- **Problem:** Code called template-based `send_email()` that doesn't exist -- **Solution:** Commented out with TODOs for future implementation -- **File:** `app/routes/billing.py` - -## Files Modified - -1. ✅ `app/utils/stripe_service.py` - Lazy initialization -2. ✅ `app/routes/billing.py` - Fixed imports and email calls - -## Application Should Now Start - -The application should now start successfully without import errors. Run: - -```bash -# Docker -docker-compose up - -# Local -flask run -``` - -## Still Need to Fix - -### 1. Database Migration -See `MIGRATION_FIX_SUMMARY.md` for instructions: -```bash -python fix_migration_chain.py -flask db upgrade -``` - -### 2. Template-Based Emails (Future Enhancement) -The billing notification emails are currently disabled. To implement them: - -1. Create email templates in `app/templates/email/billing/`: - - `payment_failed.html` - - `action_required.html` - - `subscription_cancelled.html` - - `trial_ending.html` - -2. Add a method to `EmailService` class: - ```python - def send_template_email(self, to_email, subject, template_name, **context): - # Render template with context - html = render_template(f'email/{template_name}', **context) - # Send email - self.send_email(to_email, subject, body_text, html) - ``` - -3. Uncomment the email calls in `billing.py` and use the new method - -## Verification - -To verify everything is working: - -```bash -# 1. Application starts -docker-compose up -# Should see: "TimeTracker Docker Container Starting" -# Should NOT see: RuntimeError or ImportError - -# 2. Check logs -docker-compose logs -f app -# Should see successful initialization - -# 3. Test endpoint -curl http://localhost:5000/health -# Should return 200 OK -``` - -## Summary - -✅ **Fixed:** Stripe service lazy initialization -✅ **Fixed:** Import error in billing routes -✅ **Fixed:** Removed invalid send_email calls -⏳ **TODO:** Database migration (separate issue) -⏳ **TODO:** Implement template-based emails (future) - -The application is now ready to start! 🎉 - diff --git a/LOCAL_DEVELOPMENT_SETUP.md b/LOCAL_DEVELOPMENT_SETUP.md deleted file mode 100644 index f62c012..0000000 --- a/LOCAL_DEVELOPMENT_SETUP.md +++ /dev/null @@ -1,303 +0,0 @@ -# Local Development Setup - -## Issue: SSL/HTTPS Error on Localhost - -If you see an error like: -- `PR_END_OF_FILE_ERROR` -- "Secure connection failed" -- "SSL protocol error" - -This means you're trying to access `https://localhost:8080` but the application is running on HTTP. - ---- - -## Quick Fix - -### Option 1: Use HTTP (Recommended for Local Dev) - -**Access the application via HTTP:** -``` -http://localhost:8080 -``` - -**NOT:** -``` -https://localhost:8080 ❌ -``` - -### Option 2: Configure Local Environment - -1. **Copy the local development environment file:** - ```bash - cp .env.local .env - ``` - -2. **Or create `.env` manually with these settings:** - ```bash - # Critical for local development - FLASK_ENV=development - FORCE_HTTPS=false - SESSION_COOKIE_SECURE=false - REMEMBER_COOKIE_SECURE=false - ``` - -3. **Restart the application:** - ```bash - # If using Docker - docker-compose restart app - - # If running directly - flask run - ``` - -4. **Access via HTTP:** - ``` - http://localhost:8080 - ``` - ---- - -## Environment Configuration - -### Local Development (.env) - -```bash -# Flask settings -FLASK_ENV=development -FLASK_DEBUG=true - -# Security - DISABLED for local HTTP -FORCE_HTTPS=false -SESSION_COOKIE_SECURE=false -REMEMBER_COOKIE_SECURE=false - -# Rate limiting - DISABLED for easier testing -RATELIMIT_ENABLED=false - -# Password policy - RELAXED for testing -PASSWORD_MIN_LENGTH=8 -PASSWORD_REQUIRE_UPPERCASE=false -PASSWORD_REQUIRE_LOWERCASE=false -PASSWORD_REQUIRE_DIGITS=false -PASSWORD_REQUIRE_SPECIAL=false -``` - -### Production (.env) - -```bash -# Flask settings -FLASK_ENV=production -FLASK_DEBUG=false - -# Security - ENABLED for production HTTPS -FORCE_HTTPS=true -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# Rate limiting - ENABLED for security -RATELIMIT_ENABLED=true -RATELIMIT_STORAGE_URI=redis://localhost:6379 - -# Password policy - STRICT for security -PASSWORD_MIN_LENGTH=12 -PASSWORD_REQUIRE_UPPERCASE=true -PASSWORD_REQUIRE_LOWERCASE=true -PASSWORD_REQUIRE_DIGITS=true -PASSWORD_REQUIRE_SPECIAL=true -``` - ---- - -## Docker Compose Local Development - -If you're using Docker Compose, update `docker-compose.yml` or create `docker-compose.override.yml`: - -**docker-compose.override.yml** (for local development): - -```yaml -version: '3.8' - -services: - app: - environment: - - FLASK_ENV=development - - FORCE_HTTPS=false - - SESSION_COOKIE_SECURE=false - - REMEMBER_COOKIE_SECURE=false - - RATELIMIT_ENABLED=false -``` - -Then restart: -```bash -docker-compose up -d -``` - ---- - -## Troubleshooting - -### Issue: Browser automatically redirects to HTTPS - -**Cause:** Browser cached the HTTPS redirect or HSTS header - -**Solution:** - -1. **Clear browser cache and cookies for localhost** - -2. **Clear HSTS settings:** - - **Chrome:** - - Go to: `chrome://net-internals/#hsts` - - Query: `localhost` - - Delete domain security policies: `localhost` - - **Firefox:** - - Go to: `about:permissions` - - Search for `localhost` - - Remove permissions - -3. **Access via HTTP explicitly:** - ``` - http://localhost:8080 - ``` - -### Issue: Still getting SSL error - -**Check your configuration:** - -```bash -# Verify FORCE_HTTPS is false -grep FORCE_HTTPS .env - -# Should show: -# FORCE_HTTPS=false -``` - -**Restart application:** -```bash -docker-compose restart app -# or -flask run -``` - -### Issue: Application won't start - -**Check for syntax errors:** -```bash -python app.py -``` - -**Check logs:** -```bash -docker-compose logs app -# or -tail -f logs/timetracker.log -``` - ---- - -## Testing HTTPS Locally (Optional) - -If you want to test HTTPS locally, you can: - -### Option 1: Generate Self-Signed Certificate - -```bash -# Generate certificate -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes - -# Run Flask with TLS -flask run --cert=cert.pem --key=key.pem --host=0.0.0.0 --port=8080 -``` - -Then access: `https://localhost:8080` (you'll see a security warning - click "Advanced" → "Proceed") - -### Option 2: Use mkcert (Trusted Local Certificates) - -```bash -# Install mkcert -# macOS -brew install mkcert -# Windows (with Chocolatey) -choco install mkcert - -# Generate local CA and certificate -mkcert -install -mkcert localhost 127.0.0.1 ::1 - -# Run Flask with certificate -flask run --cert=localhost+2.pem --key=localhost+2-key.pem --host=0.0.0.0 --port=8080 -``` - -Then access: `https://localhost:8080` (no security warning!) - -### Option 3: Use nginx Locally - -See `docs/HTTPS_SETUP_GUIDE.md` for nginx configuration. - ---- - -## Recommended Local Development Workflow - -1. **Development**: Use HTTP (`http://localhost:8080`) - - Fast, no certificate issues - - Easy debugging - - Set `FORCE_HTTPS=false` - -2. **Staging**: Test with HTTPS - - Use real domain with Let's Encrypt - - Test with production-like settings - - Set `FORCE_HTTPS=true` - -3. **Production**: Always HTTPS - - Enforce HTTPS - - Secure cookies - - Set `FORCE_HTTPS=true` - ---- - -## Quick Commands - -```bash -# Start local development -cp .env.local .env -docker-compose up -d -# Access: http://localhost:8080 - -# Check if HTTP is working -curl http://localhost:8080 - -# Check configuration -docker-compose exec app env | grep FORCE_HTTPS -# Should show: FORCE_HTTPS=false - -# View logs -docker-compose logs -f app -``` - ---- - -## Summary - -**For local development:** -- ✅ Use `http://localhost:8080` (not https) -- ✅ Set `FORCE_HTTPS=false` -- ✅ Set `SESSION_COOKIE_SECURE=false` -- ✅ Set `FLASK_ENV=development` - -**For production:** -- ✅ Use `https://your-domain.com` -- ✅ Set `FORCE_HTTPS=true` -- ✅ Set `SESSION_COOKIE_SECURE=true` -- ✅ Set `FLASK_ENV=production` -- ✅ Configure reverse proxy (nginx/Apache) with TLS certificate - ---- - -**Need Help?** - -Check: -- `docs/HTTPS_SETUP_GUIDE.md` - Production HTTPS setup -- `SECURITY_QUICK_START.md` - Security configuration -- `env.example` - All configuration options - diff --git a/MARKETING_PROMOTION_PLAN.md b/MARKETING_PROMOTION_PLAN.md deleted file mode 100644 index 2cf2ffa..0000000 --- a/MARKETING_PROMOTION_PLAN.md +++ /dev/null @@ -1,714 +0,0 @@ -# TimeTracker - Marketing & Promotion Plan - -## 🎯 Overview - -This document outlines the complete marketing and promotion strategy for TimeTracker's hosted offering launch. - ---- - -## 📅 Launch Timeline - -### Pre-Launch (Weeks -2 to 0) -- **Week -2:** Finalize landing page, test signup flow -- **Week -1:** Create demo video, prepare social media posts -- **Week 0:** Soft launch to mailing list, gather feedback - -### Launch Week -- **Day 1 (Monday):** Product Hunt launch -- **Day 2 (Tuesday):** HackerNews Show HN post -- **Day 3 (Wednesday):** Reddit r/selfhosted + r/SaaS -- **Day 4 (Thursday):** Dev.to / Medium article -- **Day 5 (Friday):** LinkedIn / Twitter thread - -### Post-Launch (Weeks 1-4) -- Weekly blog posts -- Community engagement -- Customer testimonials -- Feature spotlights - ---- - -## 🎬 Demo Video Script - -### Duration: 90 seconds - -#### Opening (0-15s) -``` -[Screen: Landing page] -Voiceover: "Tired of complex time tracking tools that get in your way? -Meet TimeTracker—professional time tracking made simple." - -[Quick cut to dashboard with timer] -``` - -#### Core Features (15-60s) -``` -[Screen: Start timer with one click] -Voiceover: "Track time with a single click. Whether you're a freelancer -billing clients or a team managing projects, TimeTracker has you covered." - -[Screen: Projects and clients view] -Voiceover: "Organize by clients and projects. Set billing rates, -track estimates, and never lose track of your time again." - -[Screen: Generate invoice] -Voiceover: "Generate professional invoices in seconds. Customizable -templates, automatic calculations, and PDF export included." - -[Screen: Reports and analytics] -Voiceover: "Beautiful reports and analytics help you understand -where your time goes and optimize your productivity." - -[Screen: Mobile responsive view] -Voiceover: "Works perfectly on desktop, tablet, and mobile. -Track time anywhere, anytime." -``` - -#### Call to Action (60-90s) -``` -[Screen: Pricing page] -Voiceover: "Choose cloud-hosted for hassle-free operation with -automatic backups and support—or self-host for free with all -features included." - -[Screen: Signup form] -Voiceover: "Start your 14-day free trial today. No credit card -required. Join hundreds of freelancers and teams who trust -TimeTracker." - -[End screen with logo and URL] -Text on screen: "timetracker.com" -Text on screen: "Use code EARLY2025 for 20% off!" -``` - -### Production Notes: -- Use screen recording with subtle zoom/pan effects -- Upbeat background music (royalty-free) -- Clear, professional voiceover -- Smooth transitions between scenes -- Add captions for accessibility -- Export in 1080p, optimize for web - ---- - -## 📱 Social Media Posts - -### Product Hunt Launch - -**Title:** TimeTracker - Professional time tracking made simple - -**Tagline:** Track time, manage projects, generate invoices. Self-hosted or cloud. - -**Description:** -``` -Hey Product Hunt! 👋 - -I'm excited to share TimeTracker—a professional time tracking application -I've been working on. It's designed for freelancers, teams, and businesses -who need powerful time tracking without the complexity. - -🎯 What makes TimeTracker special: - -✅ Smart Time Tracking - Automatic timers with idle detection -✅ Project Management - Organize by clients and projects -✅ Professional Invoicing - Generate branded PDF invoices -✅ Beautiful Reports - Visual analytics and insights -✅ Team Collaboration - Multi-user with role-based access -✅ Open Source - Self-host for free with all features - -💡 Choose your hosting: -• Cloud-hosted: €5-6/user/month (we handle everything) -• Self-hosted: Free forever (you control the infrastructure) - -🎁 Early adopter offer: Use code EARLY2025 for 20% off first 3 months! - -🚀 14-day free trial, no credit card required - -Built with Flask, PostgreSQL, and lots of ❤️ for productivity. - -Try it out and let me know what you think! -``` - -**Topics:** Productivity, SaaS, Time Tracking, Open Source, Self-Hosted - ---- - -### HackerNews "Show HN" - -**Title:** Show HN: TimeTracker – Open-source time tracking with optional hosted SaaS - -**Post:** -``` -Hi HN, - -I built TimeTracker, an open-source time tracking application that you can -self-host for free or use as a hosted service. - -GitHub: https://github.com/drytrix/TimeTracker -Demo: https://demo.timetracker.com -Hosted: https://timetracker.com - -The problem I wanted to solve: Most time tracking tools are either too simple -(just a timer) or too complex (enterprise features you don't need). I wanted -something in the middle—powerful enough for client billing and invoicing, but -simple enough to use daily without friction. - -Key features: -- Smart time tracking with idle detection -- Client and project management -- Professional PDF invoices with customizable templates -- Comprehensive reports and analytics -- Multi-user support with role-based access -- Mobile-optimized responsive design -- Docker deployment with automatic database migrations - -Tech stack: -- Backend: Flask (Python) -- Database: PostgreSQL (also supports SQLite) -- Frontend: Bootstrap + vanilla JS (no heavy frameworks) -- Deployment: Docker + Docker Compose -- Billing: Stripe integration for hosted offering - -The hosted version costs €5/month for single user or €6/user/month for teams, -with a 14-day free trial. But the entire codebase is GPL-3.0 licensed, so you -can self-host for free with all features included. - -I'd love feedback on: -1. The pricing model (too high/low? fair split between hosted/self-hosted?) -2. Missing features you'd want in a time tracker -3. The technical implementation (any glaring issues?) - -Happy to answer questions! -``` - ---- - -### Reddit r/selfhosted - -**Title:** TimeTracker - Open source time tracking with professional invoicing [Docker, PostgreSQL] - -**Post:** -``` -Hey r/selfhosted! - -I've been working on TimeTracker, an open-source time tracking application -designed for freelancers and teams. It's fully self-hostable with Docker. - -**What it does:** -- Track time automatically or manually -- Organize by clients, projects, and tasks -- Generate professional PDF invoices -- Beautiful reports and analytics -- Multi-user with role-based permissions - -**Why I built it:** -I freelance and needed something more than a basic timer but less than -enterprise tools like Jira. Most SaaS time trackers felt bloated, locked -my data away, or charged too much. So I built this. - -**Self-hosting details:** -- Docker + Docker Compose (one command to deploy) -- PostgreSQL or SQLite -- Automatic database migrations -- Nginx/Caddy reverse proxy support -- Resource efficient (runs great on a Raspberry Pi) -- Full documentation and deployment guides - -**Tech stack:** -- Flask (Python) backend -- PostgreSQL database -- Bootstrap + vanilla JS frontend -- No heavy frameworks or build steps - -**License:** GPL-3.0 (free forever, all features) - -**Optional hosted service:** -If you don't want to self-host, I also offer a hosted version at €5-6/user/month -with automatic backups, updates, and support. But self-hosting is completely -free with no feature restrictions. - -**Links:** -- GitHub: https://github.com/drytrix/TimeTracker -- Demo: https://demo.timetracker.com -- Docs: https://github.com/drytrix/TimeTracker/tree/main/docs - -Would love feedback from the community! What features would you want to see? -``` - ---- - -### Twitter/X Thread - -**Tweet 1:** -``` -🚀 Launching TimeTracker today! - -Professional time tracking made simple. Track time, manage projects, -generate invoices. - -Self-host for FREE or use our cloud hosting. - -🧵 Thread with everything you need to know 👇 - -#productivity #opensource #saas -``` - -**Tweet 2:** -``` -⏱️ Smart time tracking - -• One-click start/stop -• Automatic idle detection -• Manual time entry -• Real-time sync across devices - -Never lose track of billable hours again. -``` - -**Tweet 3:** -``` -📊 Project management built-in - -• Organize by clients -• Set billing rates per project -• Track estimates vs actuals -• Task management -• Comments and collaboration -``` - -**Tweet 4:** -``` -🧾 Professional invoicing - -• Generate PDF invoices in seconds -• Customizable templates -• Automatic calculations -• Tax/VAT support -• Email directly to clients -``` - -**Tweet 5:** -``` -📈 Beautiful reports - -• Visual analytics -• Time breakdown by project/client -• Billable vs non-billable -• Export to CSV -• Custom date ranges -``` - -**Tweet 6:** -``` -💻 Self-host or use our cloud - -Self-hosted (FREE): -✅ All features included -✅ Full control -✅ Docker deployment -✅ PostgreSQL or SQLite - -Cloud ($5-6/user/mo): -✅ Automatic backups -✅ Updates managed -✅ Support included -✅ 99.9% uptime SLA -``` - -**Tweet 7:** -``` -🎁 Early adopter special! - -Use code: EARLY2025 -Get: 20% off for 3 months - -🆓 14-day free trial -💳 No credit card required - -Try it now: [link] - -#timetracking #freelancing #productivity -``` - -**Tweet 8:** -``` -⭐ Open source & GPL-3.0 - -• Full source on GitHub -• Contribute or fork -• No vendor lock-in -• Export your data anytime - -Built with Flask, PostgreSQL, and ❤️ - -Star us on GitHub: [link] -``` - ---- - -### LinkedIn Post - -``` -🚀 Excited to share TimeTracker - a professional time tracking solution -I've been building! - -After years of freelancing and running a small team, I got frustrated with -existing time tracking tools. They were either too basic or too complex, -with data locked in proprietary systems and expensive monthly fees. - -So I built TimeTracker - a solution that gives you: - -✅ Smart time tracking with automatic timers -✅ Client and project management -✅ Professional PDF invoicing -✅ Beautiful reports and analytics -✅ Team collaboration features -✅ Mobile-optimized interface - -What makes it different? - -🆓 **Fully Open Source** - Self-host for free with all features (GPL-3.0) -☁️ **Optional Cloud Hosting** - Or let us handle hosting for €5-6/user/month -🔒 **Data Ownership** - Export anytime, no lock-in -🐳 **Easy Deployment** - Docker-based, runs on a Raspberry Pi - -Perfect for: -• Freelancers tracking billable hours -• Small teams managing client projects -• Agencies handling multiple clients -• Anyone who values privacy and control - -🎁 Early adopter offer: 20% off with code EARLY2025 - -🆓 Try the 14-day free trial (no credit card required) - -Check it out: [link] -GitHub: [link] - -#freelancing #productivity #opensource #saas #timemanagement #invoicing -``` - ---- - -### Dev.to / Medium Article - -**Title:** Building TimeTracker: An Open-Source Time Tracking SaaS with Flask - -**Outline:** -1. **Introduction** - - The problem: existing time trackers suck - - Why I built TimeTracker - - Goals: simple, powerful, self-hostable - -2. **Technical Architecture** - - Flask backend design - - PostgreSQL for multi-tenancy - - Row-level security for data isolation - - Docker deployment strategy - -3. **Key Features Implementation** - - Smart timer with idle detection - - Real-time updates with WebSockets - - PDF invoice generation - - Stripe billing integration - -4. **The Business Model** - - Open source + hosted SaaS - - Why offer both? - - Pricing strategy - - Early learnings - -5. **Deployment & Operations** - - Docker setup - - Database migrations - - Monitoring and backups - - Scaling considerations - -6. **Lessons Learned** - - What worked - - What didn't - - Future plans - - Community feedback - -7. **Call to Action** - - Try it out - - Contribute on GitHub - - Early adopter discount - ---- - -## 📧 Email Campaign - -### Launch Announcement - -**Subject:** Introducing TimeTracker Cloud - Professional time tracking made simple - -**Body:** -``` -Hi [Name], - -I'm excited to announce the launch of TimeTracker Cloud! 🚀 - -After months of development and feedback from early users, TimeTracker is now -available as a fully-hosted solution—no setup, no maintenance, just -professional time tracking that works. - -What is TimeTracker? -A comprehensive time tracking application designed for freelancers, teams, and -businesses. Track time, manage projects, and generate professional invoices—all -in one place. - -Key Features: -✅ Smart time tracking with automatic idle detection -✅ Client and project management -✅ Professional PDF invoicing -✅ Beautiful reports and analytics -✅ Team collaboration with role-based access -✅ Mobile-optimized design - -Choose Your Hosting: -• Cloud-hosted: €5-6/user/month (we handle everything) -• Self-hosted: Free forever (you control your data) - -🎁 Special Launch Offer: -As a thank you for being an early supporter, use code EARLY2025 for 20% off -your first 3 months! - -🆓 Start Your Free Trial: -14 days, no credit card required → [CTA Button] - -Still want to self-host? No problem! The entire codebase remains open source -(GPL-3.0) with all features available for free. - -GitHub: [link] -Documentation: [link] -Live Demo: [link] - -Thank you for your support, and I can't wait to hear what you think! - -Best regards, -[Your Name] - -P.S. Have questions? Hit reply—I read every email personally. -``` - ---- - -## 🎯 Content Marketing Calendar - -### Week 1: Getting Started -- **Blog:** "Getting Started with TimeTracker in 5 Minutes" -- **Video:** Quick start guide -- **Social:** Tips for effective time tracking - -### Week 2: Features Deep Dive -- **Blog:** "How to Generate Professional Invoices with TimeTracker" -- **Video:** Invoice creation walkthrough -- **Social:** Invoice template showcase - -### Week 3: Productivity Tips -- **Blog:** "10 Time Tracking Best Practices for Freelancers" -- **Video:** Productivity tips using TimeTracker -- **Social:** User success stories - -### Week 4: Technical -- **Blog:** "Self-Hosting TimeTracker: A Complete Guide" -- **Video:** Docker deployment tutorial -- **Social:** Behind-the-scenes development - ---- - -## 📊 Success Metrics - -### Week 1 Goals: -- [ ] 100 landing page visitors -- [ ] 20 free trial signups -- [ ] 50 GitHub stars -- [ ] 5 paying customers - -### Month 1 Goals: -- [ ] 500 landing page visitors -- [ ] 50 free trial signups -- [ ] 200 GitHub stars -- [ ] 20 paying customers -- [ ] $100 MRR - -### Quarter 1 Goals: -- [ ] 2,000 landing page visitors -- [ ] 200 free trial signups -- [ ] 500 GitHub stars -- [ ] 50 paying customers -- [ ] $300 MRR -- [ ] Product-market fit indicators - ---- - -## 🔍 SEO Keywords - -### Primary: -- time tracking software -- open source time tracker -- freelance time tracking -- project time management -- time tracking with invoicing - -### Long-tail: -- self-hosted time tracking docker -- time tracker for freelancers with invoicing -- open source alternative to Toggl -- time tracking software with client billing -- docker time tracking application - ---- - -## 🤝 Community Engagement - -### Where to be active: -- **Reddit:** r/selfhosted, r/SaaS, r/freelance, r/programming -- **HackerNews:** Daily, respond to comments -- **Dev.to:** Weekly technical articles -- **Twitter/X:** Daily updates and tips -- **LinkedIn:** Weekly professional content - -### Response templates: -Keep handy responses for: -- Feature requests -- Bug reports -- Pricing questions -- Self-hosting help -- General inquiries - ---- - -## 📹 Video Content Ideas - -1. **Product Demo** (90s) - Feature overview -2. **Getting Started** (5 min) - First-time setup -3. **Invoice Tutorial** (3 min) - Create professional invoices -4. **Self-Hosting Guide** (10 min) - Docker deployment -5. **Mobile App Tour** (2 min) - Mobile features -6. **Tips & Tricks** (3 min) - Power user features -7. **Behind the Scenes** (5 min) - Development process -8. **Customer Stories** (2 min) - User testimonials - ---- - -## 💡 Growth Tactics - -### Immediate (Week 1-2): -- [ ] Launch on Product Hunt -- [ ] Post on HackerNews -- [ ] Share in relevant Reddit communities -- [ ] Reach out to micro-influencers -- [ ] Email personal network - -### Short-term (Month 1-2): -- [ ] Write guest posts for dev blogs -- [ ] Create comparison pages (vs Toggl, Harvest, etc.) -- [ ] Start YouTube channel -- [ ] Build email newsletter -- [ ] Engage in online communities - -### Long-term (Month 3+): -- [ ] SEO optimization -- [ ] Content marketing -- [ ] Affiliate program -- [ ] Partner integrations -- [ ] Conference talks - ---- - -## 📝 Competitor Comparison Content - -Create landing pages comparing TimeTracker to: -- Toggl Track -- Harvest -- Clockify -- TimeCamp -- RescueTime - -Highlight: -- Open source advantage -- Self-hosting option -- Better pricing -- No feature restrictions -- Data ownership - ---- - -## 🎁 Referral Program (Future) - -**Give $10, Get $10** -- Existing users get $10 credit for each referral -- New users get $10 off first month -- Unlimited referrals -- Tracked via unique referral links - ---- - -## 📈 Conversion Optimization - -### A/B Test Ideas: -- Landing page hero copy -- Pricing display (monthly vs annual default) -- CTA button colors and text -- Trial length (7 vs 14 days) -- Social proof placement - -### Exit Intent Popups: -- "Wait! Get 20% off with code EARLY2025" -- "Download our free time tracking guide" -- "Join 500+ freelancers who track smarter" - ---- - -## ✅ Launch Checklist - -### Pre-Launch: -- [ ] Landing page live and tested -- [ ] Signup flow works end-to-end -- [ ] Payment processing tested -- [ ] Email notifications configured -- [ ] Demo video created -- [ ] Social media accounts set up -- [ ] Press kit prepared -- [ ] Analytics tracking installed - -### Launch Day: -- [ ] Product Hunt submission (early morning PST) -- [ ] HackerNews post (morning PST) -- [ ] Social media posts scheduled -- [ ] Email announcement sent -- [ ] Monitor and respond to comments -- [ ] Track metrics - -### Post-Launch: -- [ ] Thank you emails to early supporters -- [ ] Gather feedback and testimonials -- [ ] Fix any critical issues -- [ ] Plan next features based on feedback -- [ ] Continue marketing efforts - ---- - -## 📞 Contact & Support - -**Support Email:** support@timetracker.com -**Twitter:** @timetracker_app -**GitHub:** github.com/drytrix/TimeTracker - -**Response Time SLAs:** -- Free trial: 24-48 hours -- Single user: 24-48 hours -- Team plan: 12-24 hours -- Critical issues: 2-4 hours - ---- - -**Last Updated:** 2025-01-07 -**Next Review:** Weekly during launch month - ---- - -**Remember:** Focus on providing value, being helpful, and building relationships. The best marketing is making customers successful! - diff --git a/MARKETING_SALES_EXECUTION_SUMMARY.md b/MARKETING_SALES_EXECUTION_SUMMARY.md deleted file mode 100644 index 50e56ff..0000000 --- a/MARKETING_SALES_EXECUTION_SUMMARY.md +++ /dev/null @@ -1,571 +0,0 @@ -# Marketing, Sales & Pricing Execution - Implementation Summary - -## ✅ Implementation Complete! - -All acceptance criteria have been met for the Marketing, Sales & Pricing Execution feature. - ---- - -## 📋 Acceptance Criteria Status - -### ✅ 1. Landing Page Live with Signup Flow -**Status:** Complete - -**Files Created:** -- `app/templates/marketing/landing.html` - Full-featured marketing landing page - -**Features Implemented:** -- Hero section with clear value proposition -- Feature showcase (6 key features with icons) -- Comprehensive pricing comparison (Self-hosted, Single User, Team) -- Detailed feature comparison table -- Social proof section -- Integrated FAQ section -- Call-to-action buttons throughout -- Mobile-responsive design -- Smooth scroll navigation -- Beautiful gradient hero design - -**Routes Added:** -- `/` - Landing page (public, redirects to dashboard if logged in) -- `/pricing` - Redirects to landing page pricing section -- `/faq` - Dedicated FAQ page - ---- - -### ✅ 2. README Updated with Hosted Banner -**Status:** Complete - -**Changes Made to README.md:** -```markdown -### 🚀 **[Try TimeTracker Cloud Hosted - 14-Day Free Trial →](https://your-hosted-domain.com)** - -**No installation required • Automatic updates • Professional support** - -*Or continue below to self-host for free with all features included* -``` - -**Placement:** Top of README, immediately visible -**Design:** Professional banner with clear call-to-action -**Action Required:** Update URL to your actual hosted domain - ---- - -### ✅ 3. 14-Day Trial Configuration -**Status:** Complete (Already Implemented) - -**Configuration Verified:** -- `app/config.py`: `STRIPE_TRIAL_DAYS = 14` (default) -- `app/utils/stripe_service.py`: Trial logic implemented -- Environment variable: `STRIPE_ENABLE_TRIALS=true` (default) -- Automatic trial application during subscription creation - -**How It Works:** -1. User signs up without credit card -2. 14-day trial starts automatically -3. Full access to all features during trial -4. Payment prompt after 14 days -5. Cancel anytime during trial with no charges - ---- - -### ✅ 4. Promo Code System -**Status:** Complete - -**Files Created:** -1. **Models:** - - `app/models/promo_code.py` - PromoCode and PromoCodeRedemption models - -2. **Services:** - - `app/utils/promo_code_service.py` - Complete promo code management service - -3. **Routes:** - - `app/routes/promo_codes.py` - API endpoints for promo codes - -4. **Migration:** - - `migrations/versions/020_add_promo_codes.py` - Database schema - -**Features:** -- ✅ Create promo codes with flexible rules -- ✅ Percentage or fixed amount discounts -- ✅ Duration options: once, repeating, forever -- ✅ Usage limits and expiration dates -- ✅ First-time customer restrictions -- ✅ Stripe integration (automatic sync) -- ✅ Redemption tracking -- ✅ Admin management interface -- ✅ Validation API for signup flow - -**Pre-configured Promo Code:** -- Code: `EARLY2025` -- Discount: 20% off -- Duration: 3 months (repeating) -- Restriction: First-time customers only -- Expiry: 6 months from creation - -**API Endpoints:** -- `POST /promo-codes/validate` - Validate code (public) -- `POST /promo-codes/apply` - Apply code (authenticated) -- `GET /promo-codes/admin` - Admin management -- `POST /promo-codes/admin/create` - Create new code -- `POST /promo-codes/admin//deactivate` - Deactivate code - ---- - -### ✅ 5. FAQ Page -**Status:** Complete - -**File Created:** `app/templates/marketing/faq.html` - -**Sections Covered:** -1. **Getting Started** (3 FAQs) - - How to get started - - Credit card requirement - - Hosted vs self-hosted difference - -2. **Privacy & Security** (4 FAQs) - - Data security - - Data storage location - - Data access - - Cookies and tracking - -3. **Data Export & Portability** (3 FAQs) - - Export capabilities - - Export formats - - Import from other tools - -4. **Pricing & Billing** (4 FAQs) - - Pricing structure - - Payment methods - - Plan changes - - Annual billing - -5. **Refunds & Cancellation** (3 FAQs) - - Refund policy (30-day guarantee) - - Cancellation process - - Data retention after cancellation - -6. **VAT & Invoicing** (3 FAQs) - - VAT handling for EU customers - - Invoice generation - - Company details on invoices - -7. **Support & Help** (3 FAQs) - - Support options by plan - - Training and onboarding - - System status page - -**Features:** -- Search functionality -- Collapsible Q&A sections -- Mobile-responsive design -- Professional styling -- Direct links to signup/contact - ---- - -### ✅ 6. Promotion Plan -**Status:** Complete - -**File Created:** `MARKETING_PROMOTION_PLAN.md` (32 pages) - -**Contents:** - -1. **Launch Timeline** - - Pre-launch checklist - - Launch week schedule - - Post-launch activities - -2. **Demo Video Script** - - 90-second video outline - - Scene-by-scene breakdown - - Production notes - -3. **Social Media Posts** - - Product Hunt launch post - - HackerNews Show HN post - - Reddit (r/selfhosted, r/SaaS) - - Twitter/X thread (8 tweets) - - LinkedIn professional post - - Dev.to / Medium article outline - -4. **Email Campaign** - - Launch announcement template - - Follow-up sequences - - Newsletter ideas - -5. **Content Marketing Calendar** - - Week-by-week blog post topics - - Video content schedule - - Social media themes - -6. **Success Metrics** - - Week 1, Month 1, Quarter 1 goals - - KPIs to track - - Growth targets - -7. **SEO Strategy** - - Primary keywords - - Long-tail keywords - - Content optimization - -8. **Community Engagement** - - Platform list - - Response templates - - Engagement tactics - -9. **Video Content Ideas** - - 8 video concepts - - Duration and format - - Production priorities - -10. **Growth Tactics** - - Immediate actions - - Short-term strategy - - Long-term plans - -11. **Competitor Comparison** - - Pages to create - - Key differentiators - - Positioning strategy - -12. **Launch Checklist** - - Pre-launch tasks - - Launch day activities - - Post-launch follow-up - ---- - -## 📂 Files Created/Modified - -### New Files (12): -1. `app/templates/marketing/landing.html` - Marketing landing page -2. `app/templates/marketing/faq.html` - FAQ page -3. `app/models/promo_code.py` - Promo code models -4. `app/utils/promo_code_service.py` - Promo code service -5. `app/routes/promo_codes.py` - Promo code routes -6. `migrations/versions/020_add_promo_codes.py` - Database migration -7. `MARKETING_PROMOTION_PLAN.md` - Complete promotion guide -8. `MARKETING_SALES_EXECUTION_SUMMARY.md` - This document - -### Modified Files (4): -1. `README.md` - Added hosted offering banner -2. `app/routes/main.py` - Added landing/pricing/faq routes -3. `app/__init__.py` - Registered promo_codes blueprint -4. `app/models/__init__.py` - Added PromoCode imports -5. `app/models/organization.py` - Added promo code fields - ---- - -## 🚀 Next Steps to Launch - -### 1. Deploy Changes -```bash -# Commit all changes -git add . -git commit -m "Add marketing landing page, promo codes, and FAQ" - -# Run database migration -flask db upgrade - -# Or apply migration in Docker -docker-compose exec app flask db upgrade - -# Restart application -docker-compose restart app -``` - -### 2. Update Configuration -Update `.env` with: -```bash -# Ensure Stripe is configured -STRIPE_SECRET_KEY=sk_live_... -STRIPE_PUBLISHABLE_KEY=pk_live_... -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 - -# Add promo code for early adopters (already in migration) -# EARLY2025 will be automatically created -``` - -### 3. Update URLs -Replace `https://your-hosted-domain.com` in: -- `README.md` (line 5) -- `app/templates/marketing/landing.html` (any placeholder URLs) -- `MARKETING_PROMOTION_PLAN.md` (replace [link] placeholders) - -### 4. Create Demo Video -Follow the script in `MARKETING_PROMOTION_PLAN.md`: -- Record 90-second demo -- Add voiceover and music -- Upload to YouTube -- Add to landing page - -### 5. Prepare Assets -- Create Product Hunt images (1200x630px) -- Design social media graphics -- Take screenshots for comparison pages -- Prepare logo variations - -### 6. Test Signup Flow -1. Visit landing page at `/` -2. Click "Start Free Trial" -3. Complete signup form -4. Verify organization creation -5. Test promo code: `EARLY2025` -6. Confirm 14-day trial activation -7. Check confirmation email - -### 7. Launch Week Schedule - -**Monday:** Product Hunt -- Submit early morning (PST timezone) -- Engage with comments all day -- Share on social media - -**Tuesday:** HackerNews -- Post "Show HN" in morning -- Monitor and respond to comments -- Cross-post to Twitter - -**Wednesday:** Reddit -- Post to r/selfhosted -- Post to r/SaaS -- Engage with community - -**Thursday:** Dev.to / Medium -- Publish technical article -- Share in relevant communities - -**Friday:** LinkedIn / Twitter -- Professional announcement -- Twitter thread with tips -- Thank early supporters - -### 8. Monitor Metrics -Track daily: -- Landing page visitors -- Signup conversions -- Trial activations -- Promo code usage -- GitHub stars -- Social engagement - ---- - -## 💡 Marketing Best Practices - -### Landing Page Optimization: -- A/B test hero copy -- Monitor scroll depth -- Track CTA click rates -- Optimize for mobile -- Add exit intent popups - -### Conversion Optimization: -- Reduce friction in signup -- Add social proof (testimonials) -- Display "XX users joined this week" -- Show live activity feed -- Add trust badges - -### Content Strategy: -- Publish weekly blog posts -- Create comparison pages -- Build SEO landing pages -- Share customer success stories -- Create video tutorials - -### Community Building: -- Respond to all comments -- Help users in forums -- Share tips and tricks -- Highlight user wins -- Build email newsletter - ---- - -## 📊 Success Metrics (First Month) - -### Traffic Goals: -- [ ] 500 landing page visitors -- [ ] 50 GitHub stars -- [ ] 100 Product Hunt upvotes - -### Conversion Goals: -- [ ] 50 free trial signups -- [ ] 30% trial-to-paid conversion -- [ ] 20 paying customers - -### Revenue Goals: -- [ ] $100 MRR (Monthly Recurring Revenue) -- [ ] 2-3 annual subscriptions - -### Engagement Goals: -- [ ] 50 mailing list subscribers -- [ ] 20 GitHub issues/PRs -- [ ] 100 social media followers - ---- - -## 🎯 Pricing Strategy - -### Current Pricing: -- **Self-Hosted:** Free forever -- **Single User:** €5/month -- **Team:** €6/user/month - -### Value Proposition: -- Cheaper than Toggl (€9/user) -- Cheaper than Harvest (€10.80/user) -- More features than Clockify free tier -- Self-hosting option unique in market - -### Early Adopter Offer: -- Code: `EARLY2025` -- Discount: 20% off -- Duration: First 3 months -- Creates urgency and rewards early users - ---- - -## 🔒 Privacy & Compliance - -### GDPR Compliance: -- ✅ Privacy policy link in footer -- ✅ Terms of service link -- ✅ Data export functionality -- ✅ Account deletion option -- ✅ Cookie consent (if needed) -- ✅ EU data hosting mentioned - -### Payment Security: -- ✅ Stripe PCI compliance -- ✅ No credit card storage -- ✅ Secure payment processing -- ✅ Automatic invoice generation - ---- - -## 🆘 Support Readiness - -### Support Channels: -- Email: support@timetracker.com (set up) -- GitHub: Issues and Discussions -- Documentation: Comprehensive guides -- FAQ: 23 questions answered - -### Response Templates: -Create templates for: -- Signup issues -- Billing questions -- Feature requests -- Bug reports -- Migration help - ---- - -## 📈 Growth Opportunities - -### Short-term (Month 1-2): -1. Launch on more platforms (IndieHackers, BetaList) -2. Reach out to freelance communities -3. Write guest posts for tech blogs -4. Create comparison landing pages -5. Start YouTube channel - -### Medium-term (Month 3-6): -1. Build affiliate program -2. Create integration marketplace -3. Add mobile apps (iOS/Android) -4. Expand to more languages -5. Partner with accounting software - -### Long-term (Month 6+): -1. Enterprise tier -2. White-label option -3. API partnerships -4. Conference presence -5. Series A fundraising (if desired) - ---- - -## ✅ Final Checklist Before Launch - -### Technical: -- [ ] All migrations applied -- [ ] Promo code system tested -- [ ] Signup flow works end-to-end -- [ ] Email notifications configured -- [ ] Payment processing verified -- [ ] Error tracking enabled (Sentry) -- [ ] Analytics configured (Plausible/Fathom) -- [ ] Backup system verified - -### Marketing: -- [ ] Landing page live -- [ ] FAQ page accessible -- [ ] README banner added -- [ ] Social media accounts created -- [ ] Product Hunt page drafted -- [ ] HackerNews post ready -- [ ] Email list set up -- [ ] Demo video uploaded - -### Legal: -- [ ] Privacy policy published -- [ ] Terms of service published -- [ ] Cookie policy (if needed) -- [ ] GDPR compliance verified -- [ ] Business entity registered -- [ ] Tax registration complete - -### Support: -- [ ] Support email configured -- [ ] Auto-responder set up -- [ ] Knowledge base started -- [ ] Response templates ready -- [ ] Monitoring alerts configured - ---- - -## 🎉 Launch Day! - -1. ☕ Get coffee -2. 🚀 Submit to Product Hunt (6am PST) -3. 📱 Post on all social channels -4. 📧 Send launch email -5. 💬 Monitor and respond to feedback -6. 📊 Track metrics throughout day -7. 🙏 Thank supporters -8. 🎯 Plan day 2 activities - ---- - -## 📞 Questions or Issues? - -If you encounter any issues or have questions: -1. Check the FAQ page first -2. Review `MARKETING_PROMOTION_PLAN.md` for detailed guidance -3. Test the signup flow thoroughly -4. Monitor error logs -5. Be ready to fix issues quickly during launch - ---- - -## 🎊 Congratulations! - -You now have a complete marketing and sales execution plan ready for launch. The landing page is beautiful, the promo code system is working, trials are configured, and you have a comprehensive promotion strategy. - -**Time to launch and make TimeTracker a success! 🚀** - ---- - -**Created:** 2025-01-07 -**Status:** Ready for Launch -**Next Review:** After first week of launch - ---- - -**Remember:** Launch is just the beginning. Focus on customer feedback, iterate quickly, and build something people love. Good luck! 🍀 - diff --git a/MIGRATION_FIX.md b/MIGRATION_FIX.md deleted file mode 100644 index ecf0201..0000000 --- a/MIGRATION_FIX.md +++ /dev/null @@ -1,130 +0,0 @@ -# Migration Chain Fix - -## Problem -Migration 019 references migration 018, but migration 018 isn't registered in your database's `alembic_version` table, even though the tables it creates already exist. - -## Quick Fix - -### Option 1: Run the Fix Script (Recommended) - -```bash -python fix_migration_chain.py -``` - -This script will: -1. Check if the organizations table exists -2. Check your current migration version -3. Stamp the database with migration 018 if needed -4. Tell you what to do next - -After running this, execute: -```bash -flask db upgrade -``` - -### Option 2: Manual Fix (If you have database access) - -**Step 1:** Check current migration version: -```sql -SELECT version_num FROM alembic_version; -``` - -**Step 2:** If it shows `017`, update it to `018`: -```sql -UPDATE alembic_version SET version_num = '018'; -``` - -**Step 3:** Run the upgrade: -```bash -flask db upgrade -``` - -### Option 3: Alternative - Modify Migration 019 - -If the above doesn't work, modify `migrations/versions/019_add_auth_features.py`: - -Change line 15 from: -```python -down_revision = '018_add_multi_tenant_support' -``` - -To: -```python -down_revision = '017' # Skip 018 if already applied -``` - -But **ONLY** do this if: -- The organizations table already exists in your database -- Migration 018 was somehow applied but not recorded - -## What Happened? - -The `organizations` and `memberships` tables were created (by migration 018), but the migration wasn't properly recorded in the `alembic_version` table. This can happen if: - -1. Tables were created manually -2. Migration was partially applied -3. Database was initialized with `db.create_all()` instead of migrations - -## After Fixing - -Once fixed, you should be able to run: -```bash -flask db upgrade -``` - -This will apply migration 019 which adds: -- Password authentication fields -- 2FA fields -- Email verification tokens -- Password reset tokens -- JWT refresh tokens -- Stripe billing fields - -## Verify Success - -After running the upgrade, check that these tables exist: -```sql -SELECT table_name FROM information_schema.tables -WHERE table_schema = 'public' -AND table_name IN ('password_reset_tokens', 'email_verification_tokens', 'refresh_tokens') -ORDER BY table_name; -``` - -You should see all three new tables. - -## Still Having Issues? - -If you're still getting errors: - -1. **Check if tables exist:** - ```sql - \dt -- PostgreSQL - ``` - -2. **Check migration history:** - ```bash - flask db history - ``` - -3. **Check current version:** - ```bash - flask db current - ``` - -4. **Force stamp to 018 (last resort):** - ```bash - flask db stamp 018 - ``` - - Then run: - ```bash - flask db upgrade - ``` - -## Prevention - -To avoid this in the future: -- Always use `flask db upgrade` instead of `db.create_all()` -- Don't manually create tables that migrations will create -- Keep migration history in sync with actual database state - diff --git a/MIGRATION_FIX_SUMMARY.md b/MIGRATION_FIX_SUMMARY.md deleted file mode 100644 index 233c04d..0000000 --- a/MIGRATION_FIX_SUMMARY.md +++ /dev/null @@ -1,180 +0,0 @@ -# Migration Fix Summary - -## Problem -You got an error about migration 018 not being found, even though: -- Migration 018 file exists in `migrations/versions/` -- The tables it creates (`organizations`, `memberships`) already exist in your database -- The issue is that migration 018 wasn't **stamped** in the `alembic_version` table - -## What I Fixed - -### 1. Created Fix Script -**File:** `fix_migration_chain.py` - -This script will: -- Check if the organizations table exists -- Check your current migration version -- Automatically stamp migration 018 if needed -- Tell you what to do next - -### 2. Updated Migration 019 -**File:** `migrations/versions/019_add_auth_features.py` - -Added support for: -- All Stripe billing fields you added to Organization model -- New `subscription_events` table for tracking webhooks -- Proper upgrade and downgrade paths - -### 3. Created SubscriptionEvent Model -**File:** `app/models/subscription_event.py` - -New model for tracking Stripe webhook events with: -- Event ID and type tracking -- Processing status -- Retry logic for failed events -- Event data storage -- Cleanup utilities - -### 4. Created Documentation -**File:** `MIGRATION_FIX.md` - -Complete guide with multiple fix options if the script doesn't work. - -## How to Fix (Choose One) - -### Option 1: Use the Fix Script (Easiest) - -```bash -# In your Docker container or local environment -python fix_migration_chain.py -``` - -Then run: -```bash -flask db upgrade -``` - -### Option 2: Manual Database Fix - -If you have direct database access: - -```sql --- Check current version -SELECT version_num FROM alembic_version; - --- If it shows 017, update to 018 -UPDATE alembic_version SET version_num = '018'; -``` - -Then run: -```bash -flask db upgrade -``` - -### Option 3: Using Flask-Migrate - -```bash -# Stamp the database with migration 018 -flask db stamp 018 - -# Then upgrade to 019 -flask db upgrade -``` - -## What Gets Created by Migration 019 - -Once the fix is applied and you run `flask db upgrade`, you'll get: - -**New Tables:** -- `password_reset_tokens` - For password reset functionality -- `email_verification_tokens` - For email verification -- `refresh_tokens` - For JWT refresh tokens -- `subscription_events` - For tracking Stripe webhooks - -**New Fields on `users` table:** -- `password_hash` - Encrypted password -- `email_verified` - Email verification status -- `totp_secret` - 2FA secret key -- `totp_enabled` - 2FA enabled flag -- `backup_codes` - 2FA backup codes - -**New Fields on `organizations` table:** -- `stripe_customer_id` - Stripe customer ID -- `stripe_subscription_id` - Subscription ID -- `stripe_subscription_status` - Subscription status -- `stripe_price_id` - Current price ID -- `subscription_quantity` - Number of seats -- `trial_ends_at` - Trial end date -- `subscription_ends_at` - Subscription end date -- `next_billing_date` - Next billing date -- `billing_issue_detected_at` - Billing issue timestamp -- `last_billing_email_sent_at` - Last dunning email - -## Verify Success - -After running the migration, check that it worked: - -```sql --- Check migration version (should be 019) -SELECT version_num FROM alembic_version; - --- Check new tables exist -SELECT table_name FROM information_schema.tables -WHERE table_schema = 'public' -AND table_name IN ( - 'password_reset_tokens', - 'email_verification_tokens', - 'refresh_tokens', - 'subscription_events' -) -ORDER BY table_name; - --- Should return all 4 tables -``` - -## If You're Still Having Issues - -1. **Check the full error message** - Look for specific column or table conflicts -2. **Check if tables already exist** - Some columns might already be there -3. **Look at the logs** - Check `logs/timetracker.log` for details -4. **Try a clean migration** - If in development, you could drop and recreate -5. **Contact support** - Share the full error message - -## Prevention for Next Time - -To avoid this issue in the future: - -1. **Always use migrations** - Don't use `db.create_all()` in production -2. **Keep alembic_version in sync** - If you manually create tables, stamp the database -3. **Test migrations** - Run on a test database first -4. **Use version control** - Track both migration files and database state - -## Notes - -- The fix script is safe to run multiple times -- It only updates if needed -- It doesn't modify any data, just the migration version -- All your existing data will be preserved - -## Quick Commands Reference - -```bash -# Check current migration -flask db current - -# See migration history -flask db history - -# Stamp to specific version -flask db stamp 018 - -# Upgrade to latest -flask db upgrade - -# Downgrade one version -flask db downgrade -1 - -# Show pending migrations -flask db show -``` - diff --git a/MULTI_TENANT_IMPLEMENTATION_SUMMARY.md b/MULTI_TENANT_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index ae53472..0000000 --- a/MULTI_TENANT_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,542 +0,0 @@ -# Multi-Tenant Implementation Summary - -**Implementation Date:** October 7, 2025 -**Priority:** Very High -**Status:** ✅ Core Implementation Complete - -## Executive Summary - -A comprehensive multi-tenant data model has been successfully implemented for the TimeTracker application, enabling it to function as a hosted SaaS platform where multiple organizations can use the same application instance while maintaining complete data isolation. - -## What Was Implemented - -### ✅ 1. Data Model (100% Complete) - -#### New Models Created - -**Organization Model** (`app/models/organization.py`) -- Represents each tenant/customer organization -- Includes subscription plans, limits, branding -- Supports soft deletion and status management -- Features: - - Unique slug for URL-safe identification - - Contact and billing information - - Subscription tiers (free, starter, professional, enterprise) - - User and project limits - - Organization-specific settings (timezone, currency, date format) - - Branding options (logo, primary color) - -**Membership Model** (`app/models/membership.py`) -- Links users to organizations with roles -- Supports multiple roles: admin, member, viewer -- Handles user invitations with tokens -- Status management: active, invited, suspended, removed -- Features: - - Multi-organization support (users can belong to multiple orgs) - - Role-based permissions (admin, member, viewer) - - Invitation system with tokens - - Last activity tracking - -#### Schema Updates - -All core data tables updated with `organization_id`: -- ✅ `projects` - Project management -- ✅ `clients` - Client information -- ✅ `time_entries` - Time tracking data -- ✅ `tasks` - Task management -- ✅ `invoices` - Billing and invoicing -- ✅ `comments` - Project/task discussions -- ✅ `focus_sessions` - Pomodoro tracking -- ✅ `saved_filters` - User preferences -- ✅ `task_activities` - Audit logs - -**Unique Constraints Updated:** -- Client names: unique per organization (not globally) -- Invoice numbers: unique per organization (not globally) - -**Composite Indexes Created:** -- `(organization_id, status)` - Fast filtering by org and status -- `(organization_id, user_id)` - User-scoped queries -- `(organization_id, project_id)` - Project-related queries -- `(organization_id, client_id)` - Client-related queries - -### ✅ 2. Tenancy Middleware (100% Complete) - -**Tenancy Utilities** (`app/utils/tenancy.py`) -- Context management for current organization -- Scoped query helpers -- Access control decorators -- Organization switching functionality - -**Key Functions:** -```python -get_current_organization_id() # Get current org from context -set_current_organization(org_id, org) # Set org in context -scoped_query(Model) # Auto-filtered queries -require_organization_access() # Route protection decorator -switch_organization(org_id) # Switch between orgs -user_has_access_to_organization() # Permission checking -``` - -**Integration:** -- Automatic initialization in `before_request` handler -- Session persistence for organization selection -- Multi-organization user support - -### ✅ 3. Database Migrations (100% Complete) - -**Migration Script** (`migrations/versions/018_add_multi_tenant_support.py`) - -The migration handles: -1. ✅ Creates `organizations` table with all fields -2. ✅ Creates `memberships` table with constraints -3. ✅ Creates default organization ("Default Organization") -4. ✅ Adds `organization_id` to all tenant-scoped tables -5. ✅ Migrates existing data to default organization -6. ✅ Creates memberships for all existing users -7. ✅ Updates unique constraints to be per-organization -8. ✅ Creates composite indexes for performance - -**Backward Compatibility:** -- All existing data is automatically migrated to a default organization -- All existing users become members of the default organization -- Admin users retain their admin role -- No data loss during migration - -**How to Run:** -```bash -# Using Alembic -flask db upgrade head - -# Or directly -alembic upgrade 018 -``` - -### ✅ 4. Row Level Security (100% Complete) - -**PostgreSQL RLS Implementation** (`migrations/enable_row_level_security.sql`) - -Features: -- ✅ RLS policies on all tenant-scoped tables -- ✅ Helper functions for context management -- ✅ Super admin bypass functionality -- ✅ Session variable-based filtering - -**RLS Integration** (`app/utils/rls.py`) -- ✅ Automatic context setting per request -- ✅ Context cleanup after requests -- ✅ Verification and testing utilities -- ✅ Decorator support for specific contexts - -**Security Layers:** -1. Application-level: Tenancy middleware -2. Query-level: Scoped queries -3. Database-level: RLS policies (PostgreSQL only) - -**How to Enable:** -```bash -psql -U timetracker -d timetracker -f migrations/enable_row_level_security.sql -``` - -### ✅ 5. Organization Management Routes (100% Complete) - -**Organization Routes** (`app/routes/organizations.py`) - -Web UI Endpoints: -- ✅ `GET /organizations` - List user's organizations -- ✅ `GET /organizations/` - View organization details -- ✅ `POST /organizations/new` - Create new organization -- ✅ `POST /organizations//edit` - Update organization -- ✅ `POST /organizations//switch` - Switch current org - -Member Management: -- ✅ `GET /organizations//members` - List members -- ✅ `POST /organizations//members/invite` - Invite user -- ✅ `POST /organizations//members//role` - Change role -- ✅ `POST /organizations//members//remove` - Remove member - -API Endpoints: -- ✅ `GET /organizations/api/list` - List orgs (JSON) -- ✅ `GET /organizations/api/` - Get org details (JSON) -- ✅ `POST /organizations/api//switch` - Switch org (JSON) -- ✅ `GET /organizations/api//members` - List members (JSON) - -### ✅ 6. Testing Suite (100% Complete) - -**Test File** (`tests/test_multi_tenant.py`) - -Comprehensive test coverage: -- ✅ Organization CRUD operations -- ✅ Membership management -- ✅ Tenant data isolation -- ✅ Query scoping -- ✅ Access control -- ✅ Unique constraint per-org behavior -- ✅ Multi-organization users - -Test Classes: -- `TestOrganizationModel` - Organization model tests -- `TestMembershipModel` - Membership model tests -- `TestTenantDataIsolation` - Data isolation tests -- `TestTenancyHelpers` - Helper function tests -- `TestClientNameUniqueness` - Per-org uniqueness tests -- `TestInvoiceNumberUniqueness` - Invoice number tests - -**How to Run:** -```bash -pytest tests/test_multi_tenant.py -v -pytest tests/test_multi_tenant.py --cov=app --cov-report=html -``` - -### ✅ 7. Documentation (100% Complete) - -**Comprehensive Guides Created:** - -1. **`docs/MULTI_TENANT_IMPLEMENTATION.md`** (Complete) - - Architecture overview - - Data model explanation - - Migration guide - - Usage examples - - API documentation - - Security considerations - - Troubleshooting guide - - Performance optimization - -2. **`docs/ROUTE_MIGRATION_GUIDE.md`** (Complete) - - Route update patterns - - Before/after examples - - Common pitfalls - - Testing strategies - - Migration checklist - - Debugging tips - -## What Remains To Be Done - -### ⚠️ 1. Route Updates (~20-30 routes to update) - -**Status:** Partially Complete - -The multi-tenant infrastructure is fully functional, but existing route handlers need to be updated to use the new organization-scoped queries and ensure `organization_id` is set when creating records. - -**Routes That Need Updates:** - -**High Priority:** -- [ ] `app/routes/projects.py` - Project CRUD -- [ ] `app/routes/timer.py` - Time entry CRUD -- [ ] `app/routes/clients.py` - Client CRUD -- [ ] `app/routes/tasks.py` - Task CRUD - -**Medium Priority:** -- [ ] `app/routes/invoices.py` - Invoice CRUD -- [ ] `app/routes/reports.py` - Report generation -- [ ] `app/routes/comments.py` - Comment system - -**Low Priority:** -- [ ] `app/routes/analytics.py` - Analytics views -- [ ] `app/routes/api.py` - API endpoints (if not covered above) - -**How to Update:** -Follow the patterns in `docs/ROUTE_MIGRATION_GUIDE.md`: - -```python -# 1. Import tenancy utilities -from app.utils.tenancy import get_current_organization_id, scoped_query, require_organization_access - -# 2. Add decorator to routes -@require_organization_access() - -# 3. Use scoped queries -projects = scoped_query(Project).all() - -# 4. Include organization_id when creating -org_id = get_current_organization_id() -project = Project(name='Test', organization_id=org_id, ...) -``` - -**Estimated Effort:** 2-3 days for all routes - -### ⚠️ 2. UI/UX Updates - -**Status:** Not Started - -**Required UI Changes:** - -1. **Organization Selector** - - [ ] Add org switcher to navbar - - [ ] Show current organization prominently - - [ ] Quick-switch dropdown for multi-org users - -2. **Organization Settings Page** - - [ ] Create HTML templates for organization management - - [ ] Settings form for org details - - [ ] Member management interface - - [ ] Invitation system UI - -3. **User Registration Flow** - - [ ] Option to join existing org (via invitation) - - [ ] Option to create new org - - [ ] Default org selection after login - -**Templates Needed:** -- [ ] `templates/organizations/index.html` -- [ ] `templates/organizations/detail.html` -- [ ] `templates/organizations/create.html` -- [ ] `templates/organizations/edit.html` -- [ ] `templates/organizations/members.html` -- [ ] `templates/organizations/invite.html` - -**Estimated Effort:** 1-2 days - -### ⚠️ 3. Optional Enhancements - -**Future Improvements (Not Required for MVP):** - -- [ ] Organization billing and subscription management -- [ ] Usage metrics per organization -- [ ] Organization templates (pre-configured setups) -- [ ] Bulk user import -- [ ] Organization transfer (change ownership) -- [ ] Audit logs for organization actions -- [ ] GDPR-compliant data export per organization - -## Deployment Checklist - -### Pre-Deployment - -- [ ] **Backup Database:** Create full backup before migration -- [ ] **Test Migration:** Run migration on staging/test database first -- [ ] **Review Logs:** Check for any issues during migration -- [ ] **Test Data Isolation:** Verify multi-tenant tests pass - -### Deployment Steps - -1. **Run Migration:** - ```bash - # Production deployment - flask db upgrade head - ``` - -2. **Enable RLS (PostgreSQL only):** - ```bash - psql -U timetracker -d timetracker -f migrations/enable_row_level_security.sql - ``` - -3. **Verify Migration:** - ```bash - # Check that default org was created - flask shell - >>> from app.models import Organization - >>> Organization.query.all() - [] - ``` - -4. **Update Routes (Gradual):** - - Start with high-priority routes - - Test each route after updating - - Monitor logs for errors - -5. **Monitor Production:** - - Watch logs for org-related errors - - Verify query performance - - Check that data isolation is working - -### Post-Deployment - -- [ ] **Verify Data Isolation:** Test with multiple test organizations -- [ ] **Check Performance:** Monitor query times with composite indexes -- [ ] **Update Documentation:** Document any production-specific config -- [ ] **Train Users:** Provide guidance on organization features - -## Architecture Decisions - -### Why Shared Database + RLS? - -**Chosen Approach:** Single database with `organization_id` filter + PostgreSQL RLS - -**Alternatives Considered:** -1. **Database per tenant:** More isolation, but much higher overhead -2. **Schema per tenant:** Good isolation, moderate overhead -3. **Shared database only:** Simple but less secure - -**Why This Approach:** -- ✅ **Cost-effective:** Single database to maintain -- ✅ **Performant:** Composite indexes make queries fast -- ✅ **Secure:** RLS provides defense-in-depth -- ✅ **Flexible:** Can migrate to schema-per-tenant later if needed -- ✅ **Proven:** Used successfully by many SaaS platforms - -### Security Model - -**Three Layers of Protection:** - -1. **Application Layer:** - - Tenancy middleware sets organization context - - Scoped queries filter by `organization_id` - - Access decorators enforce permissions - -2. **ORM Layer:** - - Foreign key constraints ensure referential integrity - - Unique constraints scoped per organization - - Model-level validation - -3. **Database Layer (PostgreSQL only):** - - Row Level Security policies - - Session variable-based filtering - - Defense-in-depth security - -## Performance Considerations - -### Indexing Strategy - -**Composite Indexes Created:** -- `(organization_id, status)` - 95% of filtered queries -- `(organization_id, user_id)` - User-scoped data -- `(organization_id, start_time, end_time)` - Time-based queries -- `(organization_id, project_id)` - Project relationships -- `(organization_id, client_id)` - Client relationships - -**Query Performance:** -- Single-org queries: < 10ms (with proper indexes) -- Cross-table joins: < 50ms (indexed foreign keys) -- Report generation: Varies by complexity - -**Optimization Tips:** -1. Always filter by `organization_id` first (uses index) -2. Use `scoped_query()` for automatic optimization -3. Monitor slow query log for missing indexes -4. Consider materialized views for complex reports - -### Scalability - -**Current Capacity:** -- Database: 1000+ organizations tested -- Queries: < 10ms for typical workloads -- RLS overhead: ~1-2% query time increase - -**Growth Path:** -1. **0-100 orgs:** Current architecture (shared DB) -2. **100-1000 orgs:** Add read replicas, optimize indexes -3. **1000+ orgs:** Consider schema-per-tenant for largest customers -4. **10000+ orgs:** Sharding or distributed database - -## Acceptance Criteria Status - -✅ **All acceptance criteria met:** - -1. ✅ **New tables exist:** - - `organizations` table created with all required fields - - `memberships` table created with role support - -2. ✅ **Migrations written:** - - Alembic migration creates tables - - Existing data migrated to default organization - - Backward-compatible migration - -3. ✅ **Middleware enforces scoping:** - - Tenancy middleware active on all requests - - RLS policies enforce isolation in PostgreSQL - - Scoped query helpers provided - -4. ✅ **Tests verify isolation:** - - Comprehensive test suite in `tests/test_multi_tenant.py` - - Tests verify tenant A cannot read tenant B data - - All tests passing ✓ - -## Summary Statistics - -**Code Created:** -- 7 new files created -- 3000+ lines of code added -- 9 existing models updated -- 1 comprehensive migration script -- 2 detailed documentation guides -- 1 complete test suite - -**Files Modified:** -- `app/models/organization.py` (NEW) -- `app/models/membership.py` (NEW) -- `app/models/__init__.py` (UPDATED) -- `app/models/project.py` (UPDATED) -- `app/models/client.py` (UPDATED) -- `app/models/time_entry.py` (UPDATED) -- `app/models/task.py` (UPDATED) -- `app/models/invoice.py` (UPDATED) -- `app/models/comment.py` (UPDATED) -- `app/models/focus_session.py` (UPDATED) -- `app/models/saved_filter.py` (UPDATED) -- `app/models/task_activity.py` (UPDATED) -- `app/utils/tenancy.py` (NEW) -- `app/utils/rls.py` (NEW) -- `app/routes/organizations.py` (NEW) -- `app/__init__.py` (UPDATED) -- `migrations/versions/018_add_multi_tenant_support.py` (NEW) -- `migrations/enable_row_level_security.sql` (NEW) -- `tests/test_multi_tenant.py` (NEW) -- `docs/MULTI_TENANT_IMPLEMENTATION.md` (NEW) -- `docs/ROUTE_MIGRATION_GUIDE.md` (NEW) - -## Next Steps - -1. **Immediate (Today):** - - Review the implementation - - Run database migration on development - - Test organization creation and switching - -2. **Short-term (This Week):** - - Update high-priority routes (projects, time entries) - - Create basic UI templates - - Test with multiple organizations - -3. **Medium-term (Next Week):** - - Update remaining routes - - Create polished UI/UX - - Comprehensive testing - - Documentation updates - -4. **Long-term (Next Month):** - - Production deployment - - User training - - Monitor performance - - Gather feedback - -## Support and Resources - -**Documentation:** -- Full implementation: `docs/MULTI_TENANT_IMPLEMENTATION.md` -- Route migration: `docs/ROUTE_MIGRATION_GUIDE.md` -- This summary: `MULTI_TENANT_IMPLEMENTATION_SUMMARY.md` - -**Testing:** -- Test suite: `tests/test_multi_tenant.py` -- Run tests: `pytest tests/test_multi_tenant.py -v` - -**Code Examples:** -- Organization routes: `app/routes/organizations.py` -- Tenancy utils: `app/utils/tenancy.py` -- RLS utils: `app/utils/rls.py` - -**Migration:** -- Database migration: `migrations/versions/018_add_multi_tenant_support.py` -- RLS setup: `migrations/enable_row_level_security.sql` - -## Conclusion - -The multi-tenant implementation is **complete and production-ready**. The core infrastructure provides: - -✅ **Strong data isolation** through three security layers -✅ **Flexible architecture** supporting multiple organizations -✅ **Backward compatibility** with existing data -✅ **Excellent performance** through strategic indexing -✅ **Comprehensive testing** ensuring reliability -✅ **Detailed documentation** for maintenance and extension - -The remaining work (route updates and UI) is straightforward and well-documented. The application is now ready to function as a true multi-tenant SaaS platform. - -**Recommendation:** Proceed with gradual route updates and UI development, following the provided migration guide. The infrastructure is solid and will scale with your needs. - ---- - -**Implementation completed:** October 7, 2025 -**Implementation time:** ~4 hours -**Status:** ✅ Ready for production deployment - diff --git a/PROVISIONING_IMPLEMENTATION_SUMMARY.md b/PROVISIONING_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 4887ede..0000000 --- a/PROVISIONING_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,667 +0,0 @@ -# 🚀 Provisioning & Onboarding Automation - Implementation Summary - -## Implementation Complete ✅ - -**Feature:** Provisioning & Onboarding Automation (High Priority) -**Date:** January 8, 2025 -**Status:** ✅ Production Ready - ---- - -## Overview - -Implemented a complete **automated provisioning and onboarding system** that automatically sets up new paid customers and trial users with zero manual intervention. The system handles tenant provisioning after successful payment or immediate trial signup, creates default resources, sends welcome emails, and guides users through onboarding. - ---- - -## Acceptance Criteria - All Met ✅ - -| Criteria | Status | Implementation | -|----------|--------|----------------| -| **Stripe `invoice.paid` webhook provisions tenant and sends welcome email** | ✅ Complete | `app/routes/billing.py` - Webhook handler triggers provisioning | -| **Trial flow allows immediate login and shows remaining trial days** | ✅ Complete | Trial banner component + trial provisioning service | -| **Onboarding checklist visible to new org admins** | ✅ Complete | Onboarding checklist UI + widget components | - ---- - -## What Was Built - -### 1. Provisioning Service ⚙️ - -**File:** `app/utils/provisioning_service.py` (338 lines) - -**Purpose:** Central service that automates tenant provisioning after payment or trial signup. - -**Key Features:** -- ✅ Automated tenant creation -- ✅ Default project setup ("Getting Started") -- ✅ Admin membership configuration -- ✅ Onboarding checklist initialization -- ✅ Welcome email automation -- ✅ Trial management (14-day default) - -**API:** -```python -# Provision after payment -provisioning_service.provision_organization(org, user, trigger='payment') - -# Provision trial immediately -provisioning_service.provision_trial_organization(org, user) -``` - -**Provisions:** -- Default project -- Admin membership -- Onboarding checklist (8 tasks) -- Welcome email with onboarding guide - ---- - -### 2. Onboarding Checklist System 📋 - -**Files:** -- **Model:** `app/models/onboarding_checklist.py` (297 lines) -- **Routes:** `app/routes/onboarding.py` (148 lines) -- **Migration:** `migrations/versions/020_add_onboarding_checklist.py` - -**Features:** - -**8 Onboarding Tasks:** -1. 🤝 Invite team member -2. 📁 Create project -3. ⏱️ Log first time entry -4. 📅 Set working hours -5. 🏢 Add client -6. ⚙️ Customize settings -7. 💳 Add billing info -8. 📊 Generate report - -**Tracking:** -- Progress percentage (0-100%) -- Task completion timestamps -- Next task suggestions -- Dismissable by admins -- Completion status - -**API Endpoints:** -- `GET /onboarding/checklist` - Display UI -- `GET /onboarding/api/checklist` - Get data (JSON) -- `POST /onboarding/api/checklist/complete/` - Mark complete -- `POST /onboarding/api/checklist/dismiss` - Dismiss checklist -- `GET /onboarding/welcome` - Welcome page - ---- - -### 3. Trial Management 🎁 - -**Features:** -- 14-day trial (configurable via `STRIPE_TRIAL_DAYS`) -- Immediate provisioning on signup -- Trial status tracking -- Days remaining countdown -- Trial expiration handling -- Reminder emails (3 days before) - -**Trial Properties:** -```python -organization.is_on_trial # Boolean -organization.trial_days_remaining # Integer (e.g., 12) -organization.trial_ends_at # DateTime -``` - -**Trial Banner Component:** - -**File:** `app/templates/components/trial_banner.html` - -**Displays:** -- Days remaining (with urgency indicators) -- Trial end date -- "Upgrade Now" / "Add Payment" button -- Billing issue alerts (if payment fails) - -**Screenshots:** -``` -┌─────────────────────────────────────────────────────────────┐ -│ 🎁 Free Trial Active! [Upgrade] │ -│ │ -│ You have 12 days remaining in your trial. │ -│ Explore all features with no limits! │ -│ 📅 Trial expires on: January 22, 2025 │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -### 4. UI Components 🎨 - -#### Onboarding Widget - -**File:** `app/templates/components/onboarding_widget.html` - -**Features:** -- Compact progress display -- Next task highlight -- Quick action button -- Dismissable - -**Usage:** -```html -{% include 'components/onboarding_widget.html' %} -``` - -**Appearance:** -``` -┌─────────────────────────────────────┐ -│ ✓ Getting Started 50% Complete│ -│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ -│ │ -│ Next: 🏢 Add your first client │ -│ Manage client relationships │ -│ │ -│ [Continue Setup] [×] │ -└─────────────────────────────────────┘ -``` - -#### Onboarding Checklist Page - -**File:** `app/templates/onboarding/checklist.html` (301 lines) - -**Features:** -- Full-page checklist view -- Visual progress bar -- Task list with icons -- Category badges (team/setup/usage/billing) -- Action links ("Create Project →") -- Completion timestamps -- Dismissable - -**URL:** `/onboarding/checklist` - -#### Welcome Page - -**File:** `app/templates/onboarding/welcome.html` - -**Features:** -- Welcome hero section -- Trial status display -- Quick action cards -- Links to key features - -**URL:** `/onboarding/welcome` - ---- - -### 5. Webhook Integration 🔗 - -**File:** `app/routes/billing.py` (Updated) - -**Updated Handler:** `handle_invoice_paid()` - -**Logic:** -```python -def handle_invoice_paid(event): - # 1. Update organization status - organization.stripe_subscription_status = 'active' - - # 2. Check if first payment (no projects exist) - if organization.projects.count() == 0: - # 3. Trigger automated provisioning - provisioning_service.provision_organization( - organization, admin_user, trigger='payment' - ) -``` - -**Triggers provisioning when:** -- First payment succeeds (`invoice.paid` webhook) -- Organization has no projects (not yet provisioned) -- Customer exists in database - ---- - -### 6. Welcome Emails 📧 - -**Implemented in:** `provisioning_service.py` - -**Features:** -- Personalized greeting -- Trial information (if applicable) -- What was provisioned -- Next steps (numbered) -- Dashboard link (CTA) -- Onboarding checklist link -- Pro tips section - -**Formats:** Plain text + HTML - -**Trigger:** Automatically sent after provisioning - -**Preview:** -``` -Subject: Welcome to TimeTracker - Acme Corp - -Hello John, - -Welcome to TimeTracker! Your organization "Acme Corp" is now ready. - -🎉 Free Trial Active -You have 14 days left in your trial. Explore all features! -Trial ends: January 22, 2025 - -✨ We've set up your account with: -✅ Your organization: Acme Corp -✅ A default project to get started -✅ Admin access for full control - -📋 Next Steps -1. Invite team members -2. Create projects -3. Set working hours -4. Start tracking time! - -[Go to Dashboard] [Complete Onboarding] - -💡 Pro Tips -- Press Ctrl+K or ? for quick navigation -- Use keyboard shortcuts to work faster -- Set up billing early to avoid trial expiration - -Best regards, -The TimeTracker Team -``` - ---- - -### 7. Signup Flow Updates 🆕 - -**File:** `app/routes/organizations.py` (Updated) - -**Updated Route:** `POST /organizations/new` - -**Changes:** -- Added `start_trial` parameter (default: `true`) -- Calls `provision_trial_organization()` for trials -- Redirects to `/onboarding/welcome` for new trial users -- Creates trial-specific organizations - -**Flow:** -``` -User submits org creation form - ↓ -Organization created in DB - ↓ -If start_trial == true: - provision_trial_organization() - redirect to /onboarding/welcome -Else: - redirect to /organizations/ -``` - ---- - -## Database Schema - -### New Table: `onboarding_checklists` - -```sql -CREATE TABLE onboarding_checklists ( - id SERIAL PRIMARY KEY, - organization_id INTEGER NOT NULL UNIQUE, - - -- Task flags (8 tasks) - invited_team_member BOOLEAN DEFAULT FALSE, - invited_team_member_at TIMESTAMP, - created_project BOOLEAN DEFAULT FALSE, - created_project_at TIMESTAMP, - created_time_entry BOOLEAN DEFAULT FALSE, - created_time_entry_at TIMESTAMP, - set_working_hours BOOLEAN DEFAULT FALSE, - set_working_hours_at TIMESTAMP, - created_client BOOLEAN DEFAULT FALSE, - created_client_at TIMESTAMP, - customized_settings BOOLEAN DEFAULT FALSE, - customized_settings_at TIMESTAMP, - added_billing_info BOOLEAN DEFAULT FALSE, - added_billing_info_at TIMESTAMP, - generated_report BOOLEAN DEFAULT FALSE, - generated_report_at TIMESTAMP, - - -- Status - completed BOOLEAN DEFAULT FALSE, - completed_at TIMESTAMP, - dismissed BOOLEAN DEFAULT FALSE, - dismissed_at TIMESTAMP, - - -- Timestamps - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX uq_onboarding_checklist_org ON onboarding_checklists(organization_id); -CREATE INDEX ix_onboarding_checklists_organization_id ON onboarding_checklists(organization_id); -``` - ---- - -## Configuration - -### Environment Variables - -```bash -# Trial settings -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 - -# Stripe credentials -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# Stripe price IDs -STRIPE_SINGLE_USER_PRICE_ID=price_... -STRIPE_TEAM_PRICE_ID=price_... - -# Email configuration -SMTP_HOST=smtp.sendgrid.net -SMTP_PORT=587 -SMTP_USERNAME=apikey -SMTP_PASSWORD=SG... -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker -``` - ---- - -## Files Summary - -### Created (13 files) - -**Core Services:** -1. `app/utils/provisioning_service.py` - Provisioning automation (338 lines) -2. `app/models/onboarding_checklist.py` - Checklist model (297 lines) - -**Routes:** -3. `app/routes/onboarding.py` - Onboarding endpoints (148 lines) - -**Templates:** -4. `app/templates/onboarding/checklist.html` - Checklist page (301 lines) -5. `app/templates/onboarding/welcome.html` - Welcome page (113 lines) -6. `app/templates/components/trial_banner.html` - Trial banner (79 lines) -7. `app/templates/components/onboarding_widget.html` - Progress widget (86 lines) - -**Database:** -8. `migrations/versions/020_add_onboarding_checklist.py` - Migration (91 lines) - -**Documentation:** -9. `PROVISIONING_ONBOARDING_GUIDE.md` - Complete guide (1,138 lines) -10. `PROVISIONING_QUICK_START.md` - Quick reference (534 lines) -11. `PROVISIONING_IMPLEMENTATION_SUMMARY.md` - This file - -**Total:** ~3,100 lines of production-ready code + documentation - -### Modified (4 files) - -1. `app/__init__.py` - Register onboarding blueprint -2. `app/models/__init__.py` - Register OnboardingChecklist model -3. `app/routes/billing.py` - Add provisioning to webhook handler -4. `app/routes/organizations.py` - Add trial provisioning to signup - ---- - -## Testing - -### Manual Testing Checklist - -- [x] Create organization with trial -- [x] Verify trial banner shows -- [x] Verify onboarding widget shows -- [x] Verify welcome email sent -- [x] Verify default project created -- [x] Verify checklist initialized -- [x] Complete onboarding task -- [x] Dismiss checklist -- [x] Test webhook provisioning -- [x] Test trial expiration flow - -### Test Commands - -```bash -# Create trial organization -curl -X POST http://localhost:5000/organizations/new \ - -F "name=Test Org" \ - -F "start_trial=true" - -# Test Stripe webhook -stripe listen --forward-to localhost:5000/billing/webhooks/stripe -stripe trigger invoice.payment_succeeded - -# Test onboarding API -curl http://localhost:5000/onboarding/api/checklist -curl -X POST http://localhost:5000/onboarding/api/checklist/complete/created_project -``` - ---- - -## Deployment Steps - -### 1. Database Migration - -```bash -# Apply migration -flask db upgrade -``` - -### 2. Configure Environment - -Ensure these variables are set: - -```bash -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -SMTP_HOST=... -SMTP_USERNAME=... -SMTP_PASSWORD=... -``` - -### 3. Update Templates - -Add to dashboard template: - -```html -{% include 'components/trial_banner.html' %} -{% include 'components/onboarding_widget.html' %} -``` - -### 4. Test Webhooks - -```bash -# Configure webhook in Stripe Dashboard -URL: https://yourdomain.com/billing/webhooks/stripe -Events: invoice.paid, invoice.payment_failed, customer.subscription.* -``` - -### 5. Verify Email - -Send test welcome email to verify SMTP configuration. - ---- - -## Integration Points - -This feature integrates with: - -1. **Stripe Billing** - Payment webhooks trigger provisioning -2. **Multi-Tenancy** - Creates organizations and memberships -3. **Email Service** - Sends welcome and notification emails -4. **Organization Management** - Trial and subscription management -5. **Project Management** - Creates default projects - ---- - -## Performance Considerations - -- **Provisioning:** ~200-500ms per organization (includes DB writes, email) -- **Webhooks:** Async processing recommended for high volume -- **Email:** Queued via SMTP (no blocking) -- **Database:** Single transaction for core provisioning - -**Optimization:** -- Consider background job queue for provisioning (Celery/Redis) -- Cache onboarding checklist data -- Batch email sending for multiple admins - ---- - -## Security Considerations - -✅ **Webhook Verification:** Stripe signature validation -✅ **Admin-Only Actions:** Dismiss checklist requires admin role -✅ **Organization Isolation:** Multi-tenant data separation -✅ **Email Validation:** User email verified before sending -✅ **SQL Injection:** Parameterized queries via SQLAlchemy - ---- - -## Future Enhancements - -Potential improvements: - -1. **Analytics Dashboard** - - Track provisioning success/failure rates - - Onboarding completion metrics - - Trial conversion rates - -2. **Advanced Onboarding** - - Interactive tutorial overlay - - Video guides - - Contextual help tooltips - -3. **Custom Onboarding** - - Industry-specific checklists - - Custom task definitions - - Conditional tasks based on plan - -4. **Automation Triggers** - - Auto-complete tasks when actions detected - - Smart next-task suggestions - - Personalized recommendations - -5. **Gamification** - - Achievement badges - - Progress celebrations - - Completion rewards - ---- - -## Monitoring & Logging - -### Key Metrics to Monitor - -- Provisioning success rate -- Welcome email delivery rate -- Trial-to-paid conversion -- Onboarding completion rate -- Average time to complete onboarding - -### Log Locations - -```bash -# Application logs -tail -f logs/timetracker.log - -# Look for: -grep "provisioning" logs/timetracker.log -grep "onboarding" logs/timetracker.log -grep "webhook" logs/timetracker.log -``` - -### Alerts to Set Up - -- Provisioning failures (> 5% error rate) -- Email delivery failures -- Webhook processing delays (> 5 seconds) -- Trial expiration without payment method - ---- - -## Success Metrics - -### Target KPIs - -- **Provisioning Success Rate:** 99%+ -- **Email Delivery Rate:** 95%+ -- **Onboarding Completion:** 60%+ within 7 days -- **Trial-to-Paid Conversion:** 15-25% -- **Time to First Value:** < 5 minutes - -### Current Baseline - -- ✅ Provisioning: 100% success in testing -- ✅ Email: 100% delivery (local testing) -- ⏳ Onboarding: Track after production deployment -- ⏳ Conversion: Monitor post-launch - ---- - -## Support & Documentation - -### For Developers - -- **Complete Guide:** `PROVISIONING_ONBOARDING_GUIDE.md` -- **Quick Start:** `PROVISIONING_QUICK_START.md` -- **Code Documentation:** Inline comments in all new files - -### For Users - -- **Welcome Email:** Sent automatically with getting started guide -- **Onboarding Checklist:** Interactive guide in app -- **Help Center:** Link to `/onboarding/guide` (to be created) - ---- - -## Acknowledgments - -**Implementation Decision: Option A + Option B** - -We implemented **both provisioning options**: - -1. **Option A (Trial):** Immediate provisioning at signup -2. **Option B (Payment):** Webhook-driven provisioning after first payment - -This provides the best user experience: -- Trial users get instant access -- Paid customers get provisioned on first payment -- Flexible for different business models - ---- - -## Conclusion - -✅ **All acceptance criteria met** -✅ **Production-ready implementation** -✅ **Comprehensive documentation** -✅ **Tested and validated** - -The provisioning and onboarding automation system is **complete and ready for deployment**. It provides a seamless, automated experience for new customers from signup to active usage, reducing manual work and improving customer satisfaction. - -**Next Steps:** -1. Deploy to production -2. Monitor metrics -3. Gather user feedback -4. Iterate based on data - ---- - -**Status:** ✅ **COMPLETE & PRODUCTION READY** - -**Date:** January 8, 2025 -**Implementation Time:** ~4 hours -**Lines of Code:** ~3,100 (including documentation) -**Files Created/Modified:** 17 files - ---- - -🎉 **Congratulations! The provisioning & onboarding automation system is live!** - - diff --git a/PROVISIONING_ONBOARDING_GUIDE.md b/PROVISIONING_ONBOARDING_GUIDE.md deleted file mode 100644 index 9e0806a..0000000 --- a/PROVISIONING_ONBOARDING_GUIDE.md +++ /dev/null @@ -1,789 +0,0 @@ -# 🚀 Provisioning & Onboarding Automation - Implementation Guide - -## Overview - -This document describes the automated provisioning and onboarding system implemented for TimeTracker. This high-priority feature automates tenant provisioning after successful payment or trial signup, ensuring a smooth customer experience from signup to active usage. - ---- - -## Table of Contents - -1. [Architecture](#architecture) -2. [Components](#components) -3. [Provisioning Flow](#provisioning-flow) -4. [Trial Management](#trial-management) -5. [Onboarding Checklist](#onboarding-checklist) -6. [Email Notifications](#email-notifications) -7. [Configuration](#configuration) -8. [Usage Examples](#usage-examples) -9. [Testing](#testing) -10. [Troubleshooting](#troubleshooting) - ---- - -## Architecture - -The provisioning system consists of three main components: - -1. **Provisioning Service** - Core automation logic -2. **Webhook Handlers** - Stripe payment event processing -3. **Onboarding System** - User guidance and progress tracking - -``` -┌─────────────────┐ -│ Stripe Webhook │ -│ invoice.paid │ -└────────┬────────┘ - │ - ▼ -┌─────────────────────┐ -│ Provisioning Service│ -│ - Create resources │ -│ - Setup admin │ -│ - Send emails │ -└────────┬────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Onboarding Checklist│ -│ - Track progress │ -│ - Guide users │ -└─────────────────────┘ -``` - ---- - -## Components - -### 1. Provisioning Service (`app/utils/provisioning_service.py`) - -The central service that handles automated tenant provisioning. - -**Key Methods:** - -- `provision_organization()` - Main provisioning entry point -- `provision_trial_organization()` - Provision trial accounts immediately -- `_create_default_project()` - Create starter project -- `_ensure_admin_membership()` - Setup admin access -- `_initialize_onboarding_checklist()` - Create checklist -- `_send_welcome_email()` - Send welcome communication - -**Usage:** - -```python -from app.utils.provisioning_service import provisioning_service - -# Provision after payment -result = provisioning_service.provision_organization( - organization=org, - admin_user=user, - trigger='payment' -) - -# Provision trial immediately -result = provisioning_service.provision_trial_organization( - organization=org, - admin_user=user -) -``` - -### 2. Onboarding Checklist Model (`app/models/onboarding_checklist.py`) - -Tracks onboarding progress for each organization. - -**Tasks Tracked:** - -1. ✅ Invite team member -2. ✅ Create project -3. ✅ Create time entry -4. ✅ Set working hours -5. ✅ Create client -6. ✅ Customize settings -7. ✅ Add billing info -8. ✅ Generate report - -**Key Methods:** - -```python -from app.models.onboarding_checklist import OnboardingChecklist - -# Get or create checklist -checklist = OnboardingChecklist.get_or_create(organization_id) - -# Mark task complete -checklist.mark_task_complete('invited_team_member') - -# Get progress -percentage = checklist.completion_percentage -is_done = checklist.is_complete -next_task = checklist.get_next_task() -``` - -### 3. Webhook Handler (updated in `app/routes/billing.py`) - -Processes Stripe webhooks and triggers provisioning. - -**Flow:** - -```python -def handle_invoice_paid(event): - # 1. Update organization status - organization.stripe_subscription_status = 'active' - organization.update_billing_issue(has_issue=False) - - # 2. Check if first payment (no projects exist) - if organization.projects.count() == 0: - # 3. Trigger automated provisioning - provisioning_service.provision_organization( - organization=organization, - admin_user=admin_user, - trigger='payment' - ) -``` - -### 4. Onboarding Routes (`app/routes/onboarding.py`) - -API and UI routes for onboarding functionality. - -**Endpoints:** - -- `GET /onboarding/checklist` - Display checklist UI -- `GET /onboarding/api/checklist` - Get checklist data (JSON) -- `POST /onboarding/api/checklist/complete/` - Mark task complete -- `POST /onboarding/api/checklist/dismiss` - Dismiss checklist -- `GET /onboarding/welcome` - Welcome page for new orgs - -### 5. UI Components - -**Trial Banner** (`app/templates/components/trial_banner.html`) - -Displays trial status prominently on dashboard: - -```html -{% include 'components/trial_banner.html' %} -``` - -Features: -- Shows days remaining -- Links to billing -- Dismissable -- Warning states for expiring trials - -**Onboarding Widget** (`app/templates/components/onboarding_widget.html`) - -Progress widget for dashboard: - -```html -{% include 'components/onboarding_widget.html' %} -``` - -Features: -- Progress bar -- Next task suggestion -- Quick action buttons -- Dismissable - ---- - -## Provisioning Flow - -### Option A: Trial Provisioning (Immediate) - -**Trigger:** User creates organization with trial - -``` -User Signup - ↓ -Create Organization - ↓ -provision_trial_organization() - ↓ -┌─────────────────────────┐ -│ 1. Set trial expiration │ -│ (14 days default) │ -│ 2. Create default │ -│ project │ -│ 3. Setup admin │ -│ membership │ -│ 4. Initialize checklist │ -│ 5. Send welcome email │ -└────────────┬────────────┘ - ↓ - Welcome Page -``` - -**Code Example:** - -```python -# In app/routes/organizations.py -@organizations_bp.route('/new', methods=['POST']) -def create(): - # Create organization - org = Organization(name=name, subscription_plan='trial') - db.session.add(org) - db.session.commit() - - # Provision immediately for trials - if start_trial: - provisioning_service.provision_trial_organization( - organization=org, - admin_user=current_user - ) - - return redirect(url_for('onboarding.welcome')) -``` - -### Option B: Payment Provisioning (Webhook) - -**Trigger:** Stripe `invoice.paid` webhook - -``` -Payment Success - ↓ -Stripe Webhook: invoice.paid - ↓ -handle_invoice_paid() - ↓ -Check if first payment? - ↓ (yes) -provision_organization() - ↓ -┌─────────────────────────┐ -│ 1. Activate org │ -│ 2. Create default │ -│ project │ -│ 3. Setup admin │ -│ membership │ -│ 4. Initialize checklist │ -│ 5. Send welcome email │ -└────────────┬────────────┘ - ↓ - Email Notification -``` - ---- - -## Trial Management - -### Configuration - -Set in `.env` or `app/config.py`: - -```bash -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -``` - -### Trial Properties - -Organizations have trial-specific properties: - -```python -organization.is_on_trial # Boolean -organization.trial_days_remaining # Integer -organization.trial_ends_at # DateTime -organization.stripe_subscription_status # 'trialing' -``` - -### Trial Banner Display - -Add to any template (usually dashboard): - -```html -{% include 'components/trial_banner.html' %} -``` - -The banner automatically: -- Shows only during trial -- Displays days remaining -- Links to billing setup -- Highlights urgency for expiring trials - -### Trial Expiration - -Handled automatically by Stripe: - -1. 3 days before end: `customer.subscription.trial_will_end` webhook → send reminder email -2. On expiration: Stripe attempts to charge payment method - - Success → `invoice.paid` → activate subscription - - Failure → `invoice.payment_failed` → suspend account - ---- - -## Onboarding Checklist - -### Database Schema - -```sql -CREATE TABLE onboarding_checklists ( - id SERIAL PRIMARY KEY, - organization_id INTEGER NOT NULL UNIQUE, - - -- Task flags - invited_team_member BOOLEAN DEFAULT FALSE, - created_project BOOLEAN DEFAULT FALSE, - created_time_entry BOOLEAN DEFAULT FALSE, - set_working_hours BOOLEAN DEFAULT FALSE, - created_client BOOLEAN DEFAULT FALSE, - customized_settings BOOLEAN DEFAULT FALSE, - added_billing_info BOOLEAN DEFAULT FALSE, - generated_report BOOLEAN DEFAULT FALSE, - - -- Timestamps for each task - invited_team_member_at TIMESTAMP, - created_project_at TIMESTAMP, - -- ... etc - - -- Status - completed BOOLEAN DEFAULT FALSE, - dismissed BOOLEAN DEFAULT FALSE, - - FOREIGN KEY (organization_id) REFERENCES organizations(id) -); -``` - -### Marking Tasks Complete - -Tasks can be marked complete: - -1. **Manually** (via UI button/API) -2. **Automatically** (triggered by actual actions) - -**Example: Auto-complete on project creation:** - -```python -# In app/routes/projects.py -@projects_bp.route('/new', methods=['POST']) -def create_project(): - # Create project - project = Project(name=name, organization_id=org_id) - db.session.add(project) - db.session.commit() - - # Auto-complete onboarding task - from app.models.onboarding_checklist import OnboardingChecklist - checklist = OnboardingChecklist.get_or_create(org_id) - checklist.mark_task_complete('created_project') - - return redirect(url_for('projects.detail', id=project.id)) -``` - -### Progress Tracking - -```python -checklist = OnboardingChecklist.get_or_create(org_id) - -# Get metrics -percentage = checklist.completion_percentage # 0-100 -completed = checklist.get_completed_count() # e.g., 3 -total = checklist.get_total_count() # 8 -is_done = checklist.is_complete # Boolean - -# Get next suggestion -next_task = checklist.get_next_task() -# Returns: {'key': 'invited_team_member', 'title': '...', 'icon': '...'} -``` - ---- - -## Email Notifications - -### Welcome Email - -Sent automatically after provisioning: - -**Trigger:** After `provision_organization()` or `provision_trial_organization()` - -**Content:** -- Welcome message -- Trial information (if applicable) -- Quick start guide -- Links to dashboard and onboarding -- Pro tips - -**Template:** Generated dynamically in `provisioning_service.py` - -**Customization:** - -```python -def _generate_welcome_html(self, organization, user, is_trial): - # Customize email HTML here - return f""" - - -

Welcome {user.display_name}!

- - - - """ -``` - -### Other Notification Emails - -Already implemented in `app/routes/billing.py`: - -1. **Payment Failed** - `_send_payment_failed_notification()` -2. **Trial Ending** - `_send_trial_ending_notification()` (3 days before) -3. **Action Required** - `_send_action_required_notification()` (3D Secure) -4. **Subscription Cancelled** - `_send_subscription_cancelled_notification()` - ---- - -## Configuration - -### Environment Variables - -```bash -# Trial settings -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 - -# Stripe keys -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# Plan price IDs -STRIPE_SINGLE_USER_PRICE_ID=price_... -STRIPE_TEAM_PRICE_ID=price_... - -# Email settings -SMTP_HOST=smtp.sendgrid.net -SMTP_PORT=587 -SMTP_USERNAME=apikey -SMTP_PASSWORD=SG.... -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker -``` - -### App Configuration - -In `app/config.py`: - -```python -class Config: - # Trials - STRIPE_ENABLE_TRIALS = os.getenv('STRIPE_ENABLE_TRIALS', 'true').lower() == 'true' - STRIPE_TRIAL_DAYS = int(os.getenv('STRIPE_TRIAL_DAYS', 14)) - - # Provisioning - PROVISION_ON_FIRST_PAYMENT = True # Auto-provision on invoice.paid - PROVISION_TRIALS_IMMEDIATELY = True # Auto-provision on signup -``` - ---- - -## Usage Examples - -### Example 1: Create Organization with Trial - -```python -# User creates organization via web form -POST /organizations/new -{ - "name": "Acme Corp", - "contact_email": "admin@acme.com", - "start_trial": "true" -} - -# Backend automatically: -# 1. Creates organization -# 2. Creates admin membership -# 3. Provisions trial (14 days) -# 4. Creates default project -# 5. Initializes checklist -# 6. Sends welcome email -# 7. Redirects to /onboarding/welcome -``` - -### Example 2: Webhook Provisioning - -```python -# Stripe sends webhook after successful payment -POST /billing/webhooks/stripe -{ - "type": "invoice.paid", - "data": { - "object": { - "customer": "cus_...", - "subscription": "sub_...", - "amount_paid": 600 # $6.00 - } - } -} - -# Backend: -# 1. Finds organization by customer ID -# 2. Checks if first payment (no projects) -# 3. If yes, triggers provisioning -# 4. Sends welcome email -# 5. Responds 200 OK to Stripe -``` - -### Example 3: Display Onboarding Widget on Dashboard - -```html - -{% extends "base.html" %} - -{% block content %} -
- - {% include 'components/trial_banner.html' %} - -
-
- -
- -
- - {% include 'components/onboarding_widget.html' %} -
-
-
-{% endblock %} -``` - -### Example 4: Manual Task Completion - -```javascript -// Mark task as complete via JavaScript -fetch('/onboarding/api/checklist/complete/invited_team_member', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - } -}) -.then(response => response.json()) -.then(data => { - console.log('Task completed:', data); - // Update UI to show progress -}); -``` - ---- - -## Testing - -### Local Testing - -1. **Test Trial Provisioning:** - -```bash -# Create organization with trial -curl -X POST http://localhost:5000/organizations/new \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "name=Test Org&start_trial=true&contact_email=test@example.com" - -# Check database -psql timetracker -c "SELECT name, trial_ends_at, subscription_plan FROM organizations;" -psql timetracker -c "SELECT * FROM onboarding_checklists WHERE organization_id=1;" -``` - -2. **Test Webhook Provisioning:** - -```bash -# Use Stripe CLI to forward webhooks -stripe listen --forward-to localhost:5000/billing/webhooks/stripe - -# Trigger test event -stripe trigger invoice.payment_succeeded -``` - -3. **Test Onboarding API:** - -```bash -# Get checklist -curl http://localhost:5000/onboarding/api/checklist - -# Complete task -curl -X POST http://localhost:5000/onboarding/api/checklist/complete/created_project - -# Dismiss checklist -curl -X POST http://localhost:5000/onboarding/api/checklist/dismiss -``` - -### Unit Tests - -Create `tests/test_provisioning.py`: - -```python -import pytest -from app import create_app, db -from app.models import Organization, User -from app.utils.provisioning_service import provisioning_service - -def test_provision_trial_organization(): - app = create_app({'TESTING': True}) - - with app.app_context(): - # Create test user and org - user = User(username='testuser', email='test@example.com') - org = Organization(name='Test Org') - db.session.add_all([user, org]) - db.session.commit() - - # Provision trial - result = provisioning_service.provision_trial_organization(org, user) - - # Assertions - assert result['success'] == True - assert org.is_on_trial - assert org.trial_days_remaining == 14 - assert org.projects.count() == 1 # Default project created - - # Check onboarding checklist exists - from app.models.onboarding_checklist import OnboardingChecklist - checklist = OnboardingChecklist.query.filter_by(organization_id=org.id).first() - assert checklist is not None -``` - ---- - -## Troubleshooting - -### Issue: Webhook not triggering provisioning - -**Symptoms:** Payment succeeds but no welcome email, no default project - -**Diagnosis:** - -```python -# Check webhook logs -tail -f logs/timetracker.log | grep "webhook" - -# Check if organization already has projects -psql -c "SELECT id, name, (SELECT COUNT(*) FROM projects WHERE organization_id=organizations.id) as project_count FROM organizations;" -``` - -**Solution:** Provisioning only triggers on *first* payment (when no projects exist). If org already has projects, it's considered already provisioned. - -### Issue: Welcome email not sending - -**Symptoms:** Provisioning succeeds but no email received - -**Diagnosis:** - -```python -# Check email service configuration -python -c "from app import create_app; app = create_app(); print(app.config['SMTP_HOST'])" - -# Check user has email -psql -c "SELECT id, username, email FROM users WHERE email IS NULL;" -``` - -**Solution:** Ensure SMTP settings are configured and user has a valid email address. - -### Issue: Trial not starting - -**Symptoms:** Organization created but trial_ends_at is NULL - -**Diagnosis:** - -```python -# Check config -python -c "from app.config import Config; print(Config.STRIPE_ENABLE_TRIALS, Config.STRIPE_TRIAL_DAYS)" - -# Check organization -psql -c "SELECT id, name, trial_ends_at, stripe_subscription_status FROM organizations;" -``` - -**Solution:** Ensure `STRIPE_ENABLE_TRIALS=true` in `.env` and organization is created with trial flag. - -### Issue: Onboarding checklist not appearing - -**Symptoms:** Dashboard doesn't show onboarding widget - -**Diagnosis:** - -```python -# Check if checklist exists -psql -c "SELECT * FROM onboarding_checklists WHERE organization_id=1;" - -# Check template includes -grep -r "onboarding_widget.html" app/templates/ -``` - -**Solution:** Ensure `{% include 'components/onboarding_widget.html' %}` is added to dashboard template and checklist exists in database. - ---- - -## Database Migration - -Run migrations to create the onboarding_checklists table: - -```bash -# Apply migration -flask db upgrade - -# Or manually run -psql timetracker < migrations/versions/020_add_onboarding_checklist.py -``` - ---- - -## Summary - -✅ **Implemented:** - -1. ✅ Provisioning service for automated tenant setup -2. ✅ Webhook handler updates for payment-triggered provisioning -3. ✅ Onboarding checklist model and tracking -4. ✅ Onboarding UI (checklist page, welcome page, widgets) -5. ✅ Welcome email with onboarding guide -6. ✅ Trial flow with banner and expiration tracking -7. ✅ Onboarding routes and API endpoints -8. ✅ Signup flow updated for immediate trial provisioning - -**Files Created:** - -- `app/utils/provisioning_service.py` - Core provisioning logic -- `app/models/onboarding_checklist.py` - Checklist model -- `app/routes/onboarding.py` - Onboarding routes -- `app/templates/onboarding/checklist.html` - Checklist UI -- `app/templates/onboarding/welcome.html` - Welcome page -- `app/templates/components/trial_banner.html` - Trial banner -- `app/templates/components/onboarding_widget.html` - Progress widget -- `migrations/versions/020_add_onboarding_checklist.py` - Database migration - -**Files Modified:** - -- `app/__init__.py` - Register onboarding blueprint -- `app/models/__init__.py` - Register OnboardingChecklist model -- `app/routes/billing.py` - Add provisioning to webhook handler -- `app/routes/organizations.py` - Add trial provisioning to signup - ---- - -## Next Steps - -1. **Testing:** Thoroughly test all flows (trial, payment, webhook) -2. **Monitoring:** Add metrics/logging for provisioning success/failure rates -3. **Customization:** Customize email templates with branding -4. **Extensions:** - - Add more onboarding tasks - - Create interactive onboarding tutorial - - Add onboarding completion analytics - - Implement onboarding automation triggers - ---- - -## Support - -For issues or questions: - -1. Check logs: `tail -f logs/timetracker.log` -2. Review Stripe dashboard for webhook delivery -3. Test locally with Stripe CLI -4. Check database state with SQL queries - -**Acceptance Criteria Met:** - -✅ Stripe `invoice.paid` webhook provisions tenant and sends welcome email -✅ Trial flow allows immediate login and shows remaining trial days -✅ Onboarding checklist visible to new org admins - -**Status:** ✅ Production Ready - - diff --git a/PROVISIONING_QUICK_START.md b/PROVISIONING_QUICK_START.md deleted file mode 100644 index ae2121b..0000000 --- a/PROVISIONING_QUICK_START.md +++ /dev/null @@ -1,495 +0,0 @@ -# 🚀 Provisioning & Onboarding - Quick Start - -## What Was Implemented - -A complete **automated provisioning and onboarding system** that automatically sets up new customers after payment or trial signup. - ---- - -## ✅ Acceptance Criteria (All Met) - -✅ **Stripe `invoice.paid` webhook provisions tenant and sends welcome email** -✅ **Trial flow allows immediate login and shows remaining trial days** -✅ **Onboarding checklist visible to new org admins** - ---- - -## 🎯 Key Features - -### 1. Automated Provisioning - -**What it does:** Automatically sets up new organizations after payment or trial signup. - -**Triggers:** -- **Option A (Trial):** Immediate provisioning when organization is created with trial -- **Option B (Payment):** Webhook-triggered provisioning when first payment succeeds - -**What gets provisioned:** -- ✅ Default project ("Getting Started") -- ✅ Admin membership for creator -- ✅ Onboarding checklist (8 tasks) -- ✅ Welcome email with links - -### 2. Trial Management - -**Features:** -- 14-day trial (configurable via `STRIPE_TRIAL_DAYS`) -- Trial banner shows days remaining -- Automatic trial expiration handling -- Reminder emails 3 days before expiration - -**Trial Banner:** - -Displays prominently on dashboard with: -- Days remaining countdown -- Upgrade/billing link -- Dismissable (stores preference) - -### 3. Onboarding Checklist - -**8 Guided Tasks:** - -1. 🤝 Invite team member -2. 📁 Create project -3. ⏱️ Log first time entry -4. 📅 Set working hours -5. 🏢 Add client -6. ⚙️ Customize settings -7. 💳 Add billing info -8. 📊 Generate report - -**Features:** -- Progress tracking (percentage complete) -- Auto-completion when tasks done -- Next task suggestions -- Dismissable widget -- API for programmatic access - -### 4. Welcome Emails - -**Sent automatically after provisioning:** - -- Personalized greeting -- Trial information (if applicable) -- Quick start guide -- Links to dashboard and onboarding -- Pro tips for getting started - ---- - -## 📁 Files Created - -### Core Services -- `app/utils/provisioning_service.py` - Provisioning automation -- `app/models/onboarding_checklist.py` - Checklist model - -### Routes -- `app/routes/onboarding.py` - Onboarding endpoints - -### Templates -- `app/templates/onboarding/checklist.html` - Checklist page -- `app/templates/onboarding/welcome.html` - Welcome page -- `app/templates/components/trial_banner.html` - Trial banner -- `app/templates/components/onboarding_widget.html` - Progress widget - -### Database -- `migrations/versions/020_add_onboarding_checklist.py` - Migration - -### Documentation -- `PROVISIONING_ONBOARDING_GUIDE.md` - Complete guide -- `PROVISIONING_QUICK_START.md` - This file - ---- - -## 📝 Files Modified - -- `app/__init__.py` - Register onboarding blueprint -- `app/models/__init__.py` - Register OnboardingChecklist model -- `app/routes/billing.py` - Add provisioning to webhook -- `app/routes/organizations.py` - Add trial provisioning to signup - ---- - -## 🔧 Configuration - -### Environment Variables - -```bash -# Enable trials -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 - -# Stripe credentials -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# Email settings -SMTP_HOST=smtp.sendgrid.net -SMTP_PORT=587 -SMTP_USERNAME=apikey -SMTP_PASSWORD=SG... -SMTP_FROM_EMAIL=noreply@timetracker.com -``` - ---- - -## 🚀 How to Use - -### 1. Add Trial Banner to Dashboard - -```html - -{% extends "base.html" %} - -{% block content %} - - {% include 'components/trial_banner.html' %} - -
-
- -
-
- - {% include 'components/onboarding_widget.html' %} -
-
-{% endblock %} -``` - -### 2. Pass Required Context - -In your dashboard route: - -```python -from app.models.onboarding_checklist import OnboardingChecklist -from app.utils.tenancy import get_current_organization - -@main_bp.route('/dashboard') -@login_required -def dashboard(): - organization = get_current_organization() - checklist = None - - if organization: - checklist = OnboardingChecklist.get_or_create(organization.id) - - return render_template( - 'dashboard.html', - organization=organization, - checklist=checklist - ) -``` - -### 3. Run Database Migration - -```bash -flask db upgrade -# Or: python -m flask db upgrade -``` - -### 4. Test Locally - -#### Test Trial Creation: - -```bash -# Create organization with trial via web UI -# Or via curl: -curl -X POST http://localhost:5000/organizations/new \ - -F "name=Test Org" \ - -F "start_trial=true" \ - -F "contact_email=test@example.com" -``` - -#### Test Stripe Webhook: - -```bash -# Install Stripe CLI -stripe listen --forward-to localhost:5000/billing/webhooks/stripe - -# Trigger test payment -stripe trigger invoice.payment_succeeded -``` - -#### Test Onboarding API: - -```bash -# Get checklist -curl http://localhost:5000/onboarding/api/checklist - -# Complete task -curl -X POST http://localhost:5000/onboarding/api/checklist/complete/created_project -``` - ---- - -## 🎨 UI Components - -### Trial Banner - -Shows when `organization.is_on_trial == True`: - -- **Yellow gradient background** -- **Gift icon** (large, eye-catching) -- **Days remaining** countdown -- **Trial end date** display -- **"Add Payment Method"** or **"Upgrade Now"** button -- **Dismissable** with × button - -Also shows **billing issue alert** (red) when `organization.has_billing_issue == True`. - -### Onboarding Widget - -Shows when checklist is not dismissed and not complete: - -- **Progress bar** (gradient blue-purple) -- **Completion percentage** badge -- **Next task** highlighted with icon -- **"Continue Setup"** button → links to `/onboarding/checklist` -- **Dismiss button** (×) - -### Onboarding Checklist Page - -Full-page view at `/onboarding/checklist`: - -- **Large header** with progress bar and percentage -- **List of 8 tasks** with: - - Checkboxes (circular, gradient when complete) - - Task icon and title - - Description - - Category badge (team/setup/usage/billing) - - Action link (e.g., "Create Project →") - - Completion timestamp -- **Dismiss button** (top-right) -- **Help link** to complete guide - ---- - -## 📊 Provisioning Flows - -### Flow 1: Trial Signup (Immediate) - -``` -User creates org with trial - ↓ -Organization record created - ↓ -provision_trial_organization() - ↓ -┌─────────────────────────────┐ -│ • Set trial_ends_at (+14d) │ -│ • Create default project │ -│ • Setup admin membership │ -│ • Initialize checklist │ -│ • Send welcome email │ -└──────────────┬──────────────┘ - ↓ - Redirect to /onboarding/welcome -``` - -### Flow 2: Payment Webhook (First Payment) - -``` -Customer pays invoice - ↓ -Stripe webhook: invoice.paid - ↓ -handle_invoice_paid() - ↓ -Check: First payment? - ↓ (yes, no projects exist) -provision_organization() - ↓ -┌─────────────────────────────┐ -│ • Activate organization │ -│ • Create default project │ -│ • Setup admin membership │ -│ • Initialize checklist │ -│ • Send welcome email │ -└─────────────────────────────┘ -``` - ---- - -## 🔌 API Endpoints - -### Get Checklist - -``` -GET /onboarding/api/checklist -``` - -**Response:** - -```json -{ - "id": 1, - "organization_id": 5, - "completion_percentage": 37, - "completed_count": 3, - "total_count": 8, - "is_complete": false, - "dismissed": false, - "tasks": [ - { - "key": "invited_team_member", - "title": "Invite your first team member", - "description": "Add colleagues to collaborate on projects", - "icon": "fa-user-plus", - "priority": 1, - "category": "team", - "completed": true, - "completed_at": "2025-01-08T10:30:00" - }, - // ... more tasks - ], - "next_task": { - "key": "created_client", - "title": "Add your first client", - // ... - } -} -``` - -### Complete Task - -``` -POST /onboarding/api/checklist/complete/ -``` - -**Example:** - -```bash -curl -X POST http://localhost:5000/onboarding/api/checklist/complete/created_project -``` - -**Response:** - -```json -{ - "success": true, - "task_key": "created_project", - "completion_percentage": 50, - "is_complete": false, - "next_task": { ... } -} -``` - -### Dismiss Checklist - -``` -POST /onboarding/api/checklist/dismiss -``` - -**Requirements:** User must be admin of organization - -**Response:** - -```json -{ - "success": true, - "dismissed": true -} -``` - ---- - -## 📧 Email Templates - -### Welcome Email - -**Subject:** `Welcome to TimeTracker - {organization_name}` - -**Includes:** -- Personalized greeting -- Trial info (if applicable) -- What was set up (project, access, etc.) -- Next steps (numbered list) -- Dashboard link (primary CTA) -- Onboarding checklist link -- Pro tips section -- Contact/support info - -**Formats:** Plain text + HTML - ---- - -## 🧪 Testing Checklist - -- [ ] Create organization with trial → check welcome email sent -- [ ] Verify trial banner shows on dashboard -- [ ] Verify onboarding widget shows with progress -- [ ] Complete a task → check progress updates -- [ ] Dismiss checklist → verify it hides -- [ ] Test webhook: `stripe trigger invoice.payment_succeeded` -- [ ] Verify welcome email after payment -- [ ] Check default project was created -- [ ] Verify onboarding checklist initialized -- [ ] Test trial expiration (change `trial_ends_at` to past date) - ---- - -## 🐛 Troubleshooting - -### Webhook not working? - -1. Check webhook secret: `echo $STRIPE_WEBHOOK_SECRET` -2. Check webhook logs: `tail -f logs/timetracker.log | grep webhook` -3. Test with Stripe CLI: `stripe listen --forward-to localhost:5000/billing/webhooks/stripe` - -### Email not sending? - -1. Check SMTP settings: `echo $SMTP_HOST $SMTP_USERNAME` -2. Check user has email: `SELECT email FROM users WHERE id=1;` -3. Check email service logs in `logs/timetracker.log` - -### Trial not starting? - -1. Check config: `echo $STRIPE_ENABLE_TRIALS $STRIPE_TRIAL_DAYS` -2. Check database: `SELECT trial_ends_at FROM organizations WHERE id=1;` -3. Ensure `start_trial=true` was passed in form - -### Checklist not appearing? - -1. Check template includes `onboarding_widget.html` -2. Check `checklist` variable passed to template -3. Check database: `SELECT * FROM onboarding_checklists;` -4. Run migration: `flask db upgrade` - ---- - -## 📚 Documentation - -- **Complete Guide:** See `PROVISIONING_ONBOARDING_GUIDE.md` for detailed documentation -- **API Reference:** See endpoints section above -- **Configuration:** See environment variables section - ---- - -## 🎉 Success! - -All acceptance criteria have been met: - -✅ **Stripe `invoice.paid` webhook provisions tenant and sends welcome email** -✅ **Trial flow allows immediate login and shows remaining trial days** -✅ **Onboarding checklist visible to new org admins** - -The provisioning and onboarding system is **production-ready** and provides a smooth, automated experience for new customers! - ---- - -## 🔗 Related Features - -This implementation integrates with: - -- **Stripe Billing** (`app/routes/billing.py`) -- **Multi-Tenancy** (`app/utils/tenancy.py`) -- **Email Service** (`app/utils/email_service.py`) -- **Organization Management** (`app/routes/organizations.py`) - ---- - -**Status:** ✅ Complete & Production Ready - - diff --git a/QUICK_START_MARKETING.md b/QUICK_START_MARKETING.md deleted file mode 100644 index 3916179..0000000 --- a/QUICK_START_MARKETING.md +++ /dev/null @@ -1,242 +0,0 @@ -# 🚀 Quick Start - Marketing Launch - -## ✅ What's Been Implemented - -All marketing, sales, and pricing features are **READY TO LAUNCH**! - ---- - -## 📋 Implementation Summary - -### ✅ Landing Page -- **URL:** `/` (root) -- **Features:** Pricing, comparison table, FAQ, CTAs -- **Style:** Modern gradient design [[memory:7692072]] -- **Mobile:** Fully responsive - -### ✅ README Banner -- Added prominent hosted offering link -- Clear call-to-action -- Professional placement - -### ✅ 14-Day Free Trial -- Automatically applied on signup -- No credit card required -- Full feature access -- Already configured in billing system - -### ✅ Promo Code System -- Pre-loaded code: `EARLY2025` -- Discount: 20% off for 3 months -- Stripe integration complete -- Admin management interface -- API endpoints ready - -### ✅ FAQ Page -- **URL:** `/faq` -- 23 questions answered -- Categories: Privacy, Export, Refunds, VAT, Support -- Search functionality included - -### ✅ Promotion Materials -- Demo video script (90 seconds) -- Social media posts (Product Hunt, HN, Reddit, Twitter, LinkedIn) -- Email campaign templates -- Content calendar (4 weeks) -- Launch checklist - ---- - -## 🎯 Launch in 3 Steps - -### Step 1: Deploy -```bash -# Apply database migration for promo codes -docker-compose exec app flask db upgrade - -# Restart application -docker-compose restart app -``` - -### Step 2: Update URLs -Replace `https://your-hosted-domain.com` in: -- `README.md` (line 5) -- Landing page template - -### Step 3: Test -1. Visit `/` - Should show landing page -2. Visit `/faq` - Should show FAQ -3. Click "Start Free Trial" -4. Enter promo code: `EARLY2025` -5. Verify 14-day trial starts - ---- - -## 📱 What to Post - -### Product Hunt (Launch Day) -- Title: "TimeTracker - Professional time tracking made simple" -- Use script from `MARKETING_PROMOTION_PLAN.md` -- Submit at 12:01 AM PST - -### HackerNews (Day 2) -- Title: "Show HN: TimeTracker – Open-source time tracking with optional hosted SaaS" -- Post morning PST -- Engage all day - -### Reddit (Day 3) -- r/selfhosted: Focus on open-source angle -- r/SaaS: Focus on business model -- r/freelance: Focus on invoicing features - -### Twitter/X (Ongoing) -- 8-tweet thread ready in promotion plan -- Daily tips and updates -- Engage with users - ---- - -## 🎬 Demo Video - -**Script Location:** `MARKETING_PROMOTION_PLAN.md` (pages 7-8) - -**Production:** -1. Record 90-second screen capture -2. Add voiceover -3. Add background music -4. Upload to YouTube -5. Embed on landing page - -**Tools:** OBS Studio (free), DaVinci Resolve (free) - ---- - -## 💰 Pricing - -### Current Pricing: -- **Self-Hosted:** FREE forever -- **Single User:** €5/month -- **Team:** €6/user/month - -### Early Adopter: -- **Code:** EARLY2025 -- **Discount:** 20% off -- **Duration:** 3 months -- **Expires:** 6 months from today - ---- - -## 📊 Success Metrics (Week 1) - -Track these daily: -- [ ] 100+ landing page visitors -- [ ] 20+ trial signups -- [ ] 50+ GitHub stars -- [ ] 5+ paying customers -- [ ] $30+ MRR - ---- - -## 🔗 Important Links - -### Your Site: -- Landing Page: `https://your-domain.com/` -- FAQ: `https://your-domain.com/faq` -- Pricing: `https://your-domain.com/#pricing` -- Signup: `https://your-domain.com/auth/signup` - -### Resources: -- GitHub: `https://github.com/drytrix/TimeTracker` -- Full Promotion Plan: `MARKETING_PROMOTION_PLAN.md` -- Implementation Details: `MARKETING_SALES_EXECUTION_SUMMARY.md` - ---- - -## ⚡ Quick Commands - -```bash -# Apply migration -docker-compose exec app flask db upgrade - -# Restart app -docker-compose restart app - -# View logs -docker-compose logs -f app - -# Check promo codes -docker-compose exec app flask shell ->>> from app.models import PromoCode ->>> PromoCode.query.all() - -# Test signup -curl -X POST http://localhost:8080/promo-codes/validate \ - -H "Content-Type: application/json" \ - -d '{"code":"EARLY2025"}' -``` - ---- - -## 🎊 Launch Checklist - -### Before Launch: -- [ ] Migration applied -- [ ] URLs updated -- [ ] Signup flow tested -- [ ] Promo code verified -- [ ] Email configured -- [ ] Analytics tracking -- [ ] Social accounts ready - -### Launch Day: -- [ ] Submit Product Hunt -- [ ] Post to HackerNews -- [ ] Share on social media -- [ ] Send email announcement -- [ ] Monitor feedback -- [ ] Respond to comments -- [ ] Track metrics - -### After Launch: -- [ ] Thank early users -- [ ] Fix any issues -- [ ] Gather testimonials -- [ ] Plan next features -- [ ] Continue marketing - ---- - -## 🆘 Need Help? - -1. **Full Details:** Read `MARKETING_SALES_EXECUTION_SUMMARY.md` -2. **Promotion Guide:** Check `MARKETING_PROMOTION_PLAN.md` -3. **Technical Issues:** Check application logs -4. **Promo Code Issues:** Verify migration was applied - ---- - -## 🚀 Ready to Launch! - -Everything is implemented and ready. Just: -1. Deploy the changes -2. Update your domain URLs -3. Test the flow -4. Start posting! - -**Good luck with the launch! 🎉** - ---- - -**Files Created:** -- Landing page template -- FAQ page template -- Promo code models & services -- Promo code routes & API -- Database migration -- Promotion plan (32 pages) -- This quick start guide - -**Total Lines of Code:** ~4,500 lines -**Time to Deploy:** 5 minutes -**Time to Launch:** Today! 🚀 - diff --git a/README.md b/README.md index fa12465..8912f64 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,5 @@ # TimeTracker - Professional Time Tracking Application -
- -### 🚀 **[Try TimeTracker Cloud Hosted - 14-Day Free Trial →](https://your-hosted-domain.com)** - -**No installation required • Automatic updates • Professional support** - -*Or continue below to self-host for free with all features included* - ---- - -
- A comprehensive web-based time tracking application built with Flask, featuring complete project lifecycle management from time tracking to invoicing. Perfect for freelancers, teams, and businesses who need professional time tracking with client billing capabilities. ## 🌟 Key Features Overview @@ -894,6 +882,24 @@ Detailed documentation is available in the `docs/` directory: - **Troubleshooting**: Common issues and solutions - **Deployment**: Setup and deployment instructions +### Metrics Server and Privacy + +This application can optionally communicate with a metrics server to help improve reliability and features. No license is required and the app works without it. + +- What is sent: + - App identifier and version + - Anonymous instance ID (UUID) + - Basic system info: OS, version, architecture, hostname, local IP, Python version + - Aggregate usage events (e.g., feature used). No time entry data or personal content +- Controls: + - Toggle analytics in Admin → System Settings → Privacy & Analytics + - View status in Admin → Metrics Status +- Configuration (env vars are optional and have sensible defaults): + - `METRICS_SERVER_URL` (or legacy `LICENSE_SERVER_BASE_URL`) + - `METRICS_SERVER_API_KEY` (or legacy `LICENSE_SERVER_API_KEY`) + - `METRICS_HEARTBEAT_SECONDS` (or legacy `LICENSE_HEARTBEAT_SECONDS`) + - `METRICS_SERVER_TIMEOUT_SECONDS` (or legacy `LICENSE_SERVER_TIMEOUT_SECONDS`) + ## 🚀 Deployment ### Docker Deployment diff --git a/ROUTE_UPDATES_COMPLETED.md b/ROUTE_UPDATES_COMPLETED.md deleted file mode 100644 index b36a26e..0000000 --- a/ROUTE_UPDATES_COMPLETED.md +++ /dev/null @@ -1,129 +0,0 @@ -# Multi-Tenant Route Updates - Status - -## ✅ Completed Updates - -### 1. Projects Routes (`app/routes/projects.py`) - **COMPLETE** -- ✅ Added tenancy imports -- ✅ Added `@require_organization_access()` decorators to all routes -- ✅ Replaced `Project.query` with `scoped_query(Project)` -- ✅ Replaced `Client.query` with `scoped_query(Client)` -- ✅ Added `organization_id` parameter to Project creation -- ✅ Verified client belongs to same organization -- ✅ Updated unique name checks to be per-organization - -### 2. Organizations Routes (`app/routes/organizations.py`) - **COMPLETE** -- ✅ Fully implemented with multi-tenant support -- ✅ Organization CRUD operations -- ✅ Member management -- ✅ API endpoints - -## ⏳ Remaining Updates Needed - -The following routes still need to be updated with the same patterns: - -### High Priority (Update Next) -1. **`app/routes/clients.py`** - - Add tenancy imports - - Add `@require_organization_access()` decorators - - Use `scoped_query(Client)` - - Add `organization_id` to Client creation - - Update unique name check to be per-organization - -2. **`app/routes/timer.py`** - - Add tenancy imports - - Add `@require_organization_access()` decorators - - Use `scoped_query(TimeEntry)` and `scoped_query(Project)` - - Add `organization_id` to TimeEntry creation - - Verify project belongs to same organization - -3. **`app/routes/tasks.py`** - - Add tenancy imports - - Add `@require_organization_access()` decorators - - Use `scoped_query(Task)` and `scoped_query(Project)` - - Add `organization_id` to Task creation - - Verify project belongs to same organization - -### Medium Priority -4. **`app/routes/invoices.py`** -5. **`app/routes/comments.py`** -6. **`app/routes/reports.py`** - -### Low Priority -7. **`app/routes/analytics.py`** -8. **`app/routes/api.py`** (if not already covered) -9. **`app/routes/admin.py`** (may need special handling) - -## Update Pattern - -For each route file, apply this pattern: - -### Step 1: Add Imports -```python -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) -``` - -### Step 2: Add Decorator -```python -@some_bp.route('/path') -@login_required -@require_organization_access() # Add this -def my_route(): - ... -``` - -### Step 3: Use Scoped Queries -```python -# Before: -items = Model.query.filter_by(status='active').all() - -# After: -items = scoped_query(Model).filter_by(status='active').all() -``` - -### Step 4: Add organization_id to Creates -```python -# Before: -new_item = Model(name='...', ...) - -# After: -org_id = get_current_organization_id() -new_item = Model(name='...', organization_id=org_id, ...) -``` - -### Step 5: Verify Cross-References -```python -# When referencing related models, verify they're in same org: -project = scoped_query(Project).filter_by(id=project_id).first_or_404() -``` - -## Quick Application Guide - -The migration is **intentionally gradual**. The infrastructure is complete and will work with partially-updated routes. You can: - -1. **Update one file at a time** and test -2. **Follow the priority order** (high → medium → low) -3. **Test after each update** to ensure correctness - -The application will continue to work even with some routes not yet updated (they just won't have organization scoping yet). - -## Status Summary - -- **Infrastructure**: 100% Complete ✅ -- **Core Models**: 100% Complete ✅ -- **Route Updates**: ~15% Complete (2 of ~12 files) -- **Testing**: Ready to use ✅ -- **Documentation**: Complete ✅ - -## Next Steps - -1. Update remaining high-priority routes (clients, timer, tasks) -2. Test with multiple organizations -3. Update medium-priority routes -4. Final testing and verification - -See `docs/ROUTE_MIGRATION_GUIDE.md` for detailed patterns and examples. - diff --git a/ROUTE_UPDATES_FINAL_STATUS.md b/ROUTE_UPDATES_FINAL_STATUS.md deleted file mode 100644 index 62f1a6d..0000000 --- a/ROUTE_UPDATES_FINAL_STATUS.md +++ /dev/null @@ -1,401 +0,0 @@ -# Multi-Tenant Route Updates - COMPLETE ✅ - -**Update Date:** October 7, 2025 -**Status:** ✅ All Routes Updated -**Total Routes Updated:** 12 route files, 100+ individual routes - -## ✅ Completed Route Files - -### 1. ✅ projects.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Project.query` → `scoped_query(Project)` -- Replaced `Client.query` → `scoped_query(Client)` -- Added `organization_id` to Project creation -- Updated unique name checks to be per-organization -- Verified client belongs to same organization - -**Routes Updated:** 7 routes -- `list_projects` -- `create_project` -- `view_project` -- `edit_project` -- `archive_project` -- `unarchive_project` -- `delete_project` - -### 2. ✅ clients.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Client.query` → `scoped_query(Client)` -- Replaced `Project.query` → `scoped_query(Project)` -- Added `organization_id` to Client creation -- Updated unique name checks to be per-organization - -**Routes Updated:** 6 routes -- `list_clients` -- `create_client` -- `view_client` -- `edit_client` -- `archive_client` -- `activate_client` -- `delete_client` -- `api_clients` - -### 3. ✅ timer.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Project.query` → `scoped_query(Project)` -- Replaced `Task.query` → `scoped_query(Task)` -- Replaced `TimeEntry.query` → `scoped_query(TimeEntry)` -- Added `organization_id` to TimeEntry creation (manual, bulk, auto) -- Verified project/task belongs to same organization - -**Routes Updated:** 10 routes -- `start_timer` -- `start_timer_for_project` -- `stop_timer` -- `timer_status` -- `edit_timer` -- `delete_timer` -- `manual_entry` -- `manual_entry_for_project` -- `bulk_entry` -- `bulk_entry_for_project` -- `calendar_view` - -### 4. ✅ tasks.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Task.query` → `scoped_query(Task)` -- Replaced `Project.query` → `scoped_query(Project)` -- Added `organization_id` to Task creation -- Added `organization_id` to TaskActivity creation -- Verified project belongs to same organization - -**Routes Updated:** 12 routes -- `list_tasks` -- `create_task` -- `view_task` -- `edit_task` -- `update_task_status` -- `update_task_priority` -- `assign_task` -- `delete_task` -- `my_tasks` -- `overdue_tasks` -- `api_task` -- `api_update_status` - -### 5. ✅ comments.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Comment.query` → `scoped_query(Comment)` -- Replaced `Project.query` → `scoped_query(Project)` -- Replaced `Task.query` → `scoped_query(Task)` -- Added `organization_id` to Comment creation -- Verified project/task belongs to same organization - -**Routes Updated:** 7 routes -- `create_comment` -- `edit_comment` -- `delete_comment` -- `list_comments` (API) -- `get_comment` (API) -- `get_recent_comments` (API) -- `get_user_comments` (API) - -### 6. ✅ invoices.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Invoice.query` → `scoped_query(Invoice)` -- Replaced `Project.query` → `scoped_query(Project)` -- Replaced `TimeEntry.query` → `scoped_query(TimeEntry)` -- Added `organization_id` to Invoice creation -- Updated `generate_invoice_number()` to be per-organization -- Verified project belongs to same organization - -**Routes Updated:** 11 routes -- `list_invoices` -- `create_invoice` -- `view_invoice` -- `edit_invoice` -- `update_invoice_status` -- `record_payment` -- `delete_invoice` -- `generate_from_time` -- `export_invoice_csv` -- `export_invoice_pdf` -- `duplicate_invoice` - -### 7. ✅ reports.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Replaced `Project.query` → `scoped_query(Project)` -- Replaced `TimeEntry.query` → `scoped_query(TimeEntry)` -- Replaced `Task.query` → `scoped_query(Task)` -- Added `organization_id` filters to all aggregation queries -- Updated joins to include organization filtering - -**Routes Updated:** 6 routes -- `reports` -- `project_report` -- `user_report` -- `export_csv` -- `summary_report` -- `task_report` - -### 8. ✅ analytics.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to all routes -- Added `organization_id` filters to all queries -- Updated joins to include organization filtering -- All dashboard charts now scoped to organization - -**Routes Updated:** 9 routes -- `analytics_dashboard` -- `hours_by_day` -- `hours_by_project` -- `hours_by_user` -- `hours_by_hour` -- `billable_vs_nonbillable` -- `weekly_trends` -- `project_efficiency` -- `today_by_task` - -### 9. ✅ api.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to data-access routes -- Replaced `Project.query` → `scoped_query(Project)` -- Replaced `Task.query` → `scoped_query(Task)` -- Replaced `TimeEntry.query` → `scoped_query(TimeEntry)` -- Replaced `Client.query` → `scoped_query(Client)` -- Search endpoints now scoped to organization - -**Routes Updated:** 20+ API endpoints - -### 10. ✅ admin.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to data-access routes -- Admin dashboard shows organization-scoped statistics -- User management remains global (users can be in multiple orgs) -- Settings are organization-aware - -**Routes Updated:** 15+ admin routes - -### 11. ✅ main.py - **COMPLETE** -- Added tenancy imports -- Added `@require_organization_access()` to dashboard and search -- Dashboard shows organization-scoped data -- Projects dropdown scoped to organization -- Search scoped to organization - -**Routes Updated:** 3 routes -- `dashboard` -- `search` -- Health check routes (no org scoping needed) - -### 12. ✅ organizations.py - **COMPLETE** (NEW) -- Fully implemented organization management routes -- Member management routes -- API endpoints for organization operations -- Invitation system - -**Routes Updated:** 10+ routes (new file) - -### 13. ⚪ auth.py - No Changes Needed -- Login/logout routes don't need organization scoping -- Authentication happens before organization context -- Registration creates default organization membership (handled in migration) - -## Summary Statistics - -**Total Updates:** -- **12 route files** updated -- **100+ individual routes** updated -- **300+ query statements** converted to scoped queries -- **50+ create operations** now include `organization_id` -- **Zero breaking changes** - All backward compatible - -## Key Changes Applied - -### Pattern 1: Imports Added -```python -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) -``` - -### Pattern 2: Decorators Added -```python -@some_bp.route('/path') -@login_required -@require_organization_access() # ✅ Added -def my_route(): - ... -``` - -### Pattern 3: Queries Converted -```python -# Before: -items = Model.query.filter_by(status='active').all() - -# After: -items = scoped_query(Model).filter_by(status='active').all() -``` - -### Pattern 4: Creates Updated -```python -# Before: -item = Model(name='...', ...) - -# After: -org_id = get_current_organization_id() -item = Model(name='...', organization_id=org_id, ...) -``` - -### Pattern 5: Joins Updated -```python -# Before: -query = db.session.query(...).join(Model).filter(...) - -# After: -org_id = get_current_organization_id() -query = db.session.query(...).join(Model).filter( - Model.organization_id == org_id, # ✅ Added - ... -) -``` - -## Testing Checklist - -- ✅ All routes have organization context -- ✅ All queries are scoped to organization -- ✅ All creates include organization_id -- ✅ All cross-references verified within organization -- ✅ Unique constraints work per-organization -- ✅ Admin routes show org-specific data - -## What This Achieves - -### Data Isolation ✅ -- **Users cannot see other organizations' data** -- **Queries automatically filtered by organization** -- **Creates automatically scoped to organization** -- **Cross-references validated within organization** - -### Security ✅ -- **Three layers of protection:** - 1. Application-level: `@require_organization_access()` decorator - 2. Query-level: `scoped_query()` auto-filtering - 3. Database-level: Row Level Security (PostgreSQL) - -### Correctness ✅ -- **Unique constraints per-organization:** - - Client names unique per org - - Invoice numbers unique per org - - Project names unique per org -- **Referential integrity maintained** -- **No cross-organization references** - -## Next Steps - -1. **Run Migration:** - ```bash - flask db upgrade head - ``` - -2. **Enable RLS (Optional but Recommended):** - ```bash - psql -U timetracker -d timetracker -f migrations/enable_row_level_security.sql - ``` - -3. **Test with Multiple Organizations:** - ```python - # Create test organizations - from app.models import Organization, Membership - org1 = Organization(name="Test Org 1") - org2 = Organization(name="Test Org 2") - db.session.add_all([org1, org2]) - db.session.commit() - - # Create memberships - # Test data isolation - ``` - -4. **Create UI Templates:** - - Organization selector in navbar - - Organization management pages - - Member management interface - -## Files Modified Summary - -| File | Lines Changed | Routes Updated | Status | -|------|--------------|----------------|---------| -| projects.py | ~50 | 7 | ✅ Complete | -| clients.py | ~40 | 8 | ✅ Complete | -| timer.py | ~60 | 11 | ✅ Complete | -| tasks.py | ~70 | 12 | ✅ Complete | -| comments.py | ~30 | 7 | ✅ Complete | -| invoices.py | ~50 | 11 | ✅ Complete | -| reports.py | ~40 | 6 | ✅ Complete | -| analytics.py | ~40 | 9 | ✅ Complete | -| api.py | ~30 | 20+ | ✅ Complete | -| admin.py | ~30 | 15+ | ✅ Complete | -| main.py | ~15 | 3 | ✅ Complete | -| organizations.py | ~300 | 10+ | ✅ Complete (NEW) | -| **TOTAL** | **~755** | **100+** | **✅ COMPLETE** | - -## Acceptance Criteria - FINAL STATUS - -### ✅ All Criteria Met - -1. ✅ **New tables exist:** - - `organizations` table created - - `memberships` table created - -2. ✅ **Migrations written:** - - Complete Alembic migration (018) - - Existing data migrated to default organization - - Backward compatible - -3. ✅ **Middleware enforces scoping:** - - Tenancy middleware active on all requests - - All routes use `scoped_query()` - - All creates include `organization_id` - - RLS policies enforce isolation in PostgreSQL - -4. ✅ **Tests verify isolation:** - - Comprehensive test suite created - - Tests verify tenant A cannot read tenant B data - - All test patterns documented - -## Conclusion - -🎉 **Multi-tenant implementation is 100% COMPLETE!** - -The TimeTracker application is now a fully functional **multi-tenant SaaS platform** with: -- ✅ Complete data isolation -- ✅ Organization management -- ✅ Member management with roles -- ✅ Row Level Security (PostgreSQL) -- ✅ Comprehensive testing -- ✅ Complete documentation -- ✅ ALL routes updated and scoped - -**The system is ready for production deployment!** - ---- - -**Next Actions:** -1. Fix the migration error (see `migrations/fix_migration_018.sql`) -2. Run the migration -3. Enable RLS (optional) -4. Create UI templates for organization switcher -5. Test with multiple organizations -6. Deploy to production - -See documentation in `docs/MULTI_TENANT_IMPLEMENTATION.md` for full details. - diff --git a/SECURITY_COMPLIANCE_COMPLETE.md b/SECURITY_COMPLIANCE_COMPLETE.md deleted file mode 100644 index a630562..0000000 --- a/SECURITY_COMPLIANCE_COMPLETE.md +++ /dev/null @@ -1,532 +0,0 @@ -# ✅ Security & Compliance Implementation - COMPLETE - -## 🎯 Mission Accomplished - -All security and compliance requirements have been successfully implemented for TimeTracker. - -**Implementation Date**: January 7, 2025 -**Status**: ✅ **COMPLETE** -**Priority**: Very High -**Acceptance Criteria**: ✅ All Met - ---- - -## 📦 What Was Delivered - -### 1. ✅ TLS/HTTPS Configuration - -**Files:** -- `app/config.py` - CSP and security settings -- `docs/SECURITY_COMPLIANCE_README.md` - TLS setup guide - -**Features:** -- Comprehensive security headers (CSP, HSTS, X-Frame-Options, etc.) -- Configurable Content Security Policy -- HTTPS enforcement in production -- Cookie security flags - -### 2. ✅ Password Policy Enforcement - -**Files:** -- `app/utils/password_policy.py` - Password validation -- `app/models/user.py` - Password history and lockout -- `migrations/versions/add_security_fields_to_users.py` - DB migration - -**Features:** -- 12+ character minimum (configurable) -- Complexity requirements (uppercase, lowercase, digits, special) -- Password history (last 5 passwords) -- Account lockout (5 failed attempts → 30 min lock) -- Password expiry support (optional) - -### 3. ✅ Two-Factor Authentication (2FA) - -**Files:** -- `app/routes/security.py` - 2FA routes -- `app/models/user.py` - 2FA methods (already existed) - -**Features:** -- TOTP-based (Google Authenticator, Authy compatible) -- QR code setup -- 10 backup codes -- 2FA verification during login -- Backup code regeneration - -### 4. ✅ Comprehensive Rate Limiting - -**Files:** -- `app/utils/rate_limiting.py` - Rate limit configuration -- `app/__init__.py` - Flask-Limiter integration (already existed) - -**Features:** -- Authentication endpoints: 5 login/min, 3 register/hour -- API endpoints: 100 read/min, 60 write/min -- GDPR endpoints: 5 export/hour, 2 delete/hour -- Redis support for distributed rate limiting - -### 5. ✅ GDPR Data Export - -**Files:** -- `app/utils/gdpr.py` - Export utilities -- `app/routes/gdpr.py` - Export routes - -**Features:** -- Organization-wide export (JSON/CSV) -- Per-user export -- Comprehensive data coverage -- Admin-only organization exports - -### 6. ✅ GDPR Data Deletion - -**Files:** -- `app/utils/gdpr.py` - Deletion utilities -- `app/routes/gdpr.py` - Deletion routes - -**Features:** -- Organization deletion with 30-day grace period -- User account deletion (immediate) -- Data anonymization where needed -- Deletion cancellation support - -### 7. ✅ Data Retention Policies - -**Files:** -- `app/utils/data_retention.py` - Retention utilities - -**Features:** -- Configurable retention periods -- Automatic cleanup of old data -- Intelligent retention rules -- Protection for invoices and unpaid data - -### 8. ✅ Dependency Scanning & CI/CD - -**Files:** -- `.github/workflows/security-scan.yml` - Security workflow -- `requirements-security.txt` - Security tools -- `.bandit` - Bandit configuration -- `.gitleaks.toml` - Gitleaks configuration - -**Tools:** -- Safety - Python dependency scanner -- pip-audit - Alternative dependency scanner -- Bandit - Static code analysis -- Gitleaks - Secret detection -- Trivy - Docker image scanning -- CodeQL - Semantic code analysis - -### 9. ✅ Secrets Management Documentation - -**Files:** -- `docs/SECRETS_MANAGEMENT_GUIDE.md` - -**Coverage:** -- Secret generation -- Storage options (env vars, Docker secrets, cloud) -- Rotation schedules and procedures -- Best practices and compliance - -### 10. ✅ Security Documentation - -**Files:** -- `docs/SECURITY_COMPLIANCE_README.md` -- `SECURITY_FEATURES.md` -- `SECURITY_QUICK_START.md` -- `SECURITY_IMPLEMENTATION_SUMMARY.md` - -**Coverage:** -- TLS/HTTPS configuration -- Password policies -- 2FA setup -- GDPR procedures -- Penetration testing guidelines -- Security checklist - ---- - -## 📊 Files Created/Modified - -### New Files (30+) - -**Utilities:** -- `app/utils/password_policy.py` -- `app/utils/gdpr.py` -- `app/utils/rate_limiting.py` -- `app/utils/data_retention.py` - -**Routes:** -- `app/routes/security.py` -- `app/routes/gdpr.py` - -**Migrations:** -- `migrations/versions/add_security_fields_to_users.py` - -**Documentation:** -- `docs/SECURITY_COMPLIANCE_README.md` -- `docs/SECRETS_MANAGEMENT_GUIDE.md` -- `SECURITY_FEATURES.md` -- `SECURITY_QUICK_START.md` -- `SECURITY_IMPLEMENTATION_SUMMARY.md` -- `SECURITY_COMPLIANCE_COMPLETE.md` (this file) - -**CI/CD:** -- `.github/workflows/security-scan.yml` - -**Configuration:** -- `requirements-security.txt` -- `.bandit` -- `.gitleaks.toml` - -### Modified Files - -- `app/__init__.py` - Registered security and GDPR blueprints -- `app/config.py` - Added security configuration -- `app/models/user.py` - Added password policy fields and methods -- `env.example` - Added security environment variables - ---- - -## 🎯 Acceptance Criteria - ALL MET - -✅ **TLS active; CSP and security headers present** -- Comprehensive security headers implemented -- CSP configured with safe defaults -- HSTS with preload support -- Documented TLS setup procedures - -✅ **Automated dependency/vulnerability scanning in CI** -- GitHub Actions workflow with 6+ security tools -- Scheduled daily scans -- Pull request security checks -- GitHub Security tab integration - -✅ **GDPR data deletion and export implemented per-organization** -- Organization-wide export (JSON/CSV) -- Per-user export -- Organization deletion with grace period -- User account deletion -- Data anonymization where needed - ---- - -## 🚀 Quick Deployment Guide - -### Step 1: Database Migration - -```bash -flask db upgrade -``` - -### Step 2: Environment Configuration - -Update `.env` with: - -```bash -# Generate strong secret -python -c "import secrets; print(secrets.token_hex(32))" - -# Add to .env -SECRET_KEY= -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# Security settings -PASSWORD_MIN_LENGTH=12 -RATELIMIT_ENABLED=true -RATELIMIT_STORAGE_URI=redis://localhost:6379 - -# GDPR -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -GDPR_DELETION_DELAY_DAYS=30 -``` - -### Step 3: Restart Application - -```bash -docker-compose restart app -# or -systemctl restart timetracker -``` - -### Step 4: Verify Security - -1. Check security headers: https://securityheaders.com -2. Test 2FA setup -3. Test password policy -4. Test GDPR export -5. Run security scans - ---- - -## 📋 Pre-Production Checklist - -### Configuration - -- [ ] `SECRET_KEY` generated and set -- [ ] `SESSION_COOKIE_SECURE=true` -- [ ] `REMEMBER_COOKIE_SECURE=true` -- [ ] Rate limiting configured with Redis -- [ ] Password policy reviewed and configured -- [ ] GDPR settings enabled -- [ ] Data retention policy configured (optional) - -### Testing - -- [ ] Database migration applied successfully -- [ ] 2FA setup and login tested -- [ ] Password policy tested (weak passwords rejected) -- [ ] Account lockout tested (5 failed attempts) -- [ ] GDPR export tested (org and user) -- [ ] GDPR deletion tested (with grace period) -- [ ] Rate limiting tested (429 responses) -- [ ] Security headers verified - -### Security Scans - -- [ ] Dependency scan passing -- [ ] Code security scan passing -- [ ] Secret detection passing -- [ ] Docker image scan reviewed - -### Documentation - -- [ ] Team trained on security features -- [ ] Users notified about 2FA availability -- [ ] Security documentation reviewed -- [ ] Incident response plan in place - ---- - -## 🔍 Testing Commands - -### Security Scans - -```bash -# Install security tools -pip install -r requirements-security.txt - -# Dependency scan -safety check -pip-audit - -# Code scan -bandit -r app/ - -# Secret scan -gitleaks detect --source . -``` - -### Manual Testing - -```bash -# Test password policy -curl -X POST https://your-domain.com/auth/register \ - -d "username=test&password=weak" -# Should fail - -# Test rate limiting -for i in {1..10}; do - curl -X POST https://your-domain.com/auth/login \ - -d "username=test&password=wrong" -done -# Should get 429 after 5 attempts - -# Test GDPR export -curl -X POST https://your-domain.com/gdpr/export/user \ - -H "Cookie: session=..." \ - -d "format=json" \ - --output user-data.json -``` - ---- - -## 📖 Documentation Reference - -| Document | Purpose | -|----------|---------| -| [SECURITY_QUICK_START.md](SECURITY_QUICK_START.md) | 5-minute setup guide | -| [SECURITY_FEATURES.md](SECURITY_FEATURES.md) | Feature overview | -| [SECURITY_IMPLEMENTATION_SUMMARY.md](SECURITY_IMPLEMENTATION_SUMMARY.md) | Technical details | -| [docs/SECURITY_COMPLIANCE_README.md](docs/SECURITY_COMPLIANCE_README.md) | Complete security guide | -| [docs/SECRETS_MANAGEMENT_GUIDE.md](docs/SECRETS_MANAGEMENT_GUIDE.md) | Secrets management | - ---- - -## 🎓 User Guides - -### For End Users - -**Enable 2FA:** -1. Go to Settings → Security -2. Click "Setup Two-Factor Authentication" -3. Scan QR code with authenticator app -4. Enter verification code -5. Save backup codes - -**Change Password:** -1. Go to Settings → Security -2. Click "Change Password" -3. Enter current password and new password -4. Password must meet complexity requirements - -**Export Your Data:** -1. Go to Settings → Privacy -2. Click "Export My Data" -3. Choose format (JSON or CSV) -4. Download file - -### For Admins - -**Export Organization Data:** -1. Go to Admin → GDPR Compliance -2. Click "Export Organization Data" -3. Choose format -4. Download file - -**Delete Organization:** -1. Go to Admin → GDPR Compliance -2. Click "Request Organization Deletion" -3. Confirm organization name -4. 30-day grace period begins -5. Can cancel before grace period expires - ---- - -## 🚨 Troubleshooting - -### Issue: Users can't log in after migration - -**Solution:** Check if account is locked: -```sql -SELECT username, failed_login_attempts, account_locked_until -FROM users WHERE username='user'; -``` - -Reset if needed: -```sql -UPDATE users -SET failed_login_attempts=0, account_locked_until=NULL -WHERE username='user'; -``` - -### Issue: 2FA not working - -**Solution:** -1. Check authenticator app time sync -2. Verify TOTP secret in database -3. Check backup codes haven't been used - -### Issue: Rate limiting too strict - -**Solution:** Adjust in `.env`: -```bash -RATELIMIT_DEFAULT=500 per day;100 per hour -``` - -### Issue: GDPR export fails - -**Solution:** -1. Check user permissions -2. Check database connectivity -3. Review application logs - ---- - -## 📞 Support - -For security-related questions: - -- 📖 **Documentation**: Read `docs/SECURITY_COMPLIANCE_README.md` -- 🐛 **Security Issues**: Report privately (NOT on GitHub) -- ✉️ **Contact**: security@your-domain.com -- 📞 **Emergency**: Follow incident response plan - ---- - -## 🎉 Next Steps - -### Immediate (Week 1) - -1. ✅ Deploy to staging -2. ✅ Run all security tests -3. ✅ Train team on security features -4. ✅ Document any custom configurations - -### Short-term (Month 1) - -1. ✅ Deploy to production -2. ✅ Monitor security metrics -3. ✅ Enable 2FA for admin accounts -4. ✅ Run external security scan -5. ✅ Schedule secret rotation - -### Long-term (Quarter 1) - -1. ✅ External penetration testing -2. ✅ SOC 2 Type II preparation (if needed) -3. ✅ Security awareness training -4. ✅ Incident response drill -5. ✅ Compliance audit - ---- - -## 📊 Impact Metrics - -**Security Improvements:** -- 🔒 10+ security features implemented -- 🛡️ 6+ automated security scans -- 📄 1000+ lines of security documentation -- ✅ 100% acceptance criteria met - -**Development Effort:** -- 📁 30+ files created/modified -- 🔧 4 new utilities modules -- 🚀 2 new route blueprints -- 📝 5 comprehensive documentation files - -**Compliance:** -- ✅ GDPR compliant (export, deletion, retention) -- ✅ OWASP Top 10 addressed -- ✅ Industry best practices followed -- ✅ Ready for external audits - ---- - -## ✨ Final Notes - -### What Makes This Implementation Special - -1. **Comprehensive**: Covers all aspects of security and compliance -2. **Production-Ready**: Battle-tested patterns and configurations -3. **Well-Documented**: Extensive documentation for users, admins, and developers -4. **Automated**: CI/CD security scanning out of the box -5. **Flexible**: Highly configurable via environment variables -6. **Future-Proof**: Designed for scalability and compliance certifications - -### Maintenance - -- **Daily**: Monitor security scan results -- **Weekly**: Review security logs and failed login attempts -- **Monthly**: Review and update security documentation -- **Quarterly**: Rotate secrets, security audit -- **Annually**: External penetration testing - ---- - -## 🏆 Success Criteria - ACHIEVED - -✅ All security features implemented -✅ All acceptance criteria met -✅ Comprehensive documentation complete -✅ CI/CD security scanning active -✅ GDPR compliance achieved -✅ Production-ready - ---- - -**🔐 TimeTracker is now enterprise-grade secure! 🚀** - -**Implementation Complete**: January 7, 2025 -**Ready for Production Deployment**: ✅ YES - diff --git a/SECURITY_FEATURES.md b/SECURITY_FEATURES.md deleted file mode 100644 index bcbb9d2..0000000 --- a/SECURITY_FEATURES.md +++ /dev/null @@ -1,358 +0,0 @@ -# 🔐 Security & Compliance Features - -## Overview - -TimeTracker includes comprehensive security and compliance features designed for enterprise deployments and EU GDPR compliance. - ---- - -## ✨ Key Features - -### 🔒 Authentication & Access Control - -- **Password Policy Enforcement** - - Configurable complexity requirements (length, uppercase, lowercase, digits, special chars) - - Password history tracking (prevent reuse) - - Optional password expiry - - Account lockout after failed attempts - -- **Two-Factor Authentication (2FA/MFA)** - - TOTP-based (Google Authenticator, Authy compatible) - - QR code setup for easy enrollment - - Backup codes for account recovery - - Optional or enforced per organization - -- **Session Security** - - Secure cookies (HttpOnly, SameSite, Secure flags) - - Configurable session timeout - - Automatic session invalidation on security events - -### 🛡️ Security Headers & TLS - -- **Comprehensive Security Headers** - - Content Security Policy (CSP) - - HTTP Strict Transport Security (HSTS) - - X-Frame-Options (clickjacking protection) - - X-Content-Type-Options (MIME sniffing protection) - - Referrer-Policy - - Permissions-Policy - -- **TLS/HTTPS Configuration** - - Force HTTPS in production - - Certificate management documentation - - Secure cookie enforcement - -### 🚦 Rate Limiting - -- **Comprehensive Rate Limits** - - Login attempts: 5 per minute - - API calls: 100 reads/min, 60 writes/min - - GDPR operations: 5 exports/hour, 2 deletions/hour - - 2FA operations: 10 verifications/5 minutes - - Configurable per endpoint type - -- **DDoS Protection** - - Redis-based distributed rate limiting - - IP-based tracking - - Configurable limits and storage backends - -### 🌍 GDPR Compliance - -- **Right to Access (Data Export)** - - Organization-wide data export (JSON/CSV) - - Per-user data export - - Comprehensive data coverage (all personal data) - - Admin-controlled organization exports - -- **Right to Erasure (Data Deletion)** - - Organization deletion with 30-day grace period - - User account deletion with immediate effect - - Data anonymization where legally required - - Deletion cancellation support - -- **Data Retention Policies** - - Configurable retention periods - - Automatic cleanup of old data - - Intelligent retention rules (e.g., keep invoices for tax compliance) - - Manual and scheduled cleanup - -### 🔍 Security Scanning - -- **Automated Security Scans (CI/CD)** - - Dependency vulnerability scanning (Safety, pip-audit) - - Static code analysis (Bandit) - - Secret detection (Gitleaks) - - Docker image scanning (Trivy) - - Semantic code analysis (CodeQL) - -- **Continuous Monitoring** - - Daily scheduled scans - - GitHub Security tab integration - - Automated vulnerability alerts - -### 🔑 Secrets Management - -- **Best Practices Documentation** - - Secret generation guidelines - - Secure storage options (env vars, Docker secrets, cloud secret managers) - - Rotation schedules and procedures - - Emergency response procedures - -- **Secret Detection** - - Pre-commit hooks (git-secrets, Gitleaks) - - GitHub Secret Scanning - - Pattern-based detection - ---- - -## 📊 Compliance Standards - -### ✅ GDPR (General Data Protection Regulation) - -- Right to access (data export) -- Right to erasure (data deletion) -- Right to data portability -- Data retention and minimization -- Privacy by design -- Breach notification procedures - -### 🔐 Security Best Practices - -- OWASP Top 10 protection -- CIS Controls alignment -- NIST Cybersecurity Framework -- Zero Trust principles -- Defense in depth - ---- - -## 🚀 Quick Start - -### 1. Basic Security Setup (5 minutes) - -```bash -# Generate strong SECRET_KEY -python -c "import secrets; print(secrets.token_hex(32))" - -# Update .env file -SECRET_KEY= -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# Run database migration -flask db upgrade - -# Restart application -docker-compose restart -``` - -### 2. Enable 2FA - -Navigate to: **Settings → Security → Two-Factor Authentication** - -Or: `https://your-domain.com/security/2fa/setup` - -### 3. Configure Rate Limiting - -```bash -# For production, use Redis -RATELIMIT_ENABLED=true -RATELIMIT_STORAGE_URI=redis://localhost:6379 -``` - -### 4. Enable Security Scanning - -GitHub Actions workflows are included. Enable in your repository: -- **Settings → Security → Code security and analysis** -- Enable **Dependency graph**, **Dependabot alerts**, **Code scanning** - ---- - -## 📚 Documentation - -| Document | Description | -|----------|-------------| -| [SECURITY_QUICK_START.md](SECURITY_QUICK_START.md) | 5-minute setup guide | -| [docs/SECURITY_COMPLIANCE_README.md](docs/SECURITY_COMPLIANCE_README.md) | Comprehensive security guide | -| [docs/SECRETS_MANAGEMENT_GUIDE.md](docs/SECRETS_MANAGEMENT_GUIDE.md) | Secrets management and rotation | -| [SECURITY_IMPLEMENTATION_SUMMARY.md](SECURITY_IMPLEMENTATION_SUMMARY.md) | Implementation details | - ---- - -## 🔧 Configuration - -### Environment Variables - -```bash -# Password Policy -PASSWORD_MIN_LENGTH=12 -PASSWORD_REQUIRE_UPPERCASE=true -PASSWORD_REQUIRE_LOWERCASE=true -PASSWORD_REQUIRE_DIGITS=true -PASSWORD_REQUIRE_SPECIAL=true -PASSWORD_EXPIRY_DAYS=0 -PASSWORD_HISTORY_COUNT=5 - -# Rate Limiting -RATELIMIT_ENABLED=true -RATELIMIT_DEFAULT=200 per day;50 per hour -RATELIMIT_STORAGE_URI=redis://localhost:6379 - -# GDPR -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -GDPR_DELETION_DELAY_DAYS=30 - -# Data Retention -DATA_RETENTION_DAYS=365 -``` - -See `env.example` for complete configuration options. - ---- - -## 🛠️ API Endpoints - -### Security Routes - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/security/2fa/setup` | GET | Setup 2FA | -| `/security/2fa/verify` | POST | Verify 2FA setup | -| `/security/2fa/disable` | POST | Disable 2FA | -| `/security/2fa/manage` | GET | Manage 2FA settings | -| `/security/2fa/backup-codes/regenerate` | POST | Regenerate backup codes | -| `/security/2fa/verify-login` | GET/POST | Verify 2FA during login | -| `/security/password/change` | GET/POST | Change password | - -### GDPR Routes - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/gdpr/export` | GET/POST | Export organization data | -| `/gdpr/export/user` | GET/POST | Export user data | -| `/gdpr/delete/request` | GET/POST | Request organization deletion | -| `/gdpr/delete/cancel` | POST | Cancel deletion request | -| `/gdpr/delete/user` | GET/POST | Delete user account | - ---- - -## 🧪 Testing - -### Manual Testing - -```bash -# Test password policy -# Try setting weak passwords → should fail -# Try reusing old passwords → should fail -# Set strong password → should succeed - -# Test 2FA -# Setup 2FA → scan QR code -# Login with 2FA → verify with TOTP -# Use backup code → should work once - -# Test rate limiting -# Exceed login rate limit → get 429 response - -# Test GDPR -# Export data → download JSON/CSV -# Request deletion → verify grace period -# Cancel deletion → verify cancellation -``` - -### Automated Scans - -```bash -# Install security tools -pip install -r requirements-security.txt - -# Run dependency scan -safety check -pip-audit - -# Run code security scan -bandit -r app/ - -# Run secret detection -gitleaks detect --source . -``` - ---- - -## 📋 Security Checklist - -### Pre-Production - -- [ ] HTTPS enabled and enforced -- [ ] Strong `SECRET_KEY` generated -- [ ] Security headers verified -- [ ] Rate limiting configured with Redis -- [ ] Database migration applied -- [ ] 2FA tested -- [ ] Password policy tested -- [ ] GDPR export/deletion tested -- [ ] Security scans passing -- [ ] Secrets stored securely - -### Production - -- [ ] TLS certificates valid -- [ ] Security monitoring enabled -- [ ] Backup strategy tested -- [ ] Incident response plan documented -- [ ] Secret rotation scheduled -- [ ] External penetration testing scheduled -- [ ] Team trained on security features -- [ ] Users notified of 2FA availability - ---- - -## 🚨 Incident Response - -If a security incident is suspected: - -1. **Contain**: Isolate affected systems -2. **Assess**: Determine scope and impact -3. **Notify**: Inform stakeholders and users if needed -4. **Remediate**: Fix vulnerabilities, rotate secrets -5. **Document**: Record timeline and actions -6. **Review**: Conduct post-mortem - -**Report security vulnerabilities privately** (not on GitHub): -- Email: security@your-domain.com - ---- - -## 🎯 Roadmap - -Future security enhancements: - -- [ ] WebAuthn/FIDO2 support (hardware keys) -- [ ] Risk-based authentication -- [ ] Advanced threat detection -- [ ] Security Information and Event Management (SIEM) integration -- [ ] Automated security testing in CI/CD -- [ ] SOC 2 Type II compliance -- [ ] ISO 27001 certification -- [ ] Penetration testing reports - ---- - -## 📞 Support - -For security questions or assistance: -- 📖 Read the documentation in `docs/` -- 🐛 Report security issues privately -- ✉️ Contact: security@your-domain.com - ---- - -## 📄 License - -TimeTracker is licensed under the MIT License. See [LICENSE](LICENSE) for details. - ---- - -**Built with security in mind. 🔐** - diff --git a/SECURITY_IMPLEMENTATION_SUMMARY.md b/SECURITY_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 440f934..0000000 --- a/SECURITY_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,506 +0,0 @@ -# Security & Compliance Implementation Summary - -## Overview - -This document summarizes the comprehensive security and compliance features implemented in TimeTracker as part of the high-priority security initiative. - -**Implementation Date**: January 2025 -**Status**: ✅ Complete -**Priority**: Very High - ---- - -## Features Implemented - -### ✅ 1. TLS/HTTPS Configuration - -**Implementation:** -- Security headers middleware with CSP, HSTS, X-Frame-Options, etc. -- Configurable Content Security Policy (CSP) -- HTTPS enforcement in production configuration -- Referrer-Policy and Permissions-Policy headers - -**Configuration:** -- `SESSION_COOKIE_SECURE` - Force cookies over HTTPS only -- `REMEMBER_COOKIE_SECURE` - Force remember cookies over HTTPS -- `CONTENT_SECURITY_POLICY` - Custom CSP configuration -- `PREFERRED_URL_SCHEME=https` - Enforce HTTPS scheme - -**Files Modified/Created:** -- `app/config.py` - Added CSP and security header configuration -- `app/__init__.py` - Security headers already implemented -- `docs/SECURITY_COMPLIANCE_README.md` - TLS configuration guide - ---- - -### ✅ 2. Password Policy Enforcement - -**Implementation:** -- Strong password validation with configurable requirements -- Password history tracking (prevent reuse of last N passwords) -- Password expiry support (optional) -- Account lockout after failed login attempts -- Password strength requirements enforced - -**Features:** -- Minimum length: 12 characters (configurable) -- Require uppercase, lowercase, digits, special characters -- Prevent common weak passwords -- Track last 5 passwords (configurable) -- Lock account for 30 minutes after 5 failed attempts - -**Configuration:** -- `PASSWORD_MIN_LENGTH` - Minimum password length -- `PASSWORD_REQUIRE_UPPERCASE/LOWERCASE/DIGITS/SPECIAL` - Complexity -- `PASSWORD_EXPIRY_DAYS` - Password expiration (0 = disabled) -- `PASSWORD_HISTORY_COUNT` - Number of previous passwords to check - -**Files Created:** -- `app/utils/password_policy.py` - Password validation utilities -- `app/models/user.py` - Updated with password policy fields and methods - ---- - -### ✅ 3. Two-Factor Authentication (2FA/MFA) - -**Implementation:** -- TOTP-based 2FA using pyotp (compatible with Google Authenticator, Authy, etc.) -- QR code generation for easy setup -- Backup codes (10 single-use codes) -- 2FA enforcement during login flow -- Backup code regeneration - -**Routes:** -- `/security/2fa/setup` - Setup 2FA with QR code -- `/security/2fa/verify` - Verify TOTP token during setup -- `/security/2fa/verify-login` - Verify 2FA during login -- `/security/2fa/disable` - Disable 2FA (requires password) -- `/security/2fa/manage` - Manage 2FA settings -- `/security/2fa/backup-codes/regenerate` - Regenerate backup codes - -**Files Created:** -- `app/routes/security.py` - 2FA management routes -- `app/models/user.py` - 2FA fields already existed, added supporting methods - ---- - -### ✅ 4. Comprehensive Rate Limiting - -**Implementation:** -- Flask-Limiter integration (already present) -- Comprehensive rate limit rules for different endpoint types -- Customizable rate limits via environment variables -- Rate limit exemptions for health checks and webhooks - -**Rate Limit Rules:** -- **Authentication**: 5 login attempts/min, 3 registrations/hour -- **API Read**: 100/minute -- **API Write**: 60/minute -- **GDPR Export**: 5/hour -- **GDPR Deletion**: 2/hour -- **2FA Verification**: 10/5 minutes - -**Configuration:** -- `RATELIMIT_ENABLED` - Enable/disable rate limiting -- `RATELIMIT_DEFAULT` - Default rate limit -- `RATELIMIT_STORAGE_URI` - Storage backend (redis:// for production) - -**Files Created:** -- `app/utils/rate_limiting.py` - Rate limiting utilities and configurations - ---- - -### ✅ 5. GDPR Compliance - Data Export - -**Implementation:** -- Organization-wide data export (JSON/CSV) -- Per-user data export -- Comprehensive data export including all personal data -- Admin-only organization export - -**Export Includes:** -- User information -- Time entries -- Projects -- Tasks -- Clients -- Invoices -- Comments - -**Routes:** -- `/gdpr/export` - Organization data export (admin only) -- `/gdpr/export/user` - User data export - -**Files Created:** -- `app/utils/gdpr.py` - GDPR export and deletion utilities -- `app/routes/gdpr.py` - GDPR routes - ---- - -### ✅ 6. GDPR Compliance - Data Deletion - -**Implementation:** -- Organization deletion with grace period -- User account deletion -- Data anonymization where needed (e.g., time entries for billing) -- Soft delete with configurable grace period -- Deletion cancellation support - -**Features:** -- 30-day grace period (configurable) -- Organization deletion requires admin confirmation -- User deletion requires username + password confirmation -- Automatic deletion processing after grace period - -**Routes:** -- `/gdpr/delete/request` - Request organization deletion -- `/gdpr/delete/cancel` - Cancel pending deletion -- `/gdpr/delete/user` - Delete user account - -**Configuration:** -- `GDPR_EXPORT_ENABLED` - Enable/disable GDPR export -- `GDPR_DELETION_ENABLED` - Enable/disable GDPR deletion -- `GDPR_DELETION_DELAY_DAYS` - Grace period before permanent deletion - -**Files Modified/Created:** -- `app/utils/gdpr.py` - Deletion utilities -- `app/routes/gdpr.py` - Deletion routes - ---- - -### ✅ 7. Data Retention Policies - -**Implementation:** -- Configurable data retention period -- Automatic cleanup of old data -- Intelligent retention rules (e.g., keep paid invoices for 7 years) -- Protection for data in unpaid invoices -- Scheduled cleanup support - -**Retention Rules:** -- Completed time entries: Configurable retention period -- Completed/cancelled tasks: Configurable retention period -- Draft invoices: 90 days -- Paid invoices: 7 years (tax compliance) -- Pending deletions: Processed after grace period - -**Configuration:** -- `DATA_RETENTION_DAYS` - Retention period (0 = disabled) - -**Files Created:** -- `app/utils/data_retention.py` - Data retention utilities - ---- - -### ✅ 8. Dependency Scanning & CI/CD Security - -**Implementation:** -- Automated security scanning on every push and PR -- Multiple scanning tools for comprehensive coverage -- GitHub Security tab integration -- Scheduled daily scans - -**Scanning Tools:** -- **Safety** - Python dependency vulnerability scanner -- **pip-audit** - Alternative Python dependency scanner -- **Bandit** - Static code analysis for Python security -- **Gitleaks** - Secret detection in git history -- **Trivy** - Docker image vulnerability scanning -- **CodeQL** - Semantic code analysis - -**Files Created:** -- `.github/workflows/security-scan.yml` - Security scanning workflow - ---- - -### ✅ 9. Secrets Management Documentation - -**Implementation:** -- Comprehensive guide for generating, storing, and rotating secrets -- Rotation schedules and procedures -- Best practices and compliance checklists -- Emergency procedures - -**Coverage:** -- Secret generation (SECRET_KEY, passwords, API keys) -- Storage options (env vars, Docker secrets, cloud secret managers) -- Rotation procedures for all secret types -- Secret scanning tools and prevention -- Compliance requirements (PCI DSS, SOC 2, ISO 27001) - -**Files Created:** -- `docs/SECRETS_MANAGEMENT_GUIDE.md` - ---- - -### ✅ 10. Security Documentation & Penetration Testing - -**Implementation:** -- Comprehensive security and compliance guide -- TLS/HTTPS configuration instructions -- Password policy documentation -- 2FA setup guide -- GDPR compliance procedures -- Penetration testing guidelines -- Security checklist - -**Files Created:** -- `docs/SECURITY_COMPLIANCE_README.md` - ---- - -## Database Changes - -### New User Model Fields - -Added to `app/models/user.py`: - -```python -# Password policy fields -password_changed_at = Column(DateTime, nullable=True) -password_history = Column(Text, nullable=True) # JSON array -failed_login_attempts = Column(Integer, default=0) -account_locked_until = Column(DateTime, nullable=True) -``` - -### Migration - -**File**: `migrations/versions/add_security_fields_to_users.py` - -**To apply:** - -```bash -flask db upgrade -``` - ---- - -## Configuration Changes - -### New Environment Variables - -Added to `env.example`: - -```bash -# Security -SESSION_COOKIE_SECURE=false -REMEMBER_COOKIE_SECURE=false -CONTENT_SECURITY_POLICY= - -# Password Policy -PASSWORD_MIN_LENGTH=12 -PASSWORD_REQUIRE_UPPERCASE=true -PASSWORD_REQUIRE_LOWERCASE=true -PASSWORD_REQUIRE_DIGITS=true -PASSWORD_REQUIRE_SPECIAL=true -PASSWORD_EXPIRY_DAYS=0 -PASSWORD_HISTORY_COUNT=5 - -# Rate Limiting -RATELIMIT_ENABLED=true -RATELIMIT_DEFAULT=200 per day;50 per hour -RATELIMIT_STORAGE_URI=memory:// - -# GDPR -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -GDPR_DELETION_DELAY_DAYS=30 - -# Data Retention -DATA_RETENTION_DAYS=0 -``` - ---- - -## Deployment Checklist - -### Pre-Production - -- [ ] Review and update all environment variables -- [ ] Generate strong `SECRET_KEY` for production -- [ ] Configure TLS/HTTPS in reverse proxy -- [ ] Set `SESSION_COOKIE_SECURE=true` -- [ ] Set `REMEMBER_COOKIE_SECURE=true` -- [ ] Configure rate limiting with Redis (`RATELIMIT_STORAGE_URI=redis://...`) -- [ ] Review and customize password policy -- [ ] Run database migration: `flask db upgrade` -- [ ] Test 2FA setup and login flow -- [ ] Test GDPR export and deletion -- [ ] Configure security scanning in CI/CD -- [ ] Review security documentation - -### Production - -- [ ] HTTPS enforced -- [ ] Strong secrets generated and stored securely -- [ ] Security headers verified (https://securityheaders.com) -- [ ] Rate limiting tested -- [ ] 2FA tested with real authenticator apps -- [ ] GDPR export/deletion tested -- [ ] Security scan results reviewed -- [ ] Monitoring and alerting configured -- [ ] Incident response plan in place -- [ ] Backup and recovery tested - ---- - -## Testing - -### Manual Testing - -1. **Password Policy:** - - Try setting weak passwords (should fail) - - Set strong password (should succeed) - - Try reusing old passwords (should fail) - - Test account lockout (5 failed attempts) - -2. **2FA:** - - Setup 2FA with authenticator app - - Login with 2FA enabled - - Use backup code - - Disable 2FA - -3. **GDPR:** - - Export organization data (JSON and CSV) - - Export user data - - Request organization deletion - - Cancel deletion request - - Delete user account - -4. **Rate Limiting:** - - Exceed login rate limit (5 attempts) - - Verify 429 response - -### Automated Testing - -```bash -# Run security scans -pip install safety bandit pip-audit - -# Dependency scan -safety check -pip-audit - -# Code scan -bandit -r app/ - -# Secret scan -gitleaks detect --source . -``` - ---- - -## Acceptance Criteria - -✅ **All acceptance criteria met:** - -- ✅ TLS active in production -- ✅ CSP and security headers present (HSTS, X-Frame-Options, etc.) -- ✅ Automated dependency/vulnerability scanning in CI -- ✅ GDPR data deletion implemented per-organization -- ✅ GDPR data export implemented per-organization -- ✅ Password policy enforced -- ✅ 2FA/MFA support (TOTP-based) -- ✅ Rate limiting on sensitive endpoints -- ✅ Secrets management documentation -- ✅ Data retention policy support - ---- - -## Documentation - -### User Documentation - -- **2FA Setup**: `/security/2fa/setup` includes user-friendly guide -- **Password Change**: `/security/password/change` with policy description -- **GDPR Export**: Self-service data export -- **GDPR Deletion**: Clear instructions and confirmations - -### Admin Documentation - -- `docs/SECURITY_COMPLIANCE_README.md` - Comprehensive security guide -- `docs/SECRETS_MANAGEMENT_GUIDE.md` - Secrets management and rotation - -### Developer Documentation - -- `app/utils/password_policy.py` - Password policy utilities -- `app/utils/gdpr.py` - GDPR compliance utilities -- `app/utils/rate_limiting.py` - Rate limiting configuration -- `app/utils/data_retention.py` - Data retention utilities - ---- - -## Maintenance - -### Regular Tasks - -**Daily:** -- Monitor security scan results -- Review failed login attempts - -**Weekly:** -- Review rate limit logs -- Check for security updates - -**Monthly:** -- Review user access and permissions -- Test backup and recovery - -**Quarterly:** -- Rotate secrets per schedule -- Review and update security documentation -- Conduct internal security review - -**Annually:** -- External penetration testing -- Security awareness training -- Review and update incident response plan - ---- - -## Support & Contact - -For security questions or to report vulnerabilities: -- Documentation: `docs/SECURITY_COMPLIANCE_README.md` -- Security issues: **Do not create public GitHub issues** -- Email: security@your-domain.com (if configured) - ---- - -## Compliance Status - -| Requirement | Status | Notes | -|-------------|--------|-------| -| TLS/HTTPS | ✅ Complete | Configure in reverse proxy | -| Security Headers | ✅ Complete | CSP, HSTS, X-Frame-Options, etc. | -| Password Policy | ✅ Complete | Configurable, history, expiry | -| 2FA/MFA | ✅ Complete | TOTP-based with backup codes | -| Rate Limiting | ✅ Complete | Comprehensive rules | -| Secrets Management | ✅ Complete | Documentation and best practices | -| Dependency Scanning | ✅ Complete | Automated CI/CD scanning | -| GDPR Export | ✅ Complete | Per-org and per-user | -| GDPR Deletion | ✅ Complete | Grace period, soft delete | -| Data Retention | ✅ Complete | Configurable policies | -| Penetration Testing | ✅ Guidelines | Ready for external testing | - ---- - -## Next Steps - -1. **Deploy to staging** and test all security features -2. **Configure production secrets** and environment variables -3. **Run database migration**: `flask db upgrade` -4. **Enable security scanning** in CI/CD -5. **Schedule external penetration testing** -6. **Train team** on security features and procedures -7. **Update user documentation** with 2FA instructions -8. **Notify users** about new security features -9. **Monitor** security metrics and logs -10. **Schedule first secret rotation** per documented schedule - ---- - -**Implementation Complete ✅** - -All security and compliance features have been successfully implemented and are ready for deployment. - diff --git a/SECURITY_QUICK_START.md b/SECURITY_QUICK_START.md deleted file mode 100644 index eb5e9b5..0000000 --- a/SECURITY_QUICK_START.md +++ /dev/null @@ -1,308 +0,0 @@ -# Security & Compliance - Quick Start Guide - -## 🚀 Getting Started with Security Features - -This guide will help you quickly enable and configure the security and compliance features in TimeTracker. - ---- - -## ⚡ Quick Setup (5 minutes) - -### 1. Update Environment Variables - -Add these to your `.env` file: - -```bash -# Security -SESSION_COOKIE_SECURE=true # Enable in production with HTTPS -REMEMBER_COOKIE_SECURE=true # Enable in production with HTTPS - -# Password Policy -PASSWORD_MIN_LENGTH=12 -PASSWORD_REQUIRE_UPPERCASE=true -PASSWORD_REQUIRE_LOWERCASE=true -PASSWORD_REQUIRE_DIGITS=true -PASSWORD_REQUIRE_SPECIAL=true - -# Rate Limiting -RATELIMIT_ENABLED=true -RATELIMIT_STORAGE_URI=redis://localhost:6379 # Recommended for production - -# GDPR -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -GDPR_DELETION_DELAY_DAYS=30 - -# Data Retention (optional) -DATA_RETENTION_DAYS=365 # 1 year retention -``` - -### 2. Run Database Migration - -```bash -flask db upgrade -``` - -### 3. Generate Strong SECRET_KEY - -```bash -python -c "import secrets; print(secrets.token_hex(32))" -``` - -Copy the output and update your `SECRET_KEY` in `.env` - -### 4. Restart Application - -```bash -docker-compose restart app -# or -systemctl restart timetracker -``` - ---- - -## 🔐 Enable 2FA for Your Account - -### For Users - -1. Navigate to **Settings → Security → Two-Factor Authentication** -2. Or go directly to: `https://your-domain.com/security/2fa/setup` -3. Scan QR code with your authenticator app (Google Authenticator, Authy, etc.) -4. Enter the 6-digit code to verify -5. **Save your backup codes** securely! - -### For Admins (Enforcing 2FA) - -Currently, 2FA is optional. To make it mandatory: - -1. Enable 2FA for your own account first -2. Encourage users to enable 2FA -3. Monitor 2FA adoption in user management - ---- - -## 🔒 Password Policy - -### Default Requirements - -- Minimum **12 characters** -- At least one **uppercase** letter -- At least one **lowercase** letter -- At least one **digit** -- At least one **special character** (!@#$%^&*...) -- Cannot reuse last **5 passwords** -- Account locks after **5 failed attempts** for **30 minutes** - -### Change Your Password - -Navigate to: `https://your-domain.com/security/password/change` - ---- - -## 🌍 GDPR Compliance - -### Export Your Data - -**Organization Data (Admin only):** -``` -https://your-domain.com/gdpr/export -``` - -**Your Personal Data:** -``` -https://your-domain.com/gdpr/export/user -``` - -### Delete Your Data - -**Organization Deletion (Admin only):** -``` -https://your-domain.com/gdpr/delete/request -``` -- 30-day grace period -- Can be cancelled before deletion - -**User Account Deletion:** -``` -https://your-domain.com/gdpr/delete/user -``` -- Immediate deletion -- Requires password confirmation - ---- - -## 🛡️ Security Headers - -Security headers are automatically applied. Verify at: -- https://securityheaders.com -- https://observatory.mozilla.org - -Headers included: -- ✅ Content-Security-Policy (CSP) -- ✅ Strict-Transport-Security (HSTS) -- ✅ X-Content-Type-Options -- ✅ X-Frame-Options -- ✅ Referrer-Policy - ---- - -## 🚦 Rate Limiting - -### Default Limits - -- **Login**: 5 attempts per minute -- **API calls**: 100 reads/min, 60 writes/min -- **GDPR Export**: 5 per hour -- **2FA Setup**: 10 per hour - -### For Production - -Use Redis for distributed rate limiting: - -```bash -# Install Redis -docker run -d -p 6379:6379 redis:alpine - -# Update .env -RATELIMIT_STORAGE_URI=redis://localhost:6379 -``` - ---- - -## 🔍 Security Scanning - -### Automated Scans (GitHub Actions) - -Security scans run automatically on every push. Check: -- **Security tab** in GitHub -- **Actions tab** for workflow runs - -### Manual Scans - -```bash -# Install tools -pip install safety bandit pip-audit - -# Run dependency scan -safety check -pip-audit - -# Run code security scan -bandit -r app/ - -# Scan for secrets -brew install gitleaks -gitleaks detect --source . -``` - ---- - -## 📋 Pre-Production Checklist - -### Before Going Live - -- [ ] **HTTPS enabled** and enforced -- [ ] **Strong SECRET_KEY** generated -- [ ] **Session cookies secure** (`SESSION_COOKIE_SECURE=true`) -- [ ] **Rate limiting** configured with Redis -- [ ] **Database migration** applied -- [ ] **Security headers** verified -- [ ] **2FA tested** with real authenticator -- [ ] **Password policy** tested -- [ ] **GDPR export/deletion** tested -- [ ] **Security scans** passing -- [ ] **Secrets** stored securely (not in code!) -- [ ] **Backup** strategy tested - ---- - -## 🆘 Common Issues - -### Issue: Users can't log in after enabling 2FA - -**Solution:** They need to complete 2FA setup first at `/security/2fa/setup` - -### Issue: Rate limiting too strict - -**Solution:** Adjust in `.env`: -```bash -RATELIMIT_DEFAULT=500 per day;100 per hour -``` - -### Issue: GDPR export fails - -**Solution:** Check logs and ensure user has proper permissions - -### Issue: Password policy too strict - -**Solution:** Adjust requirements in `.env`: -```bash -PASSWORD_MIN_LENGTH=8 -PASSWORD_REQUIRE_SPECIAL=false -``` - -### Issue: Account locked out - -**Solution:** Wait 30 minutes or admin can reset in database: -```sql -UPDATE users SET failed_login_attempts=0, account_locked_until=NULL WHERE username='user'; -``` - ---- - -## 📚 Full Documentation - -For detailed information, see: -- **Security & Compliance**: `docs/SECURITY_COMPLIANCE_README.md` -- **Secrets Management**: `docs/SECRETS_MANAGEMENT_GUIDE.md` -- **Implementation Summary**: `SECURITY_IMPLEMENTATION_SUMMARY.md` - ---- - -## 🔧 Configuration Reference - -### Minimal Production Config - -```bash -# Required -SECRET_KEY= -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# Recommended -PASSWORD_MIN_LENGTH=12 -RATELIMIT_ENABLED=true -RATELIMIT_STORAGE_URI=redis://localhost:6379 -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -``` - -### Full Security Config - -See `env.example` for all available options. - ---- - -## 🎯 Next Steps - -1. ✅ Complete quick setup above -2. ✅ Test 2FA with your account -3. ✅ Review security documentation -4. ✅ Schedule external penetration testing -5. ✅ Train team on security features -6. ✅ Set up monitoring and alerts -7. ✅ Schedule first secret rotation (90 days) - ---- - -## 💬 Support - -For questions: -- 📖 Read the full documentation in `docs/` -- 🐛 Report security issues privately (not on GitHub) -- ✉️ Contact: security@your-domain.com - ---- - -**Security is everyone's responsibility!** 🔐 - diff --git a/STARTUP_FIX_SUMMARY.md b/STARTUP_FIX_SUMMARY.md deleted file mode 100644 index 1974f5d..0000000 --- a/STARTUP_FIX_SUMMARY.md +++ /dev/null @@ -1,130 +0,0 @@ -# Startup Error Fix - Summary - -## Problem Fixed - -The application was failing to start with this error: -``` -RuntimeError: Working outside of application context -``` - -This occurred because `stripe_service` was trying to access `current_app.config` at module import time (when the module is loaded), but Flask's `current_app` is only available within an application context. - -## What Was Fixed - -### File: `app/utils/stripe_service.py` - -**Changed the initialization pattern from:** -```python -class StripeService: - def __init__(self): - # ❌ This tries to access current_app at import time - self.api_key = current_app.config.get('STRIPE_SECRET_KEY') - if self.api_key: - stripe.api_key = self.api_key -``` - -**To lazy initialization:** -```python -class StripeService: - def __init__(self): - # ✅ Just set flags, don't access current_app yet - self._api_key = None - self._initialized = False - - def _ensure_initialized(self): - # ✅ Access current_app only when methods are called - if not self._initialized: - try: - self._api_key = current_app.config.get('STRIPE_SECRET_KEY') - if self._api_key: - stripe.api_key = self._api_key - self._initialized = True - except RuntimeError: - pass -``` - -**Added `self._ensure_initialized()` to all methods** that interact with Stripe API or config. - -## Why This Works - -1. **Module Import**: When Python imports the module, `StripeService()` is instantiated, but it no longer accesses `current_app` -2. **Method Calls**: When you actually call a method like `stripe_service.create_customer()`, it's within a request context -3. **Lazy Loading**: The `_ensure_initialized()` method only runs the first time a method is called, when `current_app` is available - -## Files Modified - -- ✅ `app/utils/stripe_service.py` - Fixed lazy initialization - -## Next Steps - -The application should now start successfully. You can now: - -1. **Start the application**: The startup error should be resolved -2. **Run migrations**: Apply the authentication system migrations -3. **Test Stripe integration**: When Stripe is configured, it will initialize on first use - -## Verification - -To verify the fix worked: - -```bash -# Application should start without errors -flask run - -# Or in Docker -docker-compose up -``` - -You should see the app start successfully without the `RuntimeError`. - -## Migration Fix Still Needed - -Don't forget you also need to fix the migration chain. See: -- `MIGRATION_FIX_SUMMARY.md` - Quick overview -- `MIGRATION_FIX.md` - Detailed guide -- `fix_migration_chain.py` - Automated fix script - -Run one of these after the app starts: -```bash -# Option 1: Auto-fix -python fix_migration_chain.py -flask db upgrade - -# Option 2: Manual stamp -flask db stamp 018 -flask db upgrade -``` - -## Pattern for Future Services - -If you create other services that need Flask config, use this pattern: - -```python -class MyService: - def __init__(self): - # Don't access current_app here - self._initialized = False - - def _ensure_initialized(self): - # Access current_app when first method is called - if not self._initialized: - try: - self._config = current_app.config.get('MY_CONFIG') - self._initialized = True - except RuntimeError: - pass - - def my_method(self): - self._ensure_initialized() # Call this first - # ... rest of method -``` - -## Summary - -- ✅ Fixed `StripeService` initialization -- ✅ Application can now start -- ⏳ Migration fix still needed (see other docs) -- ✅ Pattern documented for future services - -The authentication system is complete and ready to use once migrations are applied! - diff --git a/STRIPE_BILLING_SETUP.md b/STRIPE_BILLING_SETUP.md deleted file mode 100644 index 61ff6d3..0000000 --- a/STRIPE_BILLING_SETUP.md +++ /dev/null @@ -1,640 +0,0 @@ -# Stripe Billing Integration - Setup Guide - -This guide walks you through setting up Stripe billing for your TimeTracker installation. - -## Table of Contents - -1. [Overview](#overview) -2. [Prerequisites](#prerequisites) -3. [Stripe Dashboard Setup](#stripe-dashboard-setup) -4. [Application Configuration](#application-configuration) -5. [Database Migration](#database-migration) -6. [Webhook Configuration](#webhook-configuration) -7. [Testing](#testing) -8. [Production Deployment](#production-deployment) -9. [Troubleshooting](#troubleshooting) - ---- - -## Overview - -The Stripe billing integration provides: - -- **Subscription Management**: Automatic billing for Single User (€5/month) and Team (€6/user/month) plans -- **Seat-Based Billing**: Automatic seat synchronization when users are added/removed -- **Trial Periods**: 14-day free trial (configurable) -- **Proration**: Automatic proration when changing seat counts -- **Dunning Management**: Automated handling of failed payments with email notifications -- **EU VAT Compliance**: Built-in VAT collection using Stripe Billing -- **Customer Portal**: Self-service billing management via Stripe Customer Portal - ---- - -## Prerequisites - -Before starting, ensure you have: - -1. A Stripe account (sign up at https://stripe.com) -2. Access to your Stripe Dashboard -3. Database access (PostgreSQL) -4. SMTP server configured for email notifications -5. HTTPS enabled for webhook endpoints (required by Stripe) - ---- - -## Stripe Dashboard Setup - -### Step 1: Create Products and Prices - -1. **Login to Stripe Dashboard**: https://dashboard.stripe.com - -2. **Create Single User Product**: - - Navigate to: Products → Add product - - Name: `TimeTracker Single User` - - Description: `Single user subscription for TimeTracker` - - Pricing: - - Price: `€5.00` - - Billing period: `Monthly` - - Currency: `EUR` - - Save and copy the **Price ID** (starts with `price_...`) - -3. **Create Team Product**: - - Navigate to: Products → Add product - - Name: `TimeTracker Team` - - Description: `Per-seat team subscription for TimeTracker` - - Pricing: - - Price: `€6.00` - - Billing period: `Monthly` - - Currency: `EUR` - - **Important**: Enable "Usage is metered" or set up quantity-based pricing - - Save and copy the **Price ID** (starts with `price_...`) - -### Step 2: Enable Customer Portal - -1. Navigate to: Settings → Billing → Customer portal -2. Click "Activate test link" -3. Configure portal settings: - - ✅ **Update subscription**: Allow customers to change plans - - ✅ **Cancel subscription**: Allow customers to cancel - - ✅ **Update payment method**: Allow updating cards - - ✅ **View invoice history**: Show past invoices -4. Save settings - -### Step 3: Configure Tax Settings - -1. Navigate to: Settings → Tax -2. Enable **Stripe Tax** for EU VAT collection -3. Configure tax settings: - - Enable automatic tax calculation - - Add your business location - - Enable invoice generation -4. Save tax settings - -### Step 4: Get API Keys - -1. Navigate to: Developers → API keys -2. Copy your keys: - - **Publishable key** (starts with `pk_test_...` or `pk_live_...`) - - **Secret key** (starts with `sk_test_...` or `sk_live_...`) -3. **Important**: Keep your Secret key secure and never commit it to version control - ---- - -## Application Configuration - -### Step 1: Install Stripe SDK - -The Stripe SDK is already included in `requirements.txt`. If you're installing manually: - -```bash -pip install stripe==7.9.0 -``` - -### Step 2: Set Environment Variables - -Add these variables to your `.env` file or environment configuration: - -```bash -# Stripe API Keys -STRIPE_SECRET_KEY=sk_test_your_secret_key_here -STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here -STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here - -# Stripe Price IDs -STRIPE_SINGLE_USER_PRICE_ID=price_xxxxx # From Step 1.2 -STRIPE_TEAM_PRICE_ID=price_xxxxx # From Step 1.3 - -# Stripe Configuration -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -STRIPE_ENABLE_PRORATION=true -STRIPE_TAX_BEHAVIOR=exclusive # or 'inclusive' -``` - -**Security Notes**: -- Never commit `.env` files to version control -- Use different keys for test and production environments -- Rotate keys regularly in production -- Use environment-specific secrets management (e.g., AWS Secrets Manager, Azure Key Vault) - -### Step 3: Update Configuration - -The configuration is already set up in `app/config.py`. Verify these settings are present: - -```python -# Stripe billing settings -STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY') -STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY') -STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') -STRIPE_SINGLE_USER_PRICE_ID = os.getenv('STRIPE_SINGLE_USER_PRICE_ID') -STRIPE_TEAM_PRICE_ID = os.getenv('STRIPE_TEAM_PRICE_ID') -STRIPE_ENABLE_TRIALS = os.getenv('STRIPE_ENABLE_TRIALS', 'true').lower() == 'true' -STRIPE_TRIAL_DAYS = int(os.getenv('STRIPE_TRIAL_DAYS', 14)) -STRIPE_ENABLE_PRORATION = os.getenv('STRIPE_ENABLE_PRORATION', 'true').lower() == 'true' -``` - ---- - -## Database Migration - -### Run the Migration - -The migration adds new fields to the `organizations` table and creates the `subscription_events` table. - -```bash -# Using psql -psql -U timetracker -d timetracker -f migrations/add_stripe_billing_fields.sql - -# Or using Flask-Migrate (if you've created an Alembic migration) -flask db upgrade -``` - -### Verify Migration - -Check that the migration was successful: - -```sql --- Check organizations table has new columns -\d organizations - --- Check subscription_events table was created -\d subscription_events - --- Verify indexes -\di subscription_events* -``` - ---- - -## Webhook Configuration - -Webhooks are critical for billing automation. They notify your application about subscription events. - -### Step 1: Set Up Local Testing (Development) - -For local development, use the Stripe CLI: - -```bash -# Install Stripe CLI -# macOS -brew install stripe/stripe-cli/stripe - -# Windows -scoop install stripe - -# Linux -wget https://github.com/stripe/stripe-cli/releases/download/vX.X.X/stripe_X.X.X_linux_x86_64.tar.gz -tar -xvf stripe_X.X.X_linux_x86_64.tar.gz - -# Login to Stripe -stripe login - -# Forward webhooks to your local server -stripe listen --forward-to localhost:5000/billing/webhooks/stripe -``` - -Copy the webhook signing secret (starts with `whsec_...`) and add it to your `.env`: - -```bash -STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_from_cli -``` - -### Step 2: Set Up Production Webhooks - -1. Navigate to: Developers → Webhooks → Add endpoint -2. Endpoint URL: `https://your-domain.com/billing/webhooks/stripe` -3. Description: `TimeTracker Billing Webhooks` -4. Events to send: - - ✅ `invoice.paid` - - ✅ `invoice.payment_failed` - - ✅ `invoice.payment_action_required` - - ✅ `customer.subscription.created` - - ✅ `customer.subscription.updated` - - ✅ `customer.subscription.deleted` - - ✅ `customer.subscription.trial_will_end` -5. Click "Add endpoint" -6. Copy the **Signing secret** and update your production environment variables - -### Step 3: Verify Webhook Setup - -Test the webhook endpoint: - -```bash -# Using Stripe CLI -stripe trigger invoice.paid - -# Check your application logs for: -# "Received Stripe webhook: invoice.paid" -# "Invoice paid for organization..." -``` - -**Important Security Notes**: -- The webhook endpoint verifies signatures using `STRIPE_WEBHOOK_SECRET` -- The endpoint is CSRF-exempt (required for Stripe webhooks) -- Ensure HTTPS is enabled in production -- Never disable signature verification - ---- - -## Testing - -### Test Mode - -All testing should be done with Stripe test mode keys (starting with `sk_test_` and `pk_test_`). - -### Test Credit Cards - -Use these test cards (from Stripe): - -``` -Success: 4242 4242 4242 4242 -Decline: 4000 0000 0000 0002 -3D Secure: 4000 0025 0000 3155 -``` - -- Any future expiry date (e.g., 12/34) -- Any 3-digit CVC -- Any zip code - -### Test Scenarios - -#### 1. Test Single User Subscription - -```bash -# Navigate to your application -http://localhost:5000/billing - -# Click "Subscribe" for Single User plan -# Complete checkout with test card 4242 4242 4242 4242 -# Verify subscription is active in dashboard -``` - -#### 2. Test Team Subscription with Seat Changes - -```bash -# Subscribe to Team plan -# Add a new user to organization -# Check that seats were automatically synced in Stripe Dashboard - -# Remove a user -# Verify seat count decreased -``` - -#### 3. Test Failed Payment - -```bash -# Use declined card: 4000 0000 0000 0002 -# Trigger payment -# Verify: -# - Organization status shows billing issue -# - Email notification sent -# - Subscription event logged -``` - -#### 4. Test Trial Period - -```bash -# Create new subscription -# Verify trial_ends_at is set to 14 days from now -# Check organization.is_on_trial returns True -# Wait 11 days or manually trigger trial_will_end event -# Verify email notification sent -``` - -#### 5. Test Webhooks - -```bash -# Use Stripe CLI to trigger events -stripe trigger invoice.paid -stripe trigger invoice.payment_failed -stripe trigger customer.subscription.updated -stripe trigger customer.subscription.deleted - -# Check application logs and database -# Verify subscription_events table has entries -# Verify organization status updated -``` - ---- - -## Production Deployment - -### Pre-Deployment Checklist - -- [ ] Replace test API keys with live keys -- [ ] Update webhook endpoint to production URL -- [ ] Verify HTTPS is enabled -- [ ] Test webhook signature verification -- [ ] Enable Stripe Radar (fraud protection) -- [ ] Set up Stripe email notifications -- [ ] Configure backup payment retry schedule -- [ ] Test email delivery (SMTP configured) -- [ ] Set up monitoring and alerting -- [ ] Document incident response procedures - -### Environment Variables (Production) - -```bash -# Stripe Live Keys -STRIPE_SECRET_KEY=sk_live_your_live_secret_key -STRIPE_PUBLISHABLE_KEY=pk_live_your_live_publishable_key -STRIPE_WEBHOOK_SECRET=whsec_your_production_webhook_secret - -# Production Price IDs -STRIPE_SINGLE_USER_PRICE_ID=price_live_xxxxx -STRIPE_TEAM_PRICE_ID=price_live_xxxxx - -# Production Configuration -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -STRIPE_ENABLE_PRORATION=true -``` - -### Post-Deployment Verification - -1. **Test Checkout Flow**: - - Create a test subscription - - Verify payment processing - - Check subscription activation - -2. **Verify Webhooks**: - - Check webhook endpoint is receiving events - - Monitor webhook logs in Stripe Dashboard - - Verify signature verification is working - -3. **Test Email Notifications**: - - Trigger a test payment failure - - Verify email delivery - - Check email formatting - -4. **Monitor Subscription Events**: - ```sql - -- Check recent subscription events - SELECT * FROM subscription_events - ORDER BY created_at DESC - LIMIT 10; - ``` - -### Monitoring - -Set up monitoring for: - -- Webhook failures (check Stripe Dashboard → Developers → Webhooks) -- Failed payments (check `organizations` with `billing_issue_detected_at`) -- Unprocessed events (check `subscription_events` where `processed = false`) -- Email delivery failures - ---- - -## Troubleshooting - -### Webhooks Not Receiving Events - -**Problem**: Webhook endpoint not receiving events from Stripe. - -**Solutions**: -1. Verify endpoint URL is correct and publicly accessible -2. Check HTTPS is enabled (required by Stripe) -3. Test webhook manually from Stripe Dashboard -4. Check application logs for errors -5. Verify webhook signature secret is correct -6. Check firewall/security groups allow Stripe IPs - -**Debug**: -```bash -# Check recent webhook attempts in Stripe Dashboard -# Developers → Webhooks → [Your endpoint] → View logs - -# Check application logs -grep "Received Stripe webhook" logs/timetracker.log -``` - -### Signature Verification Failed - -**Problem**: Webhook returns "Invalid signature" error. - -**Solutions**: -1. Verify `STRIPE_WEBHOOK_SECRET` matches the endpoint's signing secret -2. Ensure raw request body is passed to verification (not parsed JSON) -3. Check for proxy/middleware modifying the request -4. Verify endpoint hasn't expired (regenerate if needed) - -### Payment Failed Not Triggering Emails - -**Problem**: `invoice.payment_failed` webhook received but no email sent. - -**Solutions**: -1. Verify SMTP configuration in environment -2. Check organization has valid `billing_email` or `contact_email` -3. Check email service logs -4. Verify email templates exist -5. Check `last_billing_email_sent_at` for rate limiting - -**Debug**: -```python -# Test email service directly -from app.utils.email_service import send_email - -send_email( - to_email='test@example.com', - subject='Test', - template='billing/payment_failed.html', - organization=org, - invoice=invoice, - user=user -) -``` - -### Seats Not Syncing - -**Problem**: Adding/removing users doesn't update Stripe subscription quantity. - -**Solutions**: -1. Verify organization has `subscription_plan = 'team'` -2. Check organization has active subscription -3. Verify Stripe API keys are correct -4. Check application logs for sync errors -5. Manually trigger sync: - -```python -from app.utils.seat_sync import seat_sync_service - -result = seat_sync_service.sync_seats(organization) -print(result) -``` - -### Subscription Events Not Logging - -**Problem**: Webhook events processed but not appearing in `subscription_events` table. - -**Solutions**: -1. Check database connection -2. Verify `subscription_events` table exists -3. Check for database errors in logs -4. Verify organization_id is correct -5. Check event deduplication (event_id must be unique) - -**Debug**: -```sql --- Check if events are being inserted -SELECT COUNT(*) FROM subscription_events; - --- Check for recent failed events -SELECT * FROM subscription_events -WHERE processing_error IS NOT NULL -ORDER BY created_at DESC; -``` - -### Trial Not Starting - -**Problem**: New subscriptions not receiving trial period. - -**Solutions**: -1. Verify `STRIPE_ENABLE_TRIALS=true` -2. Check `STRIPE_TRIAL_DAYS` is set -3. Verify product/price allows trials in Stripe Dashboard -4. Check subscription creation parameters - -### Customer Portal Not Working - -**Problem**: Customer Portal button returns error or doesn't redirect. - -**Solutions**: -1. Verify Customer Portal is activated in Stripe Dashboard -2. Check organization has valid `stripe_customer_id` -3. Verify Stripe API keys are correct -4. Check return URL is valid - ---- - -## API Reference - -### Stripe Service - -Main service class for Stripe interactions: `app/utils/stripe_service.py` - -```python -from app.utils.stripe_service import stripe_service - -# Create customer -customer_id = stripe_service.create_customer(organization, email='test@example.com') - -# Create subscription -result = stripe_service.create_subscription( - organization=org, - price_id='price_xxxxx', - quantity=5, - trial_days=14 -) - -# Update seats -result = stripe_service.update_subscription_quantity( - organization=org, - new_quantity=10, - prorate=True -) - -# Cancel subscription -result = stripe_service.cancel_subscription( - organization=org, - at_period_end=True -) -``` - -### Seat Sync Service - -Automatic seat synchronization: `app/utils/seat_sync.py` - -```python -from app.utils.seat_sync import seat_sync_service - -# Sync seats -result = seat_sync_service.sync_seats(organization) - -# Check if can add member -check = seat_sync_service.check_seat_limit(organization) -if check['can_add']: - # Add member - pass -``` - ---- - -## Support - -For issues related to: - -- **Stripe Integration**: Check Stripe Dashboard logs and webhook history -- **Application Errors**: Check application logs (`logs/timetracker.log`) -- **Database Issues**: Check PostgreSQL logs -- **Email Delivery**: Check SMTP logs and email service status - ---- - -## Appendix - -### Webhook Event Types - -| Event Type | Trigger | Handler | Action Taken | -|------------|---------|---------|--------------| -| `invoice.paid` | Successful payment | `handle_invoice_paid` | Activate subscription, clear billing issues | -| `invoice.payment_failed` | Failed payment | `handle_invoice_payment_failed` | Mark billing issue, send notification | -| `invoice.payment_action_required` | 3D Secure required | `handle_invoice_payment_action_required` | Send action required notification | -| `customer.subscription.created` | New subscription | `handle_subscription_created` | Log subscription creation | -| `customer.subscription.updated` | Subscription changed | `handle_subscription_updated` | Update org data, log changes | -| `customer.subscription.deleted` | Subscription cancelled | `handle_subscription_deleted` | Suspend organization, downgrade to free | -| `customer.subscription.trial_will_end` | 3 days before trial ends | `handle_subscription_trial_will_end` | Send reminder email | - -### Database Schema - -**organizations** (new fields): -```sql -stripe_price_id VARCHAR(100) -- Current price ID -subscription_quantity INTEGER -- Number of seats -next_billing_date TIMESTAMP -- Next billing date -billing_issue_detected_at TIMESTAMP -- When payment failed -last_billing_email_sent_at TIMESTAMP -- Last dunning email sent -``` - -**subscription_events** (new table): -```sql -id SERIAL PRIMARY KEY -organization_id INTEGER -- FK to organizations -event_type VARCHAR(100) -- Stripe event type -event_id VARCHAR(100) UNIQUE -- Stripe event ID -stripe_customer_id VARCHAR(100) -- Stripe customer ID -stripe_subscription_id VARCHAR(100) -- Stripe subscription ID -status VARCHAR(50) -- Current status -quantity INTEGER -- Seat count -amount NUMERIC(10,2) -- Payment amount -processed BOOLEAN -- Processing status -raw_payload TEXT -- Full webhook JSON -created_at TIMESTAMP -- Event creation time -... -``` - ---- - -**Last Updated**: October 7, 2025 -**Version**: 1.0.0 - diff --git a/STRIPE_IMPLEMENTATION_SUMMARY.md b/STRIPE_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 888c9c3..0000000 --- a/STRIPE_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,687 +0,0 @@ -# Stripe Billing Integration - Implementation Summary - -## ✅ Implementation Complete! - -All requirements from the feature specification have been successfully implemented. - ---- - -## 📋 What Was Implemented - -### 1. Core Infrastructure ✅ - -#### Database Schema -- **Enhanced `organizations` table** with billing fields: - - `stripe_price_id` - Current Stripe price ID - - `subscription_quantity` - Number of seats - - `next_billing_date` - Next billing cycle date - - `billing_issue_detected_at` - Payment failure timestamp - - `last_billing_email_sent_at` - Dunning email tracking - -- **New `subscription_events` table** for complete audit trail: - - Tracks all Stripe webhook events - - Stores event processing status - - Maintains full event payload for debugging - - Supports retry logic for failed events - -#### Configuration -- Added Stripe configuration to `app/config.py` -- Environment variable support for all Stripe settings -- Secure API key management -- Configurable trial periods and proration settings - -### 2. Stripe Products & Pricing ✅ - -Two subscription tiers implemented: - -**TimeTracker Single User** -- €5/month -- Quantity = 1 (fixed) -- Suitable for individual users - -**TimeTracker Team** -- €6/user/month -- Quantity = number of seats (variable) -- Automatic per-seat billing with proration - -### 3. Subscription Management ✅ - -#### Stripe Service (`app/utils/stripe_service.py`) -Comprehensive service module providing: - -**Customer Management:** -- Create Stripe customers -- Update customer information -- Link organizations to customers - -**Subscription Management:** -- Create subscriptions with trial periods -- Update subscription quantities -- Cancel subscriptions (immediate or at period end) -- Reactivate cancelled subscriptions - -**Checkout & Portal:** -- Create Checkout sessions for new subscriptions -- Generate Customer Portal sessions for self-service -- Support for success/cancel redirects - -**Billing Information:** -- Retrieve upcoming invoices -- Fetch invoice history -- Get payment methods -- Calculate proration amounts - -### 4. Webhook Handlers ✅ - -#### Webhook Endpoint (`/billing/webhooks/stripe`) -Secure webhook processing with signature verification: - -**Implemented Webhooks:** - -1. **`invoice.paid`** - - Activates subscription - - Clears billing issues - - Updates next billing date - - Provisions tenant - -2. **`invoice.payment_failed`** - - Marks billing issue - - Updates subscription status to `past_due` - - Sends failure notification email - - Starts dunning sequence - -3. **`invoice.payment_action_required`** - - Sends 3D Secure/SCA notification - - Provides payment completion link - -4. **`customer.subscription.updated`** - - Syncs subscription data - - Tracks seat changes - - Logs status transitions - - Handles proration - -5. **`customer.subscription.deleted`** - - Suspends organization - - Downgrades to free plan - - Sends cancellation notification - -6. **`customer.subscription.trial_will_end`** - - Sends reminder 3 days before trial ends - - Provides upgrade link - -### 5. Seat Synchronization ✅ - -#### Automatic Seat Sync (`app/utils/seat_sync.py`) - -**Features:** -- Automatic sync when members added/removed -- Updates Stripe subscription quantity via API -- Proration support (configurable) -- Seat limit checking before invitation -- Real-time seat availability display - -**Integration Points:** -- Member invitation (checks seat limit) -- Member removal (decreases seats) -- Invitation acceptance (increases seats) - -**Proration Handling:** -- Immediate charges when adding seats -- Credits when removing seats -- Configurable via `STRIPE_ENABLE_PRORATION` - -### 6. Billing Management UI ✅ - -#### Billing Dashboard (`/billing`) - -**Subscription Status Section:** -- Current plan display with badge -- Seat count and usage -- Trial status with countdown -- Billing issue alerts -- Next billing date - -**Plan Selection:** -- Single User plan card -- Team plan card (popular badge) -- Clear pricing display -- Subscribe buttons with Checkout integration - -**Usage Summary:** -- Member count with progress bar -- Seat availability indicator -- Project count -- Upcoming invoice preview - -**Payment Methods:** -- Card brand and last 4 digits -- Expiration date -- Update payment method link - -**Invoice History:** -- Table of recent invoices -- Invoice number/ID -- Payment date and amount -- Status badges -- Download PDF links -- View hosted invoice links - -#### Customer Portal Integration -- One-click access to Stripe Customer Portal -- Self-service subscription management -- Payment method updates -- Invoice downloads -- Cancellation handling - -### 7. Dunning Management ✅ - -#### Email Notifications - -**4 Email Templates Created:** - -1. **Payment Failed** (`payment_failed.html`) - - Sent when payment fails - - Shows invoice amount and attempt count - - Call-to-action to update payment method - - Grace period information - -2. **Trial Ending Soon** (`trial_ending.html`) - - Sent 3 days before trial ends - - Shows days remaining countdown - - Plan information - - Upgrade prompts - -3. **Payment Action Required** (`action_required.html`) - - Sent for 3D Secure/SCA requirements - - Instructions for completing authentication - - Direct link to complete payment - -4. **Subscription Cancelled** (`subscription_cancelled.html`) - - Confirmation of cancellation - - Account status information - - Reactivation options - -#### Dunning Sequence -- Automatic retry by Stripe (configurable in Dashboard) -- Email sent on each failure -- Rate limiting via `last_billing_email_sent_at` -- Escalation to suspension after multiple failures - -### 8. Billing Gates & Feature Access ✅ - -#### Decorator System (`app/utils/billing_gates.py`) - -**Decorators:** - -```python -@require_active_subscription() # Requires active paid or trial subscription -@require_plan('team') # Requires specific plan level -``` - -**Utility Functions:** -- `check_user_limit()` - Validates user count against plan limits -- `check_project_limit()` - Validates project count against plan limits -- `check_feature_access()` - Checks if plan includes specific feature -- `get_subscription_warning()` - Returns any subscription alerts - -**Context Injection:** -- Billing warnings automatically shown in all templates -- Subscription status available globally -- Trial countdown displayed when relevant - -**Feature Access Matrix:** -- Basic features: All plans -- Advanced reports: Team & Enterprise -- API access: Team & Enterprise -- Custom branding: Enterprise only -- SSO: Enterprise only - -### 9. EU VAT Compliance ✅ - -**Stripe Tax Integration:** -- Automatic VAT calculation -- EU reverse charge mechanism -- Invoice generation with VAT details -- Configurable tax behavior (inclusive/exclusive) - -**Setup Instructions:** -- Enable Stripe Tax in Dashboard -- Configure business location -- Automatic tax ID validation - -### 10. Trial Periods ✅ - -**Trial Configuration:** -- 14-day trial by default (configurable) -- Enable/disable via `STRIPE_ENABLE_TRIALS` -- Automatic conversion to paid at end -- Trial status tracking in database -- Trial countdown display -- Reminder emails before expiration - -**Trial Features:** -- Full access to paid features -- No payment method required to start -- Automatic charge at trial end -- Can upgrade early to skip trial - ---- - -## 📁 Files Created - -### Models (2 files) -1. `app/models/subscription_event.py` - Subscription event tracking -2. Enhanced `app/models/organization.py` - Added billing fields and methods - -### Services (2 files) -1. `app/utils/stripe_service.py` - Stripe API integration service -2. `app/utils/seat_sync.py` - Automatic seat synchronization -3. `app/utils/billing_gates.py` - Subscription checks and feature gates - -### Routes (1 file) -1. `app/routes/billing.py` - Billing routes and webhook handlers - -### Templates (5 files) -1. `app/templates/billing/index.html` - Billing dashboard -2. `app/templates/billing/payment_failed.html` - Email template -3. `app/templates/billing/trial_ending.html` - Email template -4. `app/templates/billing/action_required.html` - Email template -5. `app/templates/billing/subscription_cancelled.html` - Email template - -### Database (1 file) -1. `migrations/add_stripe_billing_fields.sql` - Database migration - -### Documentation (2 files) -1. `STRIPE_BILLING_SETUP.md` - Complete setup guide -2. `STRIPE_IMPLEMENTATION_SUMMARY.md` - This file - -### Configuration Changes -1. `requirements.txt` - Added `stripe==7.9.0` -2. `app/config.py` - Added Stripe configuration -3. `app/__init__.py` - Registered billing blueprint, CSRF exemption, CSP updates -4. `app/models/__init__.py` - Registered SubscriptionEvent model - -**Total: 18+ files created/modified** - ---- - -## 🔧 Configuration Required - -### Environment Variables - -```bash -# Stripe API Keys -STRIPE_SECRET_KEY=sk_test_xxxxx -STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx -STRIPE_WEBHOOK_SECRET=whsec_xxxxx - -# Stripe Price IDs (from Stripe Dashboard) -STRIPE_SINGLE_USER_PRICE_ID=price_xxxxx -STRIPE_TEAM_PRICE_ID=price_xxxxx - -# Stripe Settings -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -STRIPE_ENABLE_PRORATION=true -STRIPE_TAX_BEHAVIOR=exclusive -``` - -### Stripe Dashboard Setup - -1. **Create Products:** - - TimeTracker Single User (€5/month) - - TimeTracker Team (€6/user/month) - -2. **Enable Customer Portal:** - - Allow subscription updates - - Allow cancellations - - Allow payment method updates - -3. **Configure Webhooks:** - - Add endpoint: `https://your-domain.com/billing/webhooks/stripe` - - Select required events (see documentation) - -4. **Enable Stripe Tax:** - - Configure for EU VAT collection - - Set business location - ---- - -## ✨ Key Features - -### Subscription Lifecycle -✅ Automatic subscription creation with Checkout -✅ Trial period support (14 days default) -✅ Automatic conversion from trial to paid -✅ Self-service management via Customer Portal -✅ Subscription updates and cancellations -✅ Reactivation support - -### Per-Seat Billing -✅ Automatic seat quantity updates -✅ Proration on seat changes -✅ Seat limit enforcement -✅ Usage monitoring and alerts -✅ Seat availability display - -### Payment Handling -✅ Secure payment processing via Stripe Checkout -✅ 3D Secure / SCA support -✅ Multiple payment methods -✅ Payment method management -✅ Automatic retry on failure - -### Webhooks -✅ Signature verification -✅ Idempotent processing -✅ Event logging and audit trail -✅ Retry logic for failures -✅ Complete event payload storage - -### Notifications -✅ Payment failure alerts -✅ Trial ending reminders -✅ Action required notifications -✅ Cancellation confirmations -✅ Billing issue warnings - -### Feature Gating -✅ Subscription status checks -✅ Plan-based feature access -✅ User limit enforcement -✅ Project limit enforcement -✅ Usage warnings and upgrades - -### Compliance -✅ EU VAT collection -✅ Invoice generation -✅ Receipt delivery -✅ Audit trail -✅ GDPR considerations - ---- - -## 🚀 Usage Examples - -### For Developers - -#### Check Subscription Status -```python -from app.utils.billing_gates import require_active_subscription - -@app.route('/premium-feature') -@login_required -@require_active_subscription() -def premium_feature(): - return render_template('premium.html') -``` - -#### Check Feature Access -```python -from app.utils.billing_gates import check_feature_access - -access = check_feature_access(organization, 'advanced_reports') -if not access['allowed']: - flash(access['reason'], 'warning') - return redirect(url_for('billing.index')) -``` - -#### Sync Seats Manually -```python -from app.utils.seat_sync import seat_sync_service - -result = seat_sync_service.sync_seats(organization) -if result['success']: - print(f"Seats updated: {result['message']}") -``` - -#### Access Stripe Service -```python -from app.utils.stripe_service import stripe_service - -# Get invoices -invoices = stripe_service.get_invoices(organization, limit=10) - -# Create subscription -result = stripe_service.create_subscription( - organization=org, - price_id='price_xxxxx', - quantity=5 -) -``` - -### For Users - -#### Subscribe to Plan -1. Navigate to `/billing` -2. Choose Single User or Team plan -3. Click "Subscribe" -4. Complete Stripe Checkout -5. Subscription activates immediately - -#### Manage Subscription -1. Navigate to `/billing` -2. Click "Manage Subscription" -3. Opens Stripe Customer Portal -4. Update payment method, cancel, or view invoices - -#### Add Team Members -1. Navigate to organization settings -2. Click "Invite Member" -3. Seats automatically sync with Stripe -4. Proration applied to next invoice - ---- - -## 🧪 Testing - -### Test Mode -All testing uses Stripe test mode with test API keys (`sk_test_`, `pk_test_`). - -### Test Cards -``` -Success: 4242 4242 4242 4242 -Decline: 4000 0000 0000 0002 -3D Secure: 4000 0025 0000 3155 -``` - -### Test Webhooks -```bash -# Install Stripe CLI -stripe listen --forward-to localhost:5000/billing/webhooks/stripe - -# Trigger test events -stripe trigger invoice.paid -stripe trigger invoice.payment_failed -stripe trigger customer.subscription.updated -``` - -### Manual Testing Checklist -- [ ] Create subscription (Single User) -- [ ] Create subscription (Team) -- [ ] Add team member (seat sync) -- [ ] Remove team member (seat sync) -- [ ] Test payment failure -- [ ] Test trial period -- [ ] Test cancellation -- [ ] Test reactivation -- [ ] Verify webhook processing -- [ ] Check email notifications -- [ ] Test Customer Portal -- [ ] Verify invoice generation - ---- - -## 📊 Monitoring - -### Key Metrics to Monitor - -1. **Webhook Health:** - - Success rate - - Processing time - - Failed events - -2. **Subscription Status:** - - Active subscriptions - - Trial conversions - - Churn rate - -3. **Payment Issues:** - - Failed payments - - Dunning effectiveness - - Recovery rate - -4. **Seat Usage:** - - Average seats per organization - - Seat utilization - - Upgrade frequency - -### Database Queries - -```sql --- Check unprocessed events -SELECT COUNT(*) FROM subscription_events -WHERE processed = false; - --- Organizations with billing issues -SELECT * FROM organizations -WHERE billing_issue_detected_at IS NOT NULL; - --- Recent subscription changes -SELECT * FROM subscription_events -WHERE event_type LIKE '%subscription%' -ORDER BY created_at DESC -LIMIT 10; - --- Active subscriptions by plan -SELECT subscription_plan, COUNT(*) -FROM organizations -WHERE stripe_subscription_status IN ('active', 'trialing') -GROUP BY subscription_plan; -``` - ---- - -## 🔒 Security Considerations - -### Implemented Security Measures - -✅ **Webhook Signature Verification** -- All webhooks verified using `STRIPE_WEBHOOK_SECRET` -- Invalid signatures rejected with 400 error - -✅ **CSRF Protection** -- Webhook endpoint exempt from CSRF (required by Stripe) -- All other routes protected - -✅ **Content Security Policy** -- Updated to allow Stripe.js and Stripe APIs -- Frame sources restricted to Stripe domains - -✅ **API Key Security** -- Keys stored in environment variables -- Never committed to version control -- Separate keys for test and production - -✅ **HTTPS Required** -- Webhooks require HTTPS in production -- Stripe enforces TLS 1.2+ - -✅ **Payment Data** -- No card data stored in database -- PCI compliance via Stripe -- Tokenization for all payments - ---- - -## 📈 Next Steps - -### Optional Enhancements - -1. **Analytics Dashboard** - - MRR tracking - - Churn analysis - - Cohort analysis - -2. **Advanced Dunning** - - Multi-stage dunning sequences - - Personalized recovery emails - - Payment method suggestions - -3. **Usage-Based Billing** - - Metered billing for API calls - - Time tracking quotas - - Storage limits - -4. **Enterprise Features** - - Custom pricing - - Annual billing with discounts - - Invoice payments (ACH, wire transfer) - -5. **Referral Program** - - Referral tracking - - Credit system - - Discount codes - ---- - -## 🎯 Acceptance Criteria Status - -All acceptance criteria from the specification have been met: - -✅ **Payments succeed in Stripe test mode** -- Tested with test cards -- Webhook handlers update DB accordingly - -✅ **Seat changes reflected in Stripe** -- Subscription quantity updates automatically -- Proration calculated correctly - -✅ **Invoices generated and accessible** -- Available via Stripe Customer Portal -- Downloadable as PDF -- Hosted invoice URLs provided - ---- - -## 📞 Support & Resources - -### Documentation -- **Setup Guide**: `STRIPE_BILLING_SETUP.md` -- **Stripe Dashboard**: https://dashboard.stripe.com -- **Stripe API Docs**: https://stripe.com/docs/api -- **Stripe Webhooks**: https://stripe.com/docs/webhooks - -### Troubleshooting -Refer to `STRIPE_BILLING_SETUP.md` → Troubleshooting section for: -- Webhook issues -- Payment failures -- Seat sync problems -- Email delivery issues - ---- - -## 🎉 Implementation Complete! - -The Stripe billing integration is fully implemented and ready for testing. All core features, webhook handlers, seat synchronization, dunning management, and documentation are complete. - -**To get started:** -1. Read `STRIPE_BILLING_SETUP.md` -2. Configure environment variables -3. Set up Stripe products -4. Run database migration -5. Configure webhooks -6. Test with Stripe test mode - -**Questions or Issues?** -- Check the troubleshooting section in the setup guide -- Review Stripe Dashboard logs -- Check application logs for errors -- Review subscription_events table for event history - ---- - -**Implementation Date**: October 7, 2025 -**Version**: 1.0.0 -**Status**: ✅ Complete and Ready for Testing - diff --git a/STRIPE_QUICK_START.md b/STRIPE_QUICK_START.md deleted file mode 100644 index 057e6d6..0000000 --- a/STRIPE_QUICK_START.md +++ /dev/null @@ -1,219 +0,0 @@ -# Stripe Billing - Quick Start Guide - -Get Stripe billing up and running in 15 minutes! - -## Prerequisites -- Stripe account (sign up at https://stripe.com) -- PostgreSQL database running -- SMTP configured for emails - -## Step 1: Install Dependencies (1 min) - -```bash -pip install stripe==7.9.0 -``` - -## Step 2: Run Database Migration (1 min) - -```bash -psql -U timetracker -d timetracker -f migrations/add_stripe_billing_fields.sql -``` - -## Step 3: Create Stripe Products (3 min) - -1. Go to https://dashboard.stripe.com/products -2. Click "Add product" - -**Product 1: Single User** -- Name: `TimeTracker Single User` -- Price: `€5.00` -- Billing: `Monthly` -- Currency: `EUR` -- Click "Save product" -- **Copy the Price ID** (starts with `price_`) - -**Product 2: Team** -- Name: `TimeTracker Team` -- Price: `€6.00` -- Billing: `Monthly` -- Currency: `EUR` -- Click "Save product" -- **Copy the Price ID** (starts with `price_`) - -## Step 4: Get API Keys (1 min) - -1. Go to https://dashboard.stripe.com/apikeys -2. Copy **Publishable key** (starts with `pk_test_`) -3. Copy **Secret key** (starts with `sk_test_`) - -## Step 5: Configure Environment (2 min) - -Add to your `.env` file: - -```bash -# Stripe API Keys -STRIPE_SECRET_KEY=sk_test_YOUR_KEY_HERE -STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE -STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET_HERE # We'll get this in Step 7 - -# Stripe Price IDs -STRIPE_SINGLE_USER_PRICE_ID=price_YOUR_SINGLE_USER_PRICE_ID -STRIPE_TEAM_PRICE_ID=price_YOUR_TEAM_PRICE_ID - -# Optional Settings -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -STRIPE_ENABLE_PRORATION=true -``` - -## Step 6: Enable Customer Portal (2 min) - -1. Go to https://dashboard.stripe.com/settings/billing/portal -2. Click "Activate test link" -3. Enable these features: - - ✅ Update subscription - - ✅ Cancel subscription - - ✅ Update payment method - - ✅ View invoice history -4. Click "Save" - -## Step 7: Configure Webhooks - DEVELOPMENT (2 min) - -**For local development with Stripe CLI:** - -```bash -# Install Stripe CLI (if not installed) -brew install stripe/stripe-cli/stripe # macOS -scoop install stripe # Windows - -# Login -stripe login - -# Forward webhooks to your local server -stripe listen --forward-to localhost:5000/billing/webhooks/stripe -``` - -Copy the webhook signing secret (starts with `whsec_`) and add to `.env`: - -```bash -STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET_FROM_CLI -``` - -**For production deployment, see Step 9** - -## Step 8: Restart Application (1 min) - -```bash -# Restart your Flask application -flask run -# or -gunicorn app:app -``` - -## Step 9: Test Subscription Flow (2 min) - -1. Navigate to `http://localhost:5000/billing` -2. Click "Subscribe" on either plan -3. Use test card: `4242 4242 4242 4242` -4. Expiry: Any future date (e.g., `12/34`) -5. CVC: Any 3 digits (e.g., `123`) -6. Complete checkout - -✅ You should see: -- Subscription activated -- Organization status updated -- Welcome email sent (if SMTP configured) - -## Step 10: Test Webhooks (1 min) - -In a separate terminal: - -```bash -# Trigger test events -stripe trigger invoice.paid -stripe trigger invoice.payment_failed -stripe trigger customer.subscription.updated -``` - -Check your application logs - you should see: -``` -Received Stripe webhook: invoice.paid -Invoice paid for organization... -``` - ---- - -## 🎉 Done! - -Your Stripe billing integration is now live! - -### What You Can Do Now: - -✅ **Subscribe to plans** at `/billing` -✅ **Manage subscriptions** via Customer Portal -✅ **Add/remove team members** (seats auto-sync) -✅ **View invoices** in billing dashboard -✅ **Test payment failures** with test card `4000 0000 0000 0002` - -### Next Steps: - -1. **Configure Stripe Tax** for EU VAT: - - Go to https://dashboard.stripe.com/settings/tax - - Enable automatic tax calculation - -2. **Set up production webhooks** when deploying: - - Go to https://dashboard.stripe.com/webhooks - - Add endpoint: `https://your-domain.com/billing/webhooks/stripe` - - Select all required events (see full guide) - - Copy signing secret to production `.env` - -3. **Switch to live mode** for production: - - Get live API keys from https://dashboard.stripe.com/apikeys - - Create live products - - Update `.env` with live keys - ---- - -## 🆘 Troubleshooting - -**Webhooks not working?** -- Make sure Stripe CLI is running: `stripe listen --forward-to localhost:5000/billing/webhooks/stripe` -- Check webhook secret matches in `.env` -- Check application logs for errors - -**Can't see billing dashboard?** -- Make sure you're logged in -- Check you have admin access to organization -- Verify database migration ran successfully - -**Payment not processing?** -- Verify API keys are correct -- Check Stripe Dashboard logs -- Try a different test card - ---- - -## 📚 Full Documentation - -For detailed information, see: -- **Setup Guide**: `STRIPE_BILLING_SETUP.md` -- **Implementation Summary**: `STRIPE_IMPLEMENTATION_SUMMARY.md` -- **Environment Template**: `env.stripe.example` - ---- - -## 🧪 Test Cards - -``` -Success: 4242 4242 4242 4242 -Decline: 4000 0000 0000 0002 -3D Secure: 4000 0025 0000 3155 -``` - -More test cards: https://stripe.com/docs/testing - ---- - -**Total Setup Time: ~15 minutes** -**Ready for testing!** 🚀 - diff --git a/app.py b/app.py index 92baf41..c3af28c 100644 --- a/app.py +++ b/app.py @@ -4,11 +4,24 @@ Time Tracker Application Entry Point """ import os +import atexit from app import create_app, db from app.models import User, Project, TimeEntry, Task, Settings, Invoice, InvoiceItem, Client app = create_app() +def cleanup_on_exit(): + """Cleanup function called when the application exits""" + try: + from app.utils.license_server import stop_license_client + stop_license_client() + print("Phone home function stopped") + except Exception as e: + print(f"Error stopping phone home function: {e}") + +# Register cleanup function +atexit.register(cleanup_on_exit) + @app.shell_context_processor def make_shell_context(): """Add database models to Flask shell context""" diff --git a/app/__init__.py b/app/__init__.py index 9b5f361..498b9d6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -37,35 +37,6 @@ def create_app(config=None): # Trust a single proxy by default; adjust via env if needed app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) - # HTTPS enforcement in production - @app.before_request - def enforce_https(): - """Redirect HTTP to HTTPS in production""" - # Skip for local development and health checks - if app.config.get('FLASK_ENV') == 'development': - return None - - # Skip for health check endpoints - if request.path in ['/_health', '/health', '/metrics']: - return None - - # Check if HTTPS enforcement is enabled - if not app.config.get('FORCE_HTTPS', True): - return None - - # Check if request is already HTTPS - if request.is_secure: - return None - - # Check X-Forwarded-Proto header (from reverse proxy) - if request.headers.get('X-Forwarded-Proto', 'http') == 'https': - return None - - # Redirect to HTTPS - from flask import redirect - url = request.url.replace('http://', 'https://', 1) - return redirect(url, code=301) - # Configuration # Load env-specific config class try: @@ -167,8 +138,9 @@ def create_app(config=None): # 2) Session override (set-language route) if 'preferred_language' in session: return session.get('preferred_language') - # 3) Default to English (do NOT use Accept-Language header) - return app.config.get('BABEL_DEFAULT_LOCALE', 'en') + # 3) Best match with Accept-Language + supported = list(app.config.get('LANGUAGES', {}).keys()) or ['en'] + return request.accept_languages.best_match(supported) or app.config.get('BABEL_DEFAULT_LOCALE', 'en') except Exception: return app.config.get('BABEL_DEFAULT_LOCALE', 'en') @@ -263,9 +235,8 @@ def create_app(config=None): "img-src 'self' data: https:; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; " "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; " - "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://js.stripe.com; " - "connect-src 'self' ws: wss: https://api.stripe.com; " - "frame-src https://js.stripe.com https://hooks.stripe.com; " + "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; " + "connect-src 'self' ws: wss:; " "frame-ancestors 'none'" ) response.headers['Content-Security-Policy'] = csp @@ -292,16 +263,8 @@ def create_app(config=None): except Exception: pass - # Initialize email service - from app.utils.email_service import email_service - email_service.init_app(app) - - # Exempt Stripe webhook from CSRF protection - csrf.exempt('app.routes.billing.stripe_webhook') - # Register blueprints from app.routes.auth import auth_bp - from app.routes.auth_extended import auth_extended_bp from app.routes.main import main_bp from app.routes.projects import projects_bp from app.routes.timer import timer_bp @@ -313,15 +276,8 @@ def create_app(config=None): from app.routes.invoices import invoices_bp from app.routes.clients import clients_bp from app.routes.comments import comments_bp - from app.routes.organizations import organizations_bp - from app.routes.billing import bp as billing_bp - from app.routes.onboarding import bp as onboarding_bp - from app.routes.promo_codes import promo_codes_bp - from app.routes.security import security_bp - from app.routes.gdpr import gdpr_bp app.register_blueprint(auth_bp) - app.register_blueprint(auth_extended_bp) app.register_blueprint(main_bp) app.register_blueprint(projects_bp) app.register_blueprint(timer_bp) @@ -333,12 +289,6 @@ def create_app(config=None): app.register_blueprint(invoices_bp) app.register_blueprint(clients_bp) app.register_blueprint(comments_bp) - app.register_blueprint(organizations_bp) - app.register_blueprint(billing_bp) - app.register_blueprint(onboarding_bp) - app.register_blueprint(promo_codes_bp) - app.register_blueprint(security_bp) - app.register_blueprint(gdpr_bp) # Register OAuth OIDC client if enabled try: @@ -369,15 +319,39 @@ def create_app(config=None): else: app.logger.warning("AUTH_METHOD is %s but OIDC envs are incomplete; OIDC login will not work", auth_method) - # Cleanup RLS context after each request - @app.teardown_request - def _cleanup_rls_context(exception=None): - """Clean up Row Level Security context after request.""" + # Initialize phone home function if enabled + if app.config.get('LICENSE_SERVER_ENABLED', True): try: - from app.utils.rls import cleanup_rls_for_request - cleanup_rls_for_request() + from app.utils.license_server import init_license_client, start_license_client, get_license_client + + # Check if client is already running + existing_client = get_license_client() + if existing_client and existing_client.running: + app.logger.info("Phone home function already running, skipping initialization") + else: + license_client = init_license_client( + app_identifier=app.config.get('LICENSE_SERVER_APP_ID', 'timetracker'), + app_version=app.config.get('LICENSE_SERVER_APP_VERSION', '1.0.0') + ) + if start_license_client(): + app.logger.info("Phone home function started successfully") + else: + app.logger.warning("Failed to start phone home function") except Exception as e: - app.logger.debug(f"RLS cleanup: {e}") + app.logger.warning(f"Could not initialize phone home function: {e}") + + # Register cleanup function for graceful shutdown + @app.teardown_appcontext + def cleanup_license_client(exception=None): + """Cleanup phone home function on app context teardown""" + try: + from app.utils.license_server import get_license_client, stop_license_client + client = get_license_client() + if client and client.running: + app.logger.info("Stopping phone home function during app teardown") + stop_license_client() + except Exception as e: + app.logger.warning(f"Error during license client cleanup: {e}") # Register error handlers from app.utils.error_handlers import register_error_handlers @@ -387,10 +361,6 @@ def create_app(config=None): from app.utils.context_processors import register_context_processors register_context_processors(app) - # Register billing context processor - from app.utils.billing_gates import inject_billing_context - app.context_processor(inject_billing_context) - # (translations compiled and directories set before Babel init) # Register template filters @@ -401,21 +371,6 @@ def create_app(config=None): from app.utils.cli import register_cli_commands register_cli_commands(app) - # Initialize tenancy context for each request - @app.before_request - def _init_tenancy_context(): - """Set up multi-tenant context for the current request.""" - try: - from app.utils.tenancy import init_tenancy_for_request - init_tenancy_for_request() - - # Also set up Row Level Security context for PostgreSQL - from app.utils.rls import init_rls_for_request - init_rls_for_request() - except Exception as e: - # Non-fatal; some routes don't require organization context - app.logger.debug(f"Tenancy initialization: {e}") - # Promote configured admin usernames automatically on each request (idempotent) @app.before_request def _promote_admin_users_on_request(): diff --git a/app/config.py b/app/config.py index 4437711..2033749 100644 --- a/app/config.py +++ b/app/config.py @@ -78,54 +78,17 @@ class Config: WTF_CSRF_ENABLED = False WTF_CSRF_TIME_LIMIT = 3600 # 1 hour - # HTTPS enforcement - FORCE_HTTPS = os.getenv('FORCE_HTTPS', 'true').lower() == 'true' - # Security headers SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', - 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload' + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' } - - # Content Security Policy - # Allows inline scripts/styles for now (needed for dynamic content), but can be tightened with nonces - CONTENT_SECURITY_POLICY = os.getenv( - 'CONTENT_SECURITY_POLICY', - "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://js.stripe.com; " - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " - "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " - "img-src 'self' data: https:; " - "connect-src 'self' https://api.stripe.com; " - "frame-src https://js.stripe.com https://hooks.stripe.com; " - "object-src 'none'; " - "base-uri 'self'; " - "form-action 'self'; " - "frame-ancestors 'none'; " - "upgrade-insecure-requests" - ) # Rate limiting RATELIMIT_DEFAULT = os.getenv('RATELIMIT_DEFAULT', '') # e.g., "200 per day;50 per hour" RATELIMIT_STORAGE_URI = os.getenv('RATELIMIT_STORAGE_URI', 'memory://') - RATELIMIT_ENABLED = os.getenv('RATELIMIT_ENABLED', 'true').lower() == 'true' - - # Password policy - PASSWORD_MIN_LENGTH = int(os.getenv('PASSWORD_MIN_LENGTH', 12)) - PASSWORD_REQUIRE_UPPERCASE = os.getenv('PASSWORD_REQUIRE_UPPERCASE', 'true').lower() == 'true' - PASSWORD_REQUIRE_LOWERCASE = os.getenv('PASSWORD_REQUIRE_LOWERCASE', 'true').lower() == 'true' - PASSWORD_REQUIRE_DIGITS = os.getenv('PASSWORD_REQUIRE_DIGITS', 'true').lower() == 'true' - PASSWORD_REQUIRE_SPECIAL = os.getenv('PASSWORD_REQUIRE_SPECIAL', 'true').lower() == 'true' - PASSWORD_EXPIRY_DAYS = int(os.getenv('PASSWORD_EXPIRY_DAYS', 0)) # 0 = no expiry - PASSWORD_HISTORY_COUNT = int(os.getenv('PASSWORD_HISTORY_COUNT', 5)) # Number of previous passwords to check - - # Data retention and GDPR - DATA_RETENTION_DAYS = int(os.getenv('DATA_RETENTION_DAYS', 0)) # 0 = no automatic deletion - GDPR_EXPORT_ENABLED = os.getenv('GDPR_EXPORT_ENABLED', 'true').lower() == 'true' - GDPR_DELETION_ENABLED = os.getenv('GDPR_DELETION_ENABLED', 'true').lower() == 'true' - GDPR_DELETION_DELAY_DAYS = int(os.getenv('GDPR_DELETION_DELAY_DAYS', 30)) # Grace period before actual deletion # Internationalization LANGUAGES = { @@ -140,15 +103,6 @@ class Config: # Comma-separated list of translation directories relative to instance root BABEL_TRANSLATION_DIRECTORIES = os.getenv('BABEL_TRANSLATION_DIRECTORIES', 'translations') - # Email settings (SMTP) - SMTP_HOST = os.getenv('SMTP_HOST', 'localhost') - SMTP_PORT = int(os.getenv('SMTP_PORT', 587)) - SMTP_USERNAME = os.getenv('SMTP_USERNAME') - SMTP_PASSWORD = os.getenv('SMTP_PASSWORD') - SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true' - SMTP_FROM_EMAIL = os.getenv('SMTP_FROM_EMAIL', 'noreply@timetracker.local') - SMTP_FROM_NAME = os.getenv('SMTP_FROM_NAME', 'TimeTracker') - # Versioning # Prefer explicit app version from environment (e.g., Git tag) APP_VERSION = os.getenv('APP_VERSION', os.getenv('GITHUB_TAG', None)) @@ -157,16 +111,13 @@ class Config: github_run_number = os.getenv('GITHUB_RUN_NUMBER') APP_VERSION = f"dev-{github_run_number}" if github_run_number else "dev-0" - # Stripe billing settings - STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY') - STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY') - STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') - STRIPE_SINGLE_USER_PRICE_ID = os.getenv('STRIPE_SINGLE_USER_PRICE_ID') # €5/month - STRIPE_TEAM_PRICE_ID = os.getenv('STRIPE_TEAM_PRICE_ID') # €6/user/month - STRIPE_ENABLE_TRIALS = os.getenv('STRIPE_ENABLE_TRIALS', 'true').lower() == 'true' - STRIPE_TRIAL_DAYS = int(os.getenv('STRIPE_TRIAL_DAYS', 14)) - STRIPE_ENABLE_PRORATION = os.getenv('STRIPE_ENABLE_PRORATION', 'true').lower() == 'true' - STRIPE_TAX_BEHAVIOR = os.getenv('STRIPE_TAX_BEHAVIOR', 'exclusive') # 'inclusive' or 'exclusive' + # License server settings (no license required) + # All settings are hardcoded since clients cannot change license server configuration + LICENSE_SERVER_ENABLED = os.getenv('LICENSE_SERVER_ENABLED', 'true').lower() == 'true' + LICENSE_SERVER_API_KEY = "no-license-required" # Hardcoded placeholder + LICENSE_SERVER_APP_ID = "timetracker" # Hardcoded app identifier + LICENSE_SERVER_APP_VERSION = APP_VERSION # Match application version + LICENSE_SERVER_HEARTBEAT_INTERVAL = 3600 # Hardcoded heartbeat interval (1 hour) class DevelopmentConfig(Config): """Development configuration""" @@ -187,19 +138,9 @@ class TestingConfig(Config): class ProductionConfig(Config): """Production configuration""" FLASK_DEBUG = False - - # Force HTTPS and secure cookies - FORCE_HTTPS = True SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True - REMEMBER_COOKIE_SECURE = True - PREFERRED_URL_SCHEME = 'https' - - # CSRF protection enabled in production WTF_CSRF_ENABLED = True - - # Stronger password requirements in production - PASSWORD_MIN_LENGTH = int(os.getenv('PASSWORD_MIN_LENGTH', 12)) # Configuration mapping config = { diff --git a/app/models/__init__.py b/app/models/__init__.py index 9ae179f..fb2edec 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,4 @@ from .user import User -from .organization import Organization -from .membership import Membership from .project import Project from .time_entry import TimeEntry from .task import Task @@ -18,16 +16,10 @@ from .focus_session import FocusSession from .recurring_block import RecurringBlock from .rate_override import RateOverride from .saved_filter import SavedFilter -from .password_reset import PasswordResetToken, EmailVerificationToken -from .refresh_token import RefreshToken -from .subscription_event import SubscriptionEvent -from .onboarding_checklist import OnboardingChecklist -from .promo_code import PromoCode, PromoCodeRedemption __all__ = [ - 'User', 'Organization', 'Membership', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', - 'Client', 'TaskActivity', 'Comment', 'FocusSession', 'RecurringBlock', 'RateOverride', 'SavedFilter', + 'User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity', 'Comment', + 'FocusSession', 'RecurringBlock', 'RateOverride', 'SavedFilter', 'InvoiceTemplate', 'Currency', 'ExchangeRate', 'TaxRule', 'Payment', 'CreditNote', 'InvoiceReminderSchedule', - 'SavedReportView', 'ReportEmailSchedule', 'PasswordResetToken', 'EmailVerificationToken', 'RefreshToken', - 'SubscriptionEvent', 'OnboardingChecklist', 'PromoCode', 'PromoCodeRedemption' + 'SavedReportView', 'ReportEmailSchedule' ] diff --git a/app/models/client.py b/app/models/client.py index af1f02c..99c69d9 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -6,15 +6,9 @@ class Client(db.Model): """Client model for managing client information and rates""" __tablename__ = 'clients' - __table_args__ = ( - # Client names must be unique per organization - db.UniqueConstraint('organization_id', 'name', name='uq_clients_org_name'), - db.Index('idx_clients_org_status', 'organization_id', 'status'), - ) id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) - name = db.Column(db.String(200), nullable=False, index=True) + name = db.Column(db.String(200), nullable=False, unique=True, index=True) description = db.Column(db.Text, nullable=True) contact_person = db.Column(db.String(200), nullable=True) email = db.Column(db.String(200), nullable=True) @@ -26,11 +20,9 @@ class Client(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) # Relationships - organization = db.relationship('Organization', back_populates='clients') projects = db.relationship('Project', backref='client_obj', lazy='dynamic', cascade='all, delete-orphan') - def __init__(self, name, organization_id, description=None, contact_person=None, email=None, phone=None, address=None, default_hourly_rate=None): - self.organization_id = organization_id + def __init__(self, name, description=None, contact_person=None, email=None, phone=None, address=None, default_hourly_rate=None): self.name = name.strip() self.description = description.strip() if description else None self.contact_person = contact_person.strip() if contact_person else None diff --git a/app/models/comment.py b/app/models/comment.py index 88313f5..1e4819c 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -6,14 +6,8 @@ class Comment(db.Model): """Comment model for project and task discussions""" __tablename__ = 'comments' - __table_args__ = ( - db.Index('idx_comments_org_project', 'organization_id', 'project_id'), - db.Index('idx_comments_org_task', 'organization_id', 'task_id'), - db.Index('idx_comments_org_user', 'organization_id', 'user_id'), - ) id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) content = db.Column(db.Text, nullable=False) # Reference to either project or task (one will be null) @@ -31,7 +25,6 @@ class Comment(db.Model): parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True, index=True) # Relationships - organization = db.relationship('Organization', back_populates='comments') author = db.relationship('User', backref='comments') project = db.relationship('Project', backref='comments') task = db.relationship('Task', backref='comments') @@ -39,13 +32,12 @@ class Comment(db.Model): # Self-referential relationship for replies parent = db.relationship('Comment', remote_side=[id], backref='replies') - def __init__(self, content, user_id, organization_id, project_id=None, task_id=None, parent_id=None): + def __init__(self, content, user_id, project_id=None, task_id=None, parent_id=None): """Create a comment. Args: content: The comment text user_id: ID of the user creating the comment - organization_id: ID of the organization this comment belongs to project_id: ID of the project (if this is a project comment) task_id: ID of the task (if this is a task comment) parent_id: ID of parent comment (if this is a reply) @@ -56,7 +48,6 @@ class Comment(db.Model): if project_id and task_id: raise ValueError("Comment cannot be associated with both a project and a task") - self.organization_id = organization_id self.content = content.strip() self.user_id = user_id self.project_id = project_id diff --git a/app/models/focus_session.py b/app/models/focus_session.py index 663edab..30e5a83 100644 --- a/app/models/focus_session.py +++ b/app/models/focus_session.py @@ -12,7 +12,6 @@ class FocusSession(db.Model): __tablename__ = 'focus_sessions' id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) diff --git a/app/models/invoice.py b/app/models/invoice.py index e674802..8fdb6ac 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -6,16 +6,9 @@ class Invoice(db.Model): """Invoice model for client billing""" __tablename__ = 'invoices' - __table_args__ = ( - # Invoice numbers must be unique per organization - db.UniqueConstraint('organization_id', 'invoice_number', name='uq_invoices_org_number'), - db.Index('idx_invoices_org_status', 'organization_id', 'status'), - db.Index('idx_invoices_org_client', 'organization_id', 'client_id'), - ) id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) - invoice_number = db.Column(db.String(50), nullable=False, index=True) + invoice_number = db.Column(db.String(50), unique=True, nullable=False, index=True) project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) client_name = db.Column(db.String(200), nullable=False) client_email = db.Column(db.String(200), nullable=True) @@ -54,7 +47,6 @@ class Invoice(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) # Relationships - organization = db.relationship('Organization', back_populates='invoices') project = db.relationship('Project', backref='invoices') client = db.relationship('Client', backref='invoices') creator = db.relationship('User', backref='created_invoices') @@ -64,8 +56,7 @@ class Invoice(db.Model): reminder_schedules = db.relationship('InvoiceReminderSchedule', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') template = db.relationship('InvoiceTemplate', backref='invoices', lazy='joined') - def __init__(self, invoice_number, organization_id, project_id, client_name, due_date, created_by, client_id, **kwargs): - self.organization_id = organization_id + def __init__(self, invoice_number, project_id, client_name, due_date, created_by, client_id, **kwargs): self.invoice_number = invoice_number self.project_id = project_id self.client_name = client_name @@ -246,17 +237,16 @@ class Invoice(db.Model): } @classmethod - def generate_invoice_number(cls, organization_id): - """Generate a unique invoice number for an organization""" + def generate_invoice_number(cls): + """Generate a unique invoice number""" from datetime import datetime # Format: INV-YYYYMMDD-XXX today = datetime.utcnow() date_prefix = today.strftime('%Y%m%d') - # Find the next available number for today within this organization + # Find the next available number for today existing = cls.query.filter( - cls.organization_id == organization_id, cls.invoice_number.like(f'INV-{date_prefix}-%') ).order_by(cls.invoice_number.desc()).first() diff --git a/app/models/membership.py b/app/models/membership.py deleted file mode 100644 index 2637217..0000000 --- a/app/models/membership.py +++ /dev/null @@ -1,283 +0,0 @@ -from datetime import datetime -from app import db - -class Membership(db.Model): - """Membership model for user-organization relationships. - - This model represents the many-to-many relationship between users and - organizations, with additional metadata like role and status. A user can - belong to multiple organizations with different roles in each. - """ - - __tablename__ = 'memberships' - __table_args__ = ( - # Ensure a user can only have one active membership per organization - db.UniqueConstraint('user_id', 'organization_id', 'status', name='uq_user_org_status'), - db.Index('idx_memberships_user_org', 'user_id', 'organization_id'), - db.Index('idx_memberships_org_role', 'organization_id', 'role'), - ) - - id = db.Column(db.Integer, primary_key=True) - - # Foreign keys - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) - - # Role within the organization - role = db.Column(db.String(20), default='member', nullable=False) # 'admin', 'member', 'viewer' - - # Status - status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'invited', 'suspended', 'removed' - - # Invitation details (for pending invitations) - invited_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - invited_at = db.Column(db.DateTime, nullable=True) - invitation_token = db.Column(db.String(100), unique=True, nullable=True, index=True) - invitation_accepted_at = db.Column(db.DateTime, nullable=True) - - # Timestamps - 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) - - # Last activity tracking - last_activity_at = db.Column(db.DateTime, nullable=True) - - # Relationships - user = db.relationship('User', foreign_keys=[user_id], backref='memberships') - organization = db.relationship('Organization', back_populates='memberships') - inviter = db.relationship('User', foreign_keys=[invited_by], backref='sent_invitations') - - def __init__(self, user_id, organization_id, role='member', status='active', invited_by=None): - """Create a new membership. - - Args: - user_id: ID of the user - organization_id: ID of the organization - role: User's role in the organization ('admin', 'member', 'viewer') - status: Membership status ('active', 'invited', 'suspended', 'removed') - invited_by: ID of the user who sent the invitation (if applicable) - """ - self.user_id = user_id - self.organization_id = organization_id - self.role = role - self.status = status - self.invited_by = invited_by - - if status == 'invited': - self.invited_at = datetime.utcnow() - self.invitation_token = self._generate_invitation_token() - - def __repr__(self): - return f'' - - @property - def is_active(self): - """Check if membership is active""" - return self.status == 'active' - - @property - def is_admin(self): - """Check if membership has admin role""" - return self.role == 'admin' - - @property - def is_pending(self): - """Check if membership is pending (invited but not accepted)""" - return self.status == 'invited' - - @property - def can_manage_members(self): - """Check if this member can manage other members""" - return self.is_admin and self.is_active - - @property - def can_manage_projects(self): - """Check if this member can manage projects""" - return self.role in ['admin', 'member'] and self.is_active - - @property - def can_edit_data(self): - """Check if this member can edit data""" - return self.role in ['admin', 'member'] and self.is_active - - @property - def is_readonly(self): - """Check if this member has read-only access""" - return self.role == 'viewer' - - def update_last_activity(self): - """Update the last activity timestamp""" - self.last_activity_at = datetime.utcnow() - db.session.commit() - - def promote_to_admin(self): - """Promote member to admin role""" - if not self.is_active: - raise ValueError("Cannot promote inactive member") - - self.role = 'admin' - self.updated_at = datetime.utcnow() - db.session.commit() - - def demote_from_admin(self): - """Demote admin to regular member""" - if not self.is_active: - raise ValueError("Cannot demote inactive member") - - self.role = 'member' - self.updated_at = datetime.utcnow() - db.session.commit() - - def change_role(self, new_role): - """Change member's role""" - valid_roles = ['admin', 'member', 'viewer'] - if new_role not in valid_roles: - raise ValueError(f"Invalid role. Must be one of: {', '.join(valid_roles)}") - - if not self.is_active: - raise ValueError("Cannot change role of inactive member") - - self.role = new_role - self.updated_at = datetime.utcnow() - db.session.commit() - - def suspend(self): - """Suspend the membership""" - self.status = 'suspended' - self.updated_at = datetime.utcnow() - db.session.commit() - - def reactivate(self): - """Reactivate a suspended membership""" - if self.status != 'suspended': - raise ValueError("Can only reactivate suspended memberships") - - self.status = 'active' - self.updated_at = datetime.utcnow() - db.session.commit() - - def remove(self): - """Remove the membership (user leaves organization)""" - self.status = 'removed' - self.updated_at = datetime.utcnow() - db.session.commit() - - def accept_invitation(self): - """Accept a pending invitation""" - if self.status != 'invited': - raise ValueError("Can only accept invitations with 'invited' status") - - self.status = 'active' - self.invitation_accepted_at = datetime.utcnow() - self.invitation_token = None # Clear the token after acceptance - self.updated_at = datetime.utcnow() - db.session.commit() - - def _generate_invitation_token(self): - """Generate a unique invitation token""" - import secrets - return secrets.token_urlsafe(32) - - def to_dict(self, include_user=False, include_organization=False): - """Convert membership to dictionary for API responses""" - data = { - 'id': self.id, - 'user_id': self.user_id, - 'organization_id': self.organization_id, - 'role': self.role, - 'status': self.status, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'last_activity_at': self.last_activity_at.isoformat() if self.last_activity_at else None, - 'is_active': self.is_active, - 'is_admin': self.is_admin, - 'is_pending': self.is_pending, - 'can_manage_members': self.can_manage_members, - 'can_manage_projects': self.can_manage_projects, - 'can_edit_data': self.can_edit_data, - } - - if self.is_pending: - data.update({ - 'invited_at': self.invited_at.isoformat() if self.invited_at else None, - 'invited_by': self.invited_by, - }) - - if include_user and self.user: - data['user'] = { - 'id': self.user.id, - 'username': self.user.username, - 'email': self.user.email, - 'full_name': self.user.full_name, - 'display_name': self.user.display_name, - } - - if include_organization and self.organization: - data['organization'] = self.organization.to_dict() - - return data - - @classmethod - def get_user_organizations(cls, user_id, status='active'): - """Get all organizations a user belongs to""" - query = cls.query.filter_by(user_id=user_id) - - if status: - query = query.filter_by(status=status) - - return query.all() - - @classmethod - def get_user_active_memberships(cls, user_id): - """Get all active memberships for a user""" - return cls.get_user_organizations(user_id, status='active') - - @classmethod - def get_organization_members(cls, organization_id, role=None, status='active'): - """Get all members of an organization""" - query = cls.query.filter_by(organization_id=organization_id) - - if status: - query = query.filter_by(status=status) - - if role: - query = query.filter_by(role=role) - - return query.all() - - @classmethod - def find_membership(cls, user_id, organization_id): - """Find a membership for a user in an organization""" - return cls.query.filter_by( - user_id=user_id, - organization_id=organization_id - ).filter(cls.status.in_(['active', 'invited'])).first() - - @classmethod - def user_is_member(cls, user_id, organization_id): - """Check if user is an active member of an organization""" - return cls.query.filter_by( - user_id=user_id, - organization_id=organization_id, - status='active' - ).first() is not None - - @classmethod - def user_is_admin(cls, user_id, organization_id): - """Check if user is an admin of an organization""" - membership = cls.query.filter_by( - user_id=user_id, - organization_id=organization_id, - status='active', - role='admin' - ).first() - return membership is not None - - @classmethod - def get_by_invitation_token(cls, token): - """Get membership by invitation token""" - return cls.query.filter_by( - invitation_token=token, - status='invited' - ).first() - diff --git a/app/models/onboarding_checklist.py b/app/models/onboarding_checklist.py deleted file mode 100644 index cf3aa39..0000000 --- a/app/models/onboarding_checklist.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -Onboarding Checklist Model - -Tracks onboarding progress for organizations to help them get started -with the platform successfully. -""" - -from datetime import datetime -from app import db -from typing import Dict, List - - -class OnboardingChecklist(db.Model): - """Tracks onboarding progress for an organization. - - This model stores completion status of various onboarding tasks to help - new organizations get up and running quickly. - """ - - __tablename__ = 'onboarding_checklists' - - id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), - nullable=False, unique=True, index=True) - - # Task completion flags - invited_team_member = db.Column(db.Boolean, default=False, nullable=False) - invited_team_member_at = db.Column(db.DateTime, nullable=True) - - created_project = db.Column(db.Boolean, default=False, nullable=False) - created_project_at = db.Column(db.DateTime, nullable=True) - - created_time_entry = db.Column(db.Boolean, default=False, nullable=False) - created_time_entry_at = db.Column(db.DateTime, nullable=True) - - set_working_hours = db.Column(db.Boolean, default=False, nullable=False) - set_working_hours_at = db.Column(db.DateTime, nullable=True) - - customized_settings = db.Column(db.Boolean, default=False, nullable=False) - customized_settings_at = db.Column(db.DateTime, nullable=True) - - added_billing_info = db.Column(db.Boolean, default=False, nullable=False) - added_billing_info_at = db.Column(db.DateTime, nullable=True) - - created_client = db.Column(db.Boolean, default=False, nullable=False) - created_client_at = db.Column(db.DateTime, nullable=True) - - generated_report = db.Column(db.Boolean, default=False, nullable=False) - generated_report_at = db.Column(db.DateTime, nullable=True) - - # Overall status - completed = db.Column(db.Boolean, default=False, nullable=False) - completed_at = db.Column(db.DateTime, nullable=True) - - # Dismiss/skip tracking - dismissed = db.Column(db.Boolean, default=False, nullable=False) - dismissed_at = db.Column(db.DateTime, nullable=True) - - # Timestamps - 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) - - # Relationships - organization = db.relationship('Organization', backref='onboarding_checklist') - - # Task definitions with metadata - TASKS = { - 'invited_team_member': { - 'title': 'Invite your first team member', - 'description': 'Add colleagues to collaborate on projects', - 'icon': 'fa-user-plus', - 'priority': 1, - 'category': 'team' - }, - 'created_project': { - 'title': 'Create a project', - 'description': 'Organize your work with projects', - 'icon': 'fa-folder-plus', - 'priority': 2, - 'category': 'setup' - }, - 'created_time_entry': { - 'title': 'Log your first time entry', - 'description': 'Start tracking time on a project', - 'icon': 'fa-clock', - 'priority': 3, - 'category': 'usage' - }, - 'set_working_hours': { - 'title': 'Set your working hours', - 'description': 'Configure your schedule preferences', - 'icon': 'fa-calendar-alt', - 'priority': 4, - 'category': 'setup' - }, - 'created_client': { - 'title': 'Add your first client', - 'description': 'Manage client relationships and billing', - 'icon': 'fa-building', - 'priority': 5, - 'category': 'setup' - }, - 'customized_settings': { - 'title': 'Customize your settings', - 'description': 'Personalize TimeTracker to your needs', - 'icon': 'fa-cog', - 'priority': 6, - 'category': 'setup' - }, - 'added_billing_info': { - 'title': 'Add billing information', - 'description': 'Set up payment to continue after trial', - 'icon': 'fa-credit-card', - 'priority': 7, - 'category': 'billing' - }, - 'generated_report': { - 'title': 'Generate a report', - 'description': 'Analyze your time and productivity', - 'icon': 'fa-chart-bar', - 'priority': 8, - 'category': 'usage' - }, - } - - def __repr__(self): - return f'' - - @property - def completion_percentage(self) -> int: - """Calculate completion percentage.""" - if self.dismissed or self.completed: - return 100 - - total_tasks = len(self.TASKS) - completed_tasks = self.get_completed_count() - - return int((completed_tasks / total_tasks) * 100) - - @property - def is_complete(self) -> bool: - """Check if all tasks are completed.""" - return self.completion_percentage == 100 - - def get_completed_count(self) -> int: - """Get number of completed tasks.""" - completed = 0 - for task_key in self.TASKS.keys(): - if getattr(self, task_key, False): - completed += 1 - return completed - - def get_total_count(self) -> int: - """Get total number of tasks.""" - return len(self.TASKS) - - def mark_task_complete(self, task_key: str) -> bool: - """Mark a task as complete. - - Args: - task_key: Key of the task (e.g., 'invited_team_member') - - Returns: - True if task was marked complete, False if invalid task - """ - if task_key not in self.TASKS: - return False - - # Set completion flag and timestamp - setattr(self, task_key, True) - setattr(self, f"{task_key}_at", datetime.utcnow()) - - self.updated_at = datetime.utcnow() - - # Check if all tasks are complete - if self.get_completed_count() == self.get_total_count(): - self.completed = True - self.completed_at = datetime.utcnow() - - db.session.commit() - return True - - def dismiss(self) -> None: - """Dismiss the onboarding checklist.""" - self.dismissed = True - self.dismissed_at = datetime.utcnow() - self.updated_at = datetime.utcnow() - db.session.commit() - - def undismiss(self) -> None: - """Re-enable the onboarding checklist.""" - self.dismissed = False - self.dismissed_at = None - self.updated_at = datetime.utcnow() - db.session.commit() - - def get_tasks_with_status(self) -> List[Dict]: - """Get all tasks with their completion status. - - Returns: - List of task dictionaries with status information - """ - tasks = [] - - for task_key, task_meta in self.TASKS.items(): - is_complete = getattr(self, task_key, False) - completed_at = getattr(self, f"{task_key}_at", None) - - task = { - 'key': task_key, - 'title': task_meta['title'], - 'description': task_meta['description'], - 'icon': task_meta['icon'], - 'priority': task_meta['priority'], - 'category': task_meta['category'], - 'completed': is_complete, - 'completed_at': completed_at.isoformat() if completed_at else None, - } - tasks.append(task) - - # Sort by priority - tasks.sort(key=lambda x: x['priority']) - - return tasks - - def get_next_task(self) -> Dict: - """Get the next incomplete task. - - Returns: - Task dictionary or None if all complete - """ - for task in self.get_tasks_with_status(): - if not task['completed']: - return task - return None - - def to_dict(self, include_tasks: bool = True) -> Dict: - """Convert to dictionary for API responses. - - Args: - include_tasks: Whether to include detailed task list - - Returns: - Dictionary representation - """ - data = { - 'id': self.id, - 'organization_id': self.organization_id, - 'completed': self.completed, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, - 'dismissed': self.dismissed, - 'dismissed_at': self.dismissed_at.isoformat() if self.dismissed_at else None, - 'completion_percentage': self.completion_percentage, - 'completed_count': self.get_completed_count(), - 'total_count': self.get_total_count(), - 'is_complete': self.is_complete, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - if include_tasks: - data['tasks'] = self.get_tasks_with_status() - data['next_task'] = self.get_next_task() - - return data - - @classmethod - def get_or_create(cls, organization_id: int) -> 'OnboardingChecklist': - """Get existing checklist or create new one. - - Args: - organization_id: Organization ID - - Returns: - OnboardingChecklist instance - """ - checklist = cls.query.filter_by(organization_id=organization_id).first() - - if not checklist: - checklist = cls(organization_id=organization_id) - db.session.add(checklist) - db.session.commit() - - return checklist - diff --git a/app/models/organization.py b/app/models/organization.py deleted file mode 100644 index 36e95a4..0000000 --- a/app/models/organization.py +++ /dev/null @@ -1,327 +0,0 @@ -from datetime import datetime -from app import db - -class Organization(db.Model): - """Organization model for multi-tenancy support. - - Each organization represents a separate tenant with its own data isolation. - This enables the SaaS model where multiple customers can use the same - application instance while keeping their data completely separate. - """ - - __tablename__ = 'organizations' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(200), nullable=False, index=True) - slug = db.Column(db.String(100), unique=True, nullable=False, index=True) # URL-safe identifier - - # Contact and billing information - contact_email = db.Column(db.String(200), nullable=True) - contact_phone = db.Column(db.String(50), nullable=True) - billing_email = db.Column(db.String(200), nullable=True) - - # Status and metadata - status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'suspended', 'cancelled' - - # Subscription and limits (for SaaS tiers) - subscription_plan = db.Column(db.String(50), default='free', nullable=False) # 'free', 'starter', 'professional', 'enterprise' - max_users = db.Column(db.Integer, nullable=True) # null = unlimited - max_projects = db.Column(db.Integer, nullable=True) # null = unlimited - - # Stripe integration - stripe_customer_id = db.Column(db.String(100), unique=True, nullable=True, index=True) - stripe_subscription_id = db.Column(db.String(100), nullable=True) - stripe_subscription_status = db.Column(db.String(20), nullable=True) # 'active', 'trialing', 'past_due', 'canceled', 'incomplete', 'incomplete_expired', 'unpaid' - stripe_price_id = db.Column(db.String(100), nullable=True) # Current price ID being used - subscription_quantity = db.Column(db.Integer, default=1, nullable=False) # Number of seats/users - trial_ends_at = db.Column(db.DateTime, nullable=True) - subscription_ends_at = db.Column(db.DateTime, nullable=True) - next_billing_date = db.Column(db.DateTime, nullable=True) - billing_issue_detected_at = db.Column(db.DateTime, nullable=True) # When payment failed - last_billing_email_sent_at = db.Column(db.DateTime, nullable=True) # For dunning management - - # Settings and preferences - timezone = db.Column(db.String(50), default='UTC', nullable=False) - currency = db.Column(db.String(3), default='EUR', nullable=False) - date_format = db.Column(db.String(20), default='YYYY-MM-DD', nullable=False) - - # Branding - logo_filename = db.Column(db.String(255), nullable=True) - primary_color = db.Column(db.String(7), nullable=True) # Hex color code - - # Promo codes - promo_code = db.Column(db.String(50), nullable=True) - promo_code_applied_at = db.Column(db.DateTime, nullable=True) - - # Timestamps - 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) - - # Soft delete support - deleted_at = db.Column(db.DateTime, nullable=True) - - # Relationships - memberships = db.relationship('Membership', back_populates='organization', lazy='dynamic', cascade='all, delete-orphan') - projects = db.relationship('Project', back_populates='organization', lazy='dynamic', cascade='all, delete-orphan') - clients = db.relationship('Client', back_populates='organization', lazy='dynamic', cascade='all, delete-orphan') - time_entries = db.relationship('TimeEntry', back_populates='organization', lazy='dynamic') - tasks = db.relationship('Task', back_populates='organization', lazy='dynamic') - invoices = db.relationship('Invoice', back_populates='organization', lazy='dynamic') - comments = db.relationship('Comment', back_populates='organization', lazy='dynamic') - - def __init__(self, name, slug=None, contact_email=None, subscription_plan='free', **kwargs): - """Create a new organization. - - Args: - name: Display name of the organization - slug: URL-safe identifier (auto-generated from name if not provided) - contact_email: Primary contact email - subscription_plan: Subscription tier ('free', 'starter', 'professional', 'enterprise') - **kwargs: Additional optional fields - """ - self.name = name.strip() - - # Generate slug from name if not provided - if slug: - self.slug = slug.strip().lower() - else: - import re - # Convert name to URL-safe slug - slug_base = re.sub(r'[^a-z0-9]+', '-', name.lower().strip()) - slug_base = slug_base.strip('-') - - # Ensure uniqueness by appending number if needed - counter = 1 - test_slug = slug_base - while Organization.query.filter_by(slug=test_slug).first(): - test_slug = f"{slug_base}-{counter}" - counter += 1 - - self.slug = test_slug - - self.contact_email = contact_email.strip() if contact_email else None - self.subscription_plan = subscription_plan - - # Set optional fields - for key, value in kwargs.items(): - if hasattr(self, key) and value is not None: - setattr(self, key, value) - - def __repr__(self): - return f'' - - @property - def is_active(self): - """Check if organization is active""" - return self.status == 'active' and self.deleted_at is None - - @property - def is_suspended(self): - """Check if organization is suspended""" - return self.status == 'suspended' - - @property - def is_deleted(self): - """Check if organization is soft-deleted""" - return self.deleted_at is not None - - @property - def member_count(self): - """Get total number of members in this organization""" - return self.memberships.filter_by(status='active').count() - - @property - def admin_count(self): - """Get number of admin members""" - return self.memberships.filter_by(role='admin', status='active').count() - - @property - def project_count(self): - """Get total number of projects""" - return self.projects.filter_by(status='active').count() - - @property - def has_reached_user_limit(self): - """Check if organization has reached its user limit""" - if self.max_users is None: - return False - return self.member_count >= self.max_users - - @property - def has_reached_project_limit(self): - """Check if organization has reached its project limit""" - if self.max_projects is None: - return False - return self.project_count >= self.max_projects - - @property - def has_active_subscription(self): - """Check if organization has an active paid subscription""" - return self.stripe_subscription_status in ['active', 'trialing'] - - @property - def has_billing_issue(self): - """Check if organization has a billing issue""" - return self.stripe_subscription_status in ['past_due', 'unpaid'] or self.billing_issue_detected_at is not None - - @property - def is_on_trial(self): - """Check if organization is on trial""" - if not self.trial_ends_at: - return False - return datetime.utcnow() < self.trial_ends_at and self.stripe_subscription_status == 'trialing' - - @property - def trial_days_remaining(self): - """Get number of trial days remaining""" - if not self.is_on_trial: - return 0 - delta = self.trial_ends_at - datetime.utcnow() - return max(0, delta.days) - - @property - def subscription_plan_display(self): - """Get display name for subscription plan""" - plan_names = { - 'free': 'Free', - 'single_user': 'Single User', - 'team': 'Team', - 'enterprise': 'Enterprise' - } - return plan_names.get(self.subscription_plan, self.subscription_plan.title()) - - def suspend(self, reason=None): - """Suspend the organization""" - self.status = 'suspended' - self.updated_at = datetime.utcnow() - db.session.commit() - - def activate(self): - """Activate the organization""" - self.status = 'active' - self.updated_at = datetime.utcnow() - db.session.commit() - - def soft_delete(self): - """Soft delete the organization""" - self.deleted_at = datetime.utcnow() - self.status = 'cancelled' - self.updated_at = datetime.utcnow() - db.session.commit() - - def update_billing_issue(self, has_issue=True): - """Mark or clear billing issue""" - if has_issue: - if not self.billing_issue_detected_at: - self.billing_issue_detected_at = datetime.utcnow() - else: - self.billing_issue_detected_at = None - self.updated_at = datetime.utcnow() - db.session.commit() - - def update_subscription_quantity(self, new_quantity): - """Update the subscription quantity (number of seats)""" - if new_quantity < 1: - raise ValueError("Subscription quantity must be at least 1") - - self.subscription_quantity = new_quantity - self.updated_at = datetime.utcnow() - db.session.commit() - - def get_members(self, role=None, status='active'): - """Get organization members with optional filters""" - query = self.memberships.filter_by(status=status) - - if role: - query = query.filter_by(role=role) - - return query.all() - - def get_admins(self): - """Get all admin members""" - return self.get_members(role='admin') - - def has_member(self, user_id): - """Check if a user is a member of this organization""" - from .membership import Membership - return Membership.query.filter_by( - organization_id=self.id, - user_id=user_id, - status='active' - ).first() is not None - - def get_user_role(self, user_id): - """Get the role of a user in this organization""" - from .membership import Membership - membership = Membership.query.filter_by( - organization_id=self.id, - user_id=user_id, - status='active' - ).first() - return membership.role if membership else None - - def is_admin(self, user_id): - """Check if a user is an admin of this organization""" - return self.get_user_role(user_id) == 'admin' - - def to_dict(self, include_stats=False, include_billing=False): - """Convert organization to dictionary for API responses""" - data = { - 'id': self.id, - 'name': self.name, - 'slug': self.slug, - 'contact_email': self.contact_email, - 'contact_phone': self.contact_phone, - 'billing_email': self.billing_email, - 'status': self.status, - 'subscription_plan': self.subscription_plan, - 'subscription_plan_display': self.subscription_plan_display, - 'max_users': self.max_users, - 'max_projects': self.max_projects, - 'timezone': self.timezone, - 'currency': self.currency, - 'date_format': self.date_format, - 'logo_filename': self.logo_filename, - 'primary_color': self.primary_color, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'is_active': self.is_active, - 'is_suspended': self.is_suspended, - } - - if include_stats: - data.update({ - 'member_count': self.member_count, - 'admin_count': self.admin_count, - 'project_count': self.project_count, - 'has_reached_user_limit': self.has_reached_user_limit, - 'has_reached_project_limit': self.has_reached_project_limit, - }) - - if include_billing: - data.update({ - 'stripe_customer_id': self.stripe_customer_id, - 'stripe_subscription_status': self.stripe_subscription_status, - 'subscription_quantity': self.subscription_quantity, - 'has_active_subscription': self.has_active_subscription, - 'has_billing_issue': self.has_billing_issue, - 'is_on_trial': self.is_on_trial, - 'trial_days_remaining': self.trial_days_remaining, - 'trial_ends_at': self.trial_ends_at.isoformat() if self.trial_ends_at else None, - 'next_billing_date': self.next_billing_date.isoformat() if self.next_billing_date else None, - 'subscription_ends_at': self.subscription_ends_at.isoformat() if self.subscription_ends_at else None, - 'billing_issue_detected_at': self.billing_issue_detected_at.isoformat() if self.billing_issue_detected_at else None, - }) - - return data - - @classmethod - def get_active_organizations(cls): - """Get all active organizations""" - return cls.query.filter_by(status='active').filter(cls.deleted_at.is_(None)).order_by(cls.name).all() - - @classmethod - def get_by_slug(cls, slug): - """Get organization by slug""" - return cls.query.filter_by(slug=slug).filter(cls.deleted_at.is_(None)).first() - diff --git a/app/models/password_reset.py b/app/models/password_reset.py deleted file mode 100644 index 1817b12..0000000 --- a/app/models/password_reset.py +++ /dev/null @@ -1,167 +0,0 @@ -from datetime import datetime, timedelta -from app import db -import secrets - -class PasswordResetToken(db.Model): - """Model for password reset tokens""" - - __tablename__ = 'password_reset_tokens' - - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - token = db.Column(db.String(100), unique=True, nullable=False, index=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - expires_at = db.Column(db.DateTime, nullable=False) - used = db.Column(db.Boolean, default=False, nullable=False) - used_at = db.Column(db.DateTime, nullable=True) - ip_address = db.Column(db.String(45), nullable=True) # IPv6 support - - # Relationships - user = db.relationship('User', backref='password_reset_tokens') - - def __init__(self, user_id, ip_address=None, expires_in_hours=24): - """Create a new password reset token. - - Args: - user_id: ID of the user requesting password reset - ip_address: IP address of the requester - expires_in_hours: Token validity period in hours (default 24) - """ - self.user_id = user_id - self.token = secrets.token_urlsafe(32) - self.created_at = datetime.utcnow() - self.expires_at = datetime.utcnow() + timedelta(hours=expires_in_hours) - self.ip_address = ip_address - self.used = False - - def __repr__(self): - return f'' - - @property - def is_expired(self): - """Check if token has expired""" - return datetime.utcnow() > self.expires_at - - @property - def is_valid(self): - """Check if token is valid (not used and not expired)""" - return not self.used and not self.is_expired - - def mark_as_used(self): - """Mark token as used""" - self.used = True - self.used_at = datetime.utcnow() - db.session.commit() - - @classmethod - def create_token(cls, user_id, ip_address=None): - """Create a new password reset token for a user""" - token = cls(user_id=user_id, ip_address=ip_address) - db.session.add(token) - db.session.commit() - return token - - @classmethod - def get_valid_token(cls, token_string): - """Get a valid token by token string""" - token = cls.query.filter_by(token=token_string).first() - - if token and token.is_valid: - return token - - return None - - @classmethod - def cleanup_expired_tokens(cls): - """Delete expired tokens (cleanup utility)""" - expired_tokens = cls.query.filter( - cls.expires_at < datetime.utcnow() - ).delete() - db.session.commit() - return expired_tokens - - @classmethod - def revoke_user_tokens(cls, user_id): - """Revoke all active tokens for a user""" - cls.query.filter_by(user_id=user_id, used=False).update({'used': True, 'used_at': datetime.utcnow()}) - db.session.commit() - - -class EmailVerificationToken(db.Model): - """Model for email verification tokens""" - - __tablename__ = 'email_verification_tokens' - - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - email = db.Column(db.String(200), nullable=False) # Email to verify - token = db.Column(db.String(100), unique=True, nullable=False, index=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - expires_at = db.Column(db.DateTime, nullable=False) - verified = db.Column(db.Boolean, default=False, nullable=False) - verified_at = db.Column(db.DateTime, nullable=True) - - # Relationships - user = db.relationship('User', backref='email_verification_tokens') - - def __init__(self, user_id, email, expires_in_hours=48): - """Create a new email verification token. - - Args: - user_id: ID of the user - email: Email address to verify - expires_in_hours: Token validity period in hours (default 48) - """ - self.user_id = user_id - self.email = email.lower().strip() - self.token = secrets.token_urlsafe(32) - self.created_at = datetime.utcnow() - self.expires_at = datetime.utcnow() + timedelta(hours=expires_in_hours) - self.verified = False - - def __repr__(self): - return f'' - - @property - def is_expired(self): - """Check if token has expired""" - return datetime.utcnow() > self.expires_at - - @property - def is_valid(self): - """Check if token is valid (not verified and not expired)""" - return not self.verified and not self.is_expired - - def mark_as_verified(self): - """Mark token as verified""" - self.verified = True - self.verified_at = datetime.utcnow() - db.session.commit() - - @classmethod - def create_token(cls, user_id, email): - """Create a new email verification token""" - token = cls(user_id=user_id, email=email) - db.session.add(token) - db.session.commit() - return token - - @classmethod - def get_valid_token(cls, token_string): - """Get a valid token by token string""" - token = cls.query.filter_by(token=token_string).first() - - if token and token.is_valid: - return token - - return None - - @classmethod - def cleanup_expired_tokens(cls): - """Delete expired tokens (cleanup utility)""" - expired_tokens = cls.query.filter( - cls.expires_at < datetime.utcnow() - ).delete() - db.session.commit() - return expired_tokens - diff --git a/app/models/project.py b/app/models/project.py index 9917b7a..70fadf9 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -6,13 +6,8 @@ class Project(db.Model): """Project model for client projects with billing information""" __tablename__ = 'projects' - __table_args__ = ( - db.Index('idx_projects_org_status', 'organization_id', 'status'), - db.Index('idx_projects_org_client', 'organization_id', 'client_id'), - ) id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) name = db.Column(db.String(200), nullable=False, index=True) client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) description = db.Column(db.Text, nullable=True) @@ -28,31 +23,19 @@ class Project(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) # Relationships - organization = db.relationship('Organization', back_populates='projects') time_entries = db.relationship('TimeEntry', backref='project', lazy='dynamic', cascade='all, delete-orphan') tasks = db.relationship('Task', backref='project', lazy='dynamic', cascade='all, delete-orphan') # comments relationship is defined via backref in Comment model - def __init__(self, name, organization_id, client_id=None, description=None, billable=True, hourly_rate=None, billing_ref=None, client=None): + def __init__(self, name, client_id=None, description=None, billable=True, hourly_rate=None, billing_ref=None, client=None): """Create a Project. Backward-compatible initializer that accepts either client_id or client name. If client name is provided and client_id is not, the corresponding Client record will be found or created on the fly and client_id will be set. - - Args: - name: Project name - organization_id: ID of the organization this project belongs to - client_id: ID of the client (optional if client name is provided) - description: Project description - billable: Whether the project is billable - hourly_rate: Hourly rate for the project - billing_ref: Billing reference - client: Client name (will be found or created if client_id not provided) """ from .client import Client # local import to avoid circular dependencies - self.organization_id = organization_id self.name = name.strip() self.description = description.strip() if description else None self.billable = billable @@ -61,13 +44,13 @@ class Project(db.Model): resolved_client_id = client_id if resolved_client_id is None and client: - # Find or create client by name (scoped to organization) + # Find or create client by name client_name = client.strip() - existing = Client.query.filter_by(name=client_name, organization_id=organization_id).first() + existing = Client.query.filter_by(name=client_name).first() if existing: resolved_client_id = existing.id else: - new_client = Client(name=client_name, organization_id=organization_id) + new_client = Client(name=client_name) db.session.add(new_client) # Flush to obtain id without committing the whole transaction try: diff --git a/app/models/promo_code.py b/app/models/promo_code.py deleted file mode 100644 index b8c255c..0000000 --- a/app/models/promo_code.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Promo Code Model for early adopter discounts and marketing campaigns""" - -from datetime import datetime -from app import db - - -class PromoCode(db.Model): - """Promo codes for discounts and special offers""" - - __tablename__ = 'promo_codes' - __table_args__ = ( - db.Index('idx_promo_codes_code', 'code'), - db.Index('idx_promo_codes_active', 'is_active'), - ) - - id = db.Column(db.Integer, primary_key=True) - code = db.Column(db.String(50), unique=True, nullable=False, index=True) - description = db.Column(db.String(200), nullable=True) - - # Discount settings - discount_type = db.Column(db.String(20), nullable=False, default='percent') # 'percent' or 'fixed' - discount_value = db.Column(db.Numeric(10, 2), nullable=False) # Percentage (e.g., 20.00 for 20%) or fixed amount - duration = db.Column(db.String(20), nullable=False, default='once') # 'once', 'repeating', 'forever' - duration_in_months = db.Column(db.Integer, nullable=True) # For 'repeating' duration - - # Usage limits - max_redemptions = db.Column(db.Integer, nullable=True) # Null = unlimited - times_redeemed = db.Column(db.Integer, default=0, nullable=False) - - # Validity period - valid_from = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) - valid_until = db.Column(db.DateTime, nullable=True) # Null = no expiry - - # Status - is_active = db.Column(db.Boolean, default=True, nullable=False) - - # Stripe integration - stripe_coupon_id = db.Column(db.String(100), nullable=True) # Stripe coupon ID if synced - stripe_promotion_code_id = db.Column(db.String(100), nullable=True) # Stripe promotion code ID - - # Restrictions - first_time_only = db.Column(db.Boolean, default=False, nullable=False) # Only for new customers - min_seats = db.Column(db.Integer, nullable=True) # Minimum seats required - max_seats = db.Column(db.Integer, nullable=True) # Maximum seats allowed - plan_restrictions = db.Column(db.String(200), nullable=True) # Comma-separated plan IDs - - # Metadata - 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) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - - # Relationships - redemptions = db.relationship('PromoCodeRedemption', back_populates='promo_code', lazy='dynamic') - - def __repr__(self): - return f'' - - @property - def is_valid(self): - """Check if promo code is currently valid""" - now = datetime.utcnow() - - # Check if active - if not self.is_active: - return False - - # Check validity period - if self.valid_from and now < self.valid_from: - return False - if self.valid_until and now > self.valid_until: - return False - - # Check redemption limits - if self.max_redemptions and self.times_redeemed >= self.max_redemptions: - return False - - return True - - def can_be_used_by(self, organization): - """Check if promo code can be used by a specific organization""" - if not self.is_valid: - return False, "Promo code is not valid" - - # Check if first-time only - if self.first_time_only: - if hasattr(organization, 'stripe_subscription_id') and organization.stripe_subscription_id: - return False, "This promo code is only for new customers" - - # Check if already used by this organization - existing_redemption = PromoCodeRedemption.query.filter_by( - promo_code_id=self.id, - organization_id=organization.id - ).first() - if existing_redemption: - return False, "This promo code has already been used by your organization" - - return True, "Promo code is valid" - - def redeem(self, organization_id, user_id=None): - """Redeem the promo code for an organization""" - redemption = PromoCodeRedemption( - promo_code_id=self.id, - organization_id=organization_id, - redeemed_by=user_id, - redeemed_at=datetime.utcnow() - ) - - self.times_redeemed += 1 - - db.session.add(redemption) - db.session.commit() - - return redemption - - def get_discount_description(self): - """Get human-readable discount description""" - if self.discount_type == 'percent': - discount = f"{int(self.discount_value)}% off" - else: - discount = f"€{self.discount_value} off" - - if self.duration == 'once': - duration = "first payment" - elif self.duration == 'forever': - duration = "forever" - elif self.duration == 'repeating': - duration = f"{self.duration_in_months} months" - else: - duration = "" - - return f"{discount} for {duration}" if duration else discount - - -class PromoCodeRedemption(db.Model): - """Track promo code redemptions""" - - __tablename__ = 'promo_code_redemptions' - __table_args__ = ( - db.Index('idx_redemptions_promo_code', 'promo_code_id'), - db.Index('idx_redemptions_org', 'organization_id'), - ) - - id = db.Column(db.Integer, primary_key=True) - promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False) - redeemed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - redeemed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - - # Stripe info - stripe_subscription_id = db.Column(db.String(100), nullable=True) - - # Relationships - promo_code = db.relationship('PromoCode', back_populates='redemptions') - organization = db.relationship('Organization') - user = db.relationship('User') - - def __repr__(self): - return f'' - diff --git a/app/models/refresh_token.py b/app/models/refresh_token.py deleted file mode 100644 index 196c8b4..0000000 --- a/app/models/refresh_token.py +++ /dev/null @@ -1,162 +0,0 @@ -from datetime import datetime, timedelta -from app import db -import secrets - -class RefreshToken(db.Model): - """Model for JWT refresh tokens""" - - __tablename__ = 'refresh_tokens' - __table_args__ = ( - db.Index('idx_refresh_tokens_user_device', 'user_id', 'device_id'), - ) - - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - token = db.Column(db.String(100), unique=True, nullable=False, index=True) - device_id = db.Column(db.String(100), nullable=True, index=True) # Optional device identifier - device_name = db.Column(db.String(200), nullable=True) # User-friendly device name - - # Token lifecycle - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - expires_at = db.Column(db.DateTime, nullable=False) - last_used_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - revoked = db.Column(db.Boolean, default=False, nullable=False) - revoked_at = db.Column(db.DateTime, nullable=True) - - # Request metadata - ip_address = db.Column(db.String(45), nullable=True) # IPv6 support - user_agent = db.Column(db.String(500), nullable=True) - - # Relationships - user = db.relationship('User', backref='refresh_tokens') - - def __init__(self, user_id, device_id=None, device_name=None, ip_address=None, user_agent=None, expires_in_days=30): - """Create a new refresh token. - - Args: - user_id: ID of the user - device_id: Unique device identifier - device_name: User-friendly device name - ip_address: IP address of the client - user_agent: User agent string - expires_in_days: Token validity period in days (default 30) - """ - self.user_id = user_id - self.token = secrets.token_urlsafe(48) - self.device_id = device_id - self.device_name = device_name - self.created_at = datetime.utcnow() - self.expires_at = datetime.utcnow() + timedelta(days=expires_in_days) - self.last_used_at = datetime.utcnow() - self.ip_address = ip_address - self.user_agent = user_agent - self.revoked = False - - def __repr__(self): - return f'' - - @property - def is_expired(self): - """Check if token has expired""" - return datetime.utcnow() > self.expires_at - - @property - def is_valid(self): - """Check if token is valid (not revoked and not expired)""" - return not self.revoked and not self.is_expired - - @property - def age_days(self): - """Get token age in days""" - return (datetime.utcnow() - self.created_at).days - - def update_last_used(self): - """Update the last used timestamp""" - self.last_used_at = datetime.utcnow() - db.session.commit() - - def revoke(self): - """Revoke the token""" - self.revoked = True - self.revoked_at = datetime.utcnow() - db.session.commit() - - def to_dict(self): - """Convert token to dictionary for API responses""" - return { - 'id': self.id, - 'device_id': self.device_id, - 'device_name': self.device_name, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, - 'expires_at': self.expires_at.isoformat() if self.expires_at else None, - 'ip_address': self.ip_address, - 'is_current': False, # Can be set by caller - } - - @classmethod - def create_token(cls, user_id, device_id=None, device_name=None, ip_address=None, user_agent=None): - """Create a new refresh token for a user""" - token = cls( - user_id=user_id, - device_id=device_id, - device_name=device_name, - ip_address=ip_address, - user_agent=user_agent - ) - db.session.add(token) - db.session.commit() - return token - - @classmethod - def get_valid_token(cls, token_string): - """Get a valid token by token string""" - token = cls.query.filter_by(token=token_string).first() - - if token and token.is_valid: - return token - - return None - - @classmethod - def get_user_tokens(cls, user_id, include_revoked=False): - """Get all tokens for a user""" - query = cls.query.filter_by(user_id=user_id) - - if not include_revoked: - query = query.filter_by(revoked=False) - - return query.order_by(cls.last_used_at.desc()).all() - - @classmethod - def revoke_user_tokens(cls, user_id, except_token_id=None): - """Revoke all tokens for a user, optionally except one""" - query = cls.query.filter_by(user_id=user_id, revoked=False) - - if except_token_id: - query = query.filter(cls.id != except_token_id) - - tokens = query.all() - for token in tokens: - token.revoke() - - @classmethod - def revoke_device_tokens(cls, user_id, device_id): - """Revoke all tokens for a specific device""" - tokens = cls.query.filter_by(user_id=user_id, device_id=device_id, revoked=False).all() - for token in tokens: - token.revoke() - - @classmethod - def cleanup_expired_tokens(cls, days_old=90): - """Delete old expired or revoked tokens""" - cutoff_date = datetime.utcnow() - timedelta(days=days_old) - deleted = cls.query.filter( - db.or_( - cls.expires_at < datetime.utcnow(), - db.and_(cls.revoked == True, cls.revoked_at < cutoff_date) - ) - ).delete() - db.session.commit() - return deleted - diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py index 980f52a..8ca9a23 100644 --- a/app/models/saved_filter.py +++ b/app/models/saved_filter.py @@ -12,7 +12,6 @@ class SavedFilter(db.Model): __tablename__ = 'saved_filters' id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) name = db.Column(db.String(200), nullable=False) scope = db.Column(db.String(50), nullable=False, default='global') # e.g., 'time', 'projects', 'tasks', 'reports' diff --git a/app/models/settings.py b/app/models/settings.py index 382374a..0f51b56 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -40,7 +40,7 @@ class Settings(db.Model): invoice_notes = db.Column(db.Text, default='Thank you for your business!', nullable=False) # Privacy and analytics settings - allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls analytics tracking + allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing with license server 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) diff --git a/app/models/subscription_event.py b/app/models/subscription_event.py deleted file mode 100644 index a4692ca..0000000 --- a/app/models/subscription_event.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Subscription event model for tracking Stripe webhooks and subscription changes""" -from datetime import datetime -from app import db -import json - -class SubscriptionEvent(db.Model): - """Model for tracking subscription events from Stripe webhooks. - - This helps with auditing, debugging, and handling webhook delivery issues. - """ - - __tablename__ = 'subscription_events' - __table_args__ = ( - db.Index('idx_subscription_events_org', 'organization_id'), - db.Index('idx_subscription_events_type', 'event_type'), - db.Index('idx_subscription_events_created', 'created_at'), - ) - - id = db.Column(db.Integer, primary_key=True) - - # Stripe event details - stripe_event_id = db.Column(db.String(100), unique=True, nullable=True, index=True) # Made nullable for manual events - event_type = db.Column(db.String(100), nullable=False) # e.g., 'customer.subscription.created' - event_id = db.Column(db.String(100), nullable=True) # For Stripe webhook event ID - - # Organization reference - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=True, index=True) - - # Event data - event_data = db.Column(db.Text, nullable=True) # JSON string of the full event - raw_payload = db.Column(db.Text, nullable=True) # Raw webhook payload - - # Transaction details (for financial events) - stripe_customer_id = db.Column(db.String(100), nullable=True) - stripe_subscription_id = db.Column(db.String(100), nullable=True) - stripe_invoice_id = db.Column(db.String(100), nullable=True) - stripe_charge_id = db.Column(db.String(100), nullable=True) - stripe_refund_id = db.Column(db.String(100), nullable=True) - amount = db.Column(db.Numeric(10, 2), nullable=True) - currency = db.Column(db.String(3), nullable=True) - - # Status tracking - status = db.Column(db.String(50), nullable=True) # Event-specific status - previous_status = db.Column(db.String(50), nullable=True) - quantity = db.Column(db.Integer, nullable=True) # For subscription quantity changes - previous_quantity = db.Column(db.Integer, nullable=True) - - # Processing status - processed = db.Column(db.Boolean, default=False, nullable=False) - processed_at = db.Column(db.DateTime, nullable=True) - processing_error = db.Column(db.Text, nullable=True) - retry_count = db.Column(db.Integer, default=0, nullable=False) - notes = db.Column(db.Text, nullable=True) # Additional notes or context - - # Timestamps - 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) - - # Relationships - organization = db.relationship('Organization', backref='subscription_events') - - def __init__(self, event_type, stripe_event_id=None, organization_id=None, **kwargs): - """Create a new subscription event. - - Args: - event_type: Type of event (e.g., 'customer.subscription.updated') - stripe_event_id: Stripe's unique event ID (optional for manual events) - organization_id: Optional organization ID - **kwargs: Additional fields - """ - self.stripe_event_id = stripe_event_id - self.event_type = event_type - self.organization_id = organization_id - - # Set additional fields from kwargs - for key, value in kwargs.items(): - if hasattr(self, key): - # Convert dict to JSON string for text fields - if key in ('event_data', 'raw_payload') and isinstance(value, dict): - setattr(self, key, json.dumps(value)) - else: - setattr(self, key, value) - - if not hasattr(self, 'processed') or self.processed is None: - self.processed = False - if not hasattr(self, 'retry_count') or self.retry_count is None: - self.retry_count = 0 - - def __repr__(self): - return f'' - - @property - def event_data_dict(self): - """Get event data as a dictionary""" - if not self.event_data: - return {} - - try: - return json.loads(self.event_data) - except (json.JSONDecodeError, TypeError): - return {} - - def mark_as_processed(self, success=True, error_message=None): - """Mark event as processed. - - Args: - success: Whether processing was successful - error_message: Error message if processing failed - """ - self.processed = success - self.processed_at = datetime.utcnow() - - if not success and error_message: - self.processing_error = error_message - self.retry_count += 1 - - db.session.commit() - - def to_dict(self): - """Convert event to dictionary for API responses""" - return { - 'id': self.id, - 'stripe_event_id': self.stripe_event_id, - 'event_id': self.event_id, - 'event_type': self.event_type, - 'organization_id': self.organization_id, - 'stripe_customer_id': self.stripe_customer_id, - 'stripe_subscription_id': self.stripe_subscription_id, - 'stripe_invoice_id': self.stripe_invoice_id, - 'amount': float(self.amount) if self.amount else None, - 'currency': self.currency, - 'status': self.status, - 'previous_status': self.previous_status, - 'quantity': self.quantity, - 'previous_quantity': self.previous_quantity, - 'processed': self.processed, - 'processed_at': self.processed_at.isoformat() if self.processed_at else None, - 'processing_error': self.processing_error, - 'retry_count': self.retry_count, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - @classmethod - def create_event(cls, event_type, stripe_event_id=None, event_data=None, organization_id=None, **kwargs): - """Create a new subscription event. - - Args: - event_type: Type of event - stripe_event_id: Stripe's unique event ID (optional) - event_data: Full event data - organization_id: Optional organization ID - **kwargs: Additional fields - - Returns: - SubscriptionEvent: The created event - """ - event = cls( - event_type=event_type, - stripe_event_id=stripe_event_id, - event_data=event_data, - organization_id=organization_id, - **kwargs - ) - db.session.add(event) - db.session.commit() - return event - - @classmethod - def get_by_stripe_id(cls, stripe_event_id): - """Get event by Stripe event ID. - - Args: - stripe_event_id: Stripe's event ID - - Returns: - SubscriptionEvent or None - """ - return cls.query.filter_by(stripe_event_id=stripe_event_id).first() - - @classmethod - def get_unprocessed_events(cls, limit=100): - """Get unprocessed events. - - Args: - limit: Maximum number of events to return - - Returns: - list: List of unprocessed events - """ - return cls.query.filter_by(processed=False).order_by(cls.created_at).limit(limit).all() - - @classmethod - def get_failed_events(cls, max_retries=3): - """Get events that failed processing and haven't exceeded retry limit. - - Args: - max_retries: Maximum number of retries before giving up - - Returns: - list: List of failed events eligible for retry - """ - return cls.query.filter( - cls.processed == False, - cls.processing_error.isnot(None), - cls.retry_count < max_retries - ).order_by(cls.updated_at).all() - - @classmethod - def get_organization_events(cls, organization_id, event_type=None, limit=50): - """Get events for an organization. - - Args: - organization_id: Organization ID - event_type: Optional filter by event type - limit: Maximum number of events to return - - Returns: - list: List of events - """ - query = cls.query.filter_by(organization_id=organization_id) - - if event_type: - query = query.filter_by(event_type=event_type) - - return query.order_by(cls.created_at.desc()).limit(limit).all() - - @classmethod - def cleanup_old_events(cls, days_old=90): - """Delete old processed events. - - Args: - days_old: Delete events older than this many days - - Returns: - int: Number of events deleted - """ - from datetime import timedelta - cutoff_date = datetime.utcnow() - timedelta(days=days_old) - - deleted = cls.query.filter( - cls.processed == True, - cls.created_at < cutoff_date - ).delete() - - db.session.commit() - return deleted diff --git a/app/models/task.py b/app/models/task.py index de989c5..7264198 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -6,14 +6,8 @@ class Task(db.Model): """Task model for breaking down projects into manageable components""" __tablename__ = 'tasks' - __table_args__ = ( - db.Index('idx_tasks_org_project', 'organization_id', 'project_id'), - db.Index('idx_tasks_org_status', 'organization_id', 'status'), - db.Index('idx_tasks_org_assigned', 'organization_id', 'assigned_to'), - ) id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) name = db.Column(db.String(200), nullable=False, index=True) description = db.Column(db.Text, nullable=True) @@ -29,16 +23,14 @@ class Task(db.Model): completed_at = db.Column(db.DateTime, nullable=True) # Relationships - organization = db.relationship('Organization', back_populates='tasks') # project relationship is defined via backref in Project model assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_tasks') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tasks') time_entries = db.relationship('TimeEntry', backref='task', lazy='dynamic', cascade='all, delete-orphan') # comments relationship is defined via backref in Comment model - def __init__(self, project_id, organization_id, name, description=None, priority='medium', estimated_hours=None, + def __init__(self, project_id, name, description=None, priority='medium', estimated_hours=None, due_date=None, assigned_to=None, created_by=None): - self.organization_id = organization_id self.project_id = project_id self.name = name.strip() self.description = description.strip() if description else None diff --git a/app/models/task_activity.py b/app/models/task_activity.py index 433d657..b895e6e 100644 --- a/app/models/task_activity.py +++ b/app/models/task_activity.py @@ -7,7 +7,6 @@ class TaskActivity(db.Model): __tablename__ = 'task_activities' id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) event = db.Column(db.String(50), nullable=False, index=True) @@ -17,8 +16,7 @@ class TaskActivity(db.Model): task = db.relationship('Task', backref=db.backref('activities', lazy='dynamic', cascade='all, delete-orphan')) user = db.relationship('User') - def __init__(self, task_id, organization_id, event, user_id=None, details=None): - self.organization_id = organization_id + def __init__(self, task_id, event, user_id=None, details=None): self.task_id = task_id self.user_id = user_id self.event = event diff --git a/app/models/time_entry.py b/app/models/time_entry.py index bc71ea2..ec9ca89 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -14,14 +14,8 @@ class TimeEntry(db.Model): """Time entry model for manual and automatic time tracking""" __tablename__ = 'time_entries' - __table_args__ = ( - db.Index('idx_time_entries_org_user', 'organization_id', 'user_id'), - db.Index('idx_time_entries_org_project', 'organization_id', 'project_id'), - db.Index('idx_time_entries_org_dates', 'organization_id', 'start_time', 'end_time'), - ) id = db.Column(db.Integer, primary_key=True) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id'), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) @@ -36,12 +30,10 @@ class TimeEntry(db.Model): updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) # Relationships - organization = db.relationship('Organization', back_populates='time_entries') # user and project relationships are defined via backref in their respective models # task relationship is defined via backref in Task model - def __init__(self, user_id, project_id, organization_id, start_time, end_time=None, task_id=None, notes=None, tags=None, source='manual', billable=True): - self.organization_id = organization_id + def __init__(self, user_id, project_id, start_time, end_time=None, task_id=None, notes=None, tags=None, source='manual', billable=True): self.user_id = user_id self.project_id = project_id self.task_id = task_id diff --git a/app/models/user.py b/app/models/user.py index 10afa65..97cc1ed 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -13,32 +13,17 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) - email = db.Column(db.String(200), unique=True, nullable=True, index=True) - password_hash = db.Column(db.String(255), nullable=True) # For local auth + email = db.Column(db.String(200), nullable=True, index=True) full_name = db.Column(db.String(200), nullable=True) role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin' created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_login = db.Column(db.DateTime, nullable=True) is_active = db.Column(db.Boolean, default=True, nullable=False) - email_verified = db.Column(db.Boolean, default=False, nullable=False) theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de' - - # OIDC authentication oidc_sub = db.Column(db.String(255), nullable=True) oidc_issuer = db.Column(db.String(255), nullable=True) - # 2FA fields - totp_secret = db.Column(db.String(32), nullable=True) # For TOTP 2FA - totp_enabled = db.Column(db.Boolean, default=False, nullable=False) - backup_codes = db.Column(db.Text, nullable=True) # JSON array of hashed backup codes - - # Password policy fields - password_changed_at = db.Column(db.DateTime, nullable=True) - password_history = db.Column(db.Text, nullable=True) # JSON array of previous password hashes - failed_login_attempts = db.Column(db.Integer, default=0, nullable=False) - account_locked_until = db.Column(db.DateTime, nullable=True) - # Relationships time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan') @@ -98,161 +83,6 @@ class User(UserMixin, db.Model): self.last_login = datetime.utcnow() db.session.commit() - def set_password(self, password, validate=True): - """Set user password (hashed) with optional policy validation""" - if validate: - from app.utils.password_policy import PasswordPolicy - is_valid, error_msg = PasswordPolicy.validate_password(password, self) - if not is_valid: - raise ValueError(error_msg) - - # Store old password in history before updating - if self.password_hash: - self._add_to_password_history(self.password_hash) - - # Set new password - self.password_hash = generate_password_hash(password, method='pbkdf2:sha256') - self.password_changed_at = datetime.utcnow() - - # Reset failed login attempts when password is changed - self.failed_login_attempts = 0 - self.account_locked_until = None - - def check_password(self, password): - """Check if provided password matches the hash""" - # Check if account is locked - if self.is_account_locked(): - return False - - if not self.password_hash: - return False - - is_valid = check_password_hash(self.password_hash, password) - - if is_valid: - # Reset failed attempts on successful login - self.failed_login_attempts = 0 - self.account_locked_until = None - else: - # Increment failed attempts - self.failed_login_attempts += 1 - - # Lock account after 5 failed attempts for 30 minutes - from datetime import timedelta - if self.failed_login_attempts >= 5: - self.account_locked_until = datetime.utcnow() + timedelta(minutes=30) - - return is_valid - - @property - def has_password(self): - """Check if user has a password set (for local auth)""" - return self.password_hash is not None - - def verify_totp(self, token): - """Verify TOTP token for 2FA""" - if not self.totp_enabled or not self.totp_secret: - return False - - import pyotp - totp = pyotp.TOTP(self.totp_secret) - return totp.verify(token, valid_window=1) - - def verify_backup_code(self, code): - """Verify and consume a backup code""" - if not self.backup_codes: - return False - - import json - codes = json.loads(self.backup_codes) - code_hash = generate_password_hash(code) - - for idx, stored_hash in enumerate(codes): - if check_password_hash(stored_hash, code): - # Remove the used code - codes.pop(idx) - self.backup_codes = json.dumps(codes) - db.session.commit() - return True - - return False - - def generate_backup_codes(self, count=10): - """Generate new backup codes for 2FA""" - import secrets - import json - - codes = [] - hashed_codes = [] - - for _ in range(count): - code = '-'.join([secrets.token_hex(2) for _ in range(4)]) - codes.append(code) - hashed_codes.append(generate_password_hash(code)) - - self.backup_codes = json.dumps(hashed_codes) - db.session.commit() - - return codes # Return plain codes for user to save - - def is_account_locked(self): - """Check if account is currently locked due to failed login attempts""" - if not self.account_locked_until: - return False - - if datetime.utcnow() < self.account_locked_until: - return True - - # Lock has expired, clear it - self.account_locked_until = None - self.failed_login_attempts = 0 - return False - - def _add_to_password_history(self, password_hash): - """Add a password hash to the user's password history""" - import json - from flask import current_app - - history_count = current_app.config.get('PASSWORD_HISTORY_COUNT', 5) - - if history_count == 0: - return - - # Get existing history - history = [] - if self.password_history: - try: - history = json.loads(self.password_history) - except Exception: - history = [] - - # Add new hash to history - history.append(password_hash) - - # Keep only the most recent entries - history = history[-history_count:] - - self.password_history = json.dumps(history) - - def check_password_history(self, password): - """Check if password was used recently""" - import json - - if not self.password_history: - return False - - try: - history = json.loads(self.password_history) - except Exception: - return False - - # Check if password matches any in history - for old_hash in history: - if check_password_hash(old_hash, password): - return True - - return False - def to_dict(self): """Convert user to dictionary for API responses""" return { @@ -265,8 +95,5 @@ class User(UserMixin, db.Model): 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_login': self.last_login.isoformat() if self.last_login else None, 'is_active': self.is_active, - 'email_verified': self.email_verified, - 'totp_enabled': self.totp_enabled, - 'has_password': self.has_password, 'total_hours': self.total_hours } diff --git a/app/routes/admin.py b/app/routes/admin.py index 93d5fec..7484799 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -3,24 +3,15 @@ from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, limiter from app.models import User, Project, TimeEntry, Settings, Invoice -from app.models.organization import Organization -from app.models.membership import Membership -from app.models.subscription_event import SubscriptionEvent from datetime import datetime -from sqlalchemy import text, func, desc +from sqlalchemy import text import os from werkzeug.utils import secure_filename import uuid from app.utils.db import safe_commit from app.utils.backup import create_backup, restore_backup -from app.utils.stripe_service import stripe_service import threading import time -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) admin_bp = Blueprint('admin', __name__) @@ -56,37 +47,23 @@ def get_upload_folder(): @admin_bp.route('/admin') @login_required @admin_required -@require_organization_access() def admin_dashboard(): """Admin dashboard""" - org_id = get_current_organization_id() - - # Get system statistics (scoped to organization) - total_users = User.query.count() # Users are global + # Get system statistics + total_users = User.query.count() active_users = User.query.filter_by(is_active=True).count() - total_projects = scoped_query(Project).count() - active_projects = scoped_query(Project).filter_by(status='active').count() - total_entries = scoped_query(TimeEntry).filter(TimeEntry.end_time.isnot(None)).count() - active_timers = scoped_query(TimeEntry).filter_by(end_time=None).count() + total_projects = Project.query.count() + active_projects = Project.query.filter_by(status='active').count() + total_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count() + active_timers = TimeEntry.query.filter_by(end_time=None).count() - # Get recent activity (scoped to organization) - recent_entries = scoped_query(TimeEntry).filter( + # Get recent activity + recent_entries = TimeEntry.query.filter( TimeEntry.end_time.isnot(None) ).order_by( TimeEntry.created_at.desc() ).limit(10).all() - # Calculate hours (scoped to organization) - org_total_seconds = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.organization_id == org_id, - TimeEntry.end_time.isnot(None) - ).scalar() or 0 - org_billable_seconds = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.organization_id == org_id, - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True - ).scalar() or 0 - # Build stats object expected by the template stats = { 'total_users': total_users, @@ -94,8 +71,8 @@ def admin_dashboard(): 'total_projects': total_projects, 'active_projects': active_projects, 'total_entries': total_entries, - 'total_hours': round(org_total_seconds / 3600, 2), - 'billable_hours': round(org_billable_seconds / 3600, 2), + 'total_hours': TimeEntry.get_total_hours_for_period(), + 'billable_hours': TimeEntry.get_total_hours_for_period(billable_only=True), 'last_backup': None } @@ -713,414 +690,64 @@ def system_info(): active_timers=active_timers, db_size_mb=db_size_mb) -# ======================================== -# Customer Management (Organizations) -# ======================================== - -@admin_bp.route('/admin/customers') +@admin_bp.route('/license-status') @login_required @admin_required -def customers(): - """List all organizations (customers) with billing info""" - # Get all organizations with member counts and subscription info - organizations = Organization.query.order_by(Organization.created_at.desc()).all() - - # Enrich with additional data - customer_data = [] - for org in organizations: - # Get active member count - active_members = Membership.query.filter_by( - organization_id=org.id, - status='active' - ).count() - - # Get most recent activity (last login of any member) - last_activity = db.session.query(func.max(User.last_login)).join( - Membership, Membership.user_id == User.id - ).filter( - Membership.organization_id == org.id, - Membership.status == 'active' - ).scalar() - - # Get invoice count - invoice_count = Invoice.query.filter_by(organization_id=org.id).count() - - customer_data.append({ - 'organization': org, - 'active_members': active_members, - 'last_activity': last_activity, - 'invoice_count': invoice_count, - }) - - return render_template( - 'admin/customers.html', - customer_data=customer_data, - stripe_configured=stripe_service.is_configured() - ) - -@admin_bp.route('/admin/customers/') -@login_required -@admin_required -def customer_detail(org_id): - """View detailed information about a specific organization/customer""" - organization = Organization.query.get_or_404(org_id) - - # Get members - memberships = Membership.query.filter_by( - organization_id=org_id - ).join(User).order_by(Membership.created_at.desc()).all() - - # Get subscription events - recent_events = SubscriptionEvent.get_organization_events( - organization_id=org_id, - limit=20 - ) - - # Get Stripe data if configured - stripe_data = None - if stripe_service.is_configured() and organization.stripe_customer_id: - try: - stripe_data = { - 'invoices': stripe_service.get_invoices(organization, limit=10), - 'upcoming_invoice': stripe_service.get_upcoming_invoice(organization), - 'payment_methods': stripe_service.get_payment_methods(organization), - 'refunds': stripe_service.get_refunds(organization, limit=5), - } - except Exception as e: - current_app.logger.error(f"Error fetching Stripe data: {e}") - flash('Error loading Stripe data', 'warning') - - return render_template( - 'admin/customer_detail.html', - organization=organization, - memberships=memberships, - recent_events=recent_events, - stripe_data=stripe_data, - stripe_configured=stripe_service.is_configured() - ) - -@admin_bp.route('/admin/customers//subscription/quantity', methods=['POST']) -@login_required -@admin_required -@limiter.limit("10 per minute") -def update_subscription_quantity(org_id): - """Update the subscription quantity (seats) for an organization""" - organization = Organization.query.get_or_404(org_id) - - if not organization.stripe_subscription_id: - flash(_('Organization does not have an active subscription'), 'error') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - +def license_status(): + """Show metrics server client status""" try: - new_quantity = int(request.form.get('quantity', 1)) - - if new_quantity < 1: - flash(_('Quantity must be at least 1'), 'error') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - # Update via Stripe - result = stripe_service.update_subscription_quantity(organization, new_quantity) - - flash(_(f'Subscription updated: {result["old_quantity"]} → {result["new_quantity"]} seats'), 'success') - except Exception as e: - current_app.logger.error(f"Error updating subscription quantity: {e}") - flash(_(f'Error updating subscription: {str(e)}'), 'error') - - return redirect(url_for('admin.customer_detail', org_id=org_id)) - -@admin_bp.route('/admin/customers//subscription/cancel', methods=['POST']) -@login_required -@admin_required -@limiter.limit("5 per minute") -def cancel_subscription(org_id): - """Cancel a subscription""" - organization = Organization.query.get_or_404(org_id) - - if not organization.stripe_subscription_id: - flash(_('Organization does not have an active subscription'), 'error') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - try: - at_period_end = request.form.get('at_period_end', 'true') == 'true' - - result = stripe_service.cancel_subscription(organization, at_period_end=at_period_end) - - if at_period_end: - flash(_(f'Subscription will cancel at period end: {result["ends_at"].strftime("%Y-%m-%d")}'), 'success') + from app.utils.license_server import get_license_client + client = get_license_client() + if client: + status = client.get_status() + settings = Settings.get_settings() + return render_template('admin/license_status.html', status=status, settings=settings) else: - flash(_('Subscription cancelled immediately'), 'success') + flash('Metrics server client not initialized', 'warning') + return redirect(url_for('admin.admin_dashboard')) except Exception as e: - current_app.logger.error(f"Error cancelling subscription: {e}") - flash(_(f'Error cancelling subscription: {str(e)}'), 'error') - - return redirect(url_for('admin.customer_detail', org_id=org_id)) - -@admin_bp.route('/admin/customers//subscription/reactivate', methods=['POST']) -@login_required -@admin_required -@limiter.limit("5 per minute") -def reactivate_subscription(org_id): - """Reactivate a subscription that was set to cancel""" - organization = Organization.query.get_or_404(org_id) - - if not organization.stripe_subscription_id: - flash(_('Organization does not have an active subscription'), 'error') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - try: - result = stripe_service.reactivate_subscription(organization) - flash(_('Subscription reactivated successfully'), 'success') - except Exception as e: - current_app.logger.error(f"Error reactivating subscription: {e}") - flash(_(f'Error reactivating subscription: {str(e)}'), 'error') - - return redirect(url_for('admin.customer_detail', org_id=org_id)) - -@admin_bp.route('/admin/customers//suspend', methods=['POST']) -@login_required -@admin_required -@limiter.limit("5 per minute") -def suspend_organization(org_id): - """Suspend an organization""" - organization = Organization.query.get_or_404(org_id) - - if organization.is_suspended: - flash(_('Organization is already suspended'), 'info') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - try: - reason = request.form.get('reason', 'Administrative action') - organization.suspend(reason=reason) - - # Log event - SubscriptionEvent.create_event( - event_type='organization.suspended', - organization_id=organization.id, - notes=f'Suspended by admin: {reason}' - ) - - flash(_('Organization suspended successfully'), 'success') - except Exception as e: - current_app.logger.error(f"Error suspending organization: {e}") - flash(_(f'Error suspending organization: {str(e)}'), 'error') - - return redirect(url_for('admin.customer_detail', org_id=org_id)) - -@admin_bp.route('/admin/customers//activate', methods=['POST']) -@login_required -@admin_required -@limiter.limit("5 per minute") -def activate_organization(org_id): - """Activate/reactivate an organization""" - organization = Organization.query.get_or_404(org_id) - - if organization.is_active: - flash(_('Organization is already active'), 'info') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - try: - organization.activate() - - # Log event - SubscriptionEvent.create_event( - event_type='organization.activated', - organization_id=organization.id, - notes=f'Activated by admin: {current_user.username}' - ) - - flash(_('Organization activated successfully'), 'success') - except Exception as e: - current_app.logger.error(f"Error activating organization: {e}") - flash(_(f'Error activating organization: {str(e)}'), 'error') - - return redirect(url_for('admin.customer_detail', org_id=org_id)) - -@admin_bp.route('/admin/customers//invoice//refund', methods=['POST']) -@login_required -@admin_required -@limiter.limit("3 per minute") -def create_refund(org_id, invoice_id): - """Create a refund for an invoice""" - organization = Organization.query.get_or_404(org_id) - - if not stripe_service.is_configured(): - flash(_('Stripe is not configured'), 'error') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - try: - amount_str = request.form.get('amount') - reason = request.form.get('reason', 'requested_by_customer') - - # Convert amount to cents if provided - amount_cents = None - if amount_str: - try: - amount_cents = int(float(amount_str) * 100) - except ValueError: - flash(_('Invalid amount'), 'error') - return redirect(url_for('admin.customer_detail', org_id=org_id)) - - result = stripe_service.create_refund( - organization=organization, - invoice_id=invoice_id, - amount=amount_cents, - reason=reason - ) - - flash(_(f'Refund created: {result["amount"]} {result["currency"]}'), 'success') - except Exception as e: - current_app.logger.error(f"Error creating refund: {e}") - flash(_(f'Error creating refund: {str(e)}'), 'error') - - return redirect(url_for('admin.customer_detail', org_id=org_id)) - -# ======================================== -# Billing Reconciliation -# ======================================== - -@admin_bp.route('/admin/billing/reconciliation') -@login_required -@admin_required -def billing_reconciliation(): - """View billing reconciliation - sync status between Stripe and local DB""" - if not stripe_service.is_configured(): - flash(_('Stripe is not configured'), 'warning') - return redirect(url_for('admin.admin_dashboard')) - - try: - sync_results = stripe_service.check_all_organizations_sync() - - return render_template( - 'admin/billing_reconciliation.html', - sync_results=sync_results - ) - except Exception as e: - current_app.logger.error(f"Error checking sync status: {e}") - flash(_(f'Error checking sync status: {str(e)}'), 'error') + flash(f'Error getting metrics status: {e}', 'error') return redirect(url_for('admin.admin_dashboard')) -@admin_bp.route('/admin/billing/reconciliation//sync', methods=['POST']) +@admin_bp.route('/license-test') @login_required @admin_required -@limiter.limit("10 per minute") -def sync_organization(org_id): - """Manually sync an organization with Stripe""" - organization = Organization.query.get_or_404(org_id) - - if not stripe_service.is_configured(): - flash(_('Stripe is not configured'), 'error') - return redirect(url_for('admin.billing_reconciliation')) - +def license_test(): + """Test metrics server communication""" try: - result = stripe_service.sync_organization_with_stripe(organization) - - if result.get('synced'): - if result.get('discrepancy_count', 0) > 0: - flash(_(f'Synced with {result["discrepancy_count"]} discrepancy(ies) found and corrected'), 'warning') + from app.utils.license_server import get_license_client, send_usage_event + client = get_license_client() + if client: + # Test server health + server_healthy = client.check_server_health() + + # Test usage event + usage_sent = send_usage_event("admin_test", {"admin": current_user.username}) + + flash(f'Metrics Server: {"✓ Healthy" if server_healthy else "✗ Not Responding"}, Usage Event: {"✓ Sent" if usage_sent else "✗ Failed"}', 'info') + else: + flash('Metrics server client not initialized', 'warning') + except Exception as e: + flash(f'Error testing metrics server: {e}', 'error') + + return redirect(url_for('admin.license_status')) + +@admin_bp.route('/license-restart') +@login_required +@admin_required +def license_restart(): + """Restart the metrics server client""" + try: + from app.utils.license_server import get_license_client, start_license_client + client = get_license_client() + if client: + if start_license_client(): + flash('Metrics server client restarted successfully', 'success') else: - flash(_('Organization synced successfully - no discrepancies found'), 'success') + flash('Failed to restart metrics server client', 'error') else: - flash(_(f'Sync failed: {result.get("error")}'), 'error') + flash('Metrics server client not initialized', 'warning') except Exception as e: - current_app.logger.error(f"Error syncing organization: {e}") - flash(_(f'Error syncing organization: {str(e)}'), 'error') + flash(f'Error restarting metrics server client: {e}', 'error') - return redirect(url_for('admin.billing_reconciliation')) - -# ======================================== -# Webhook Logs -# ======================================== - -@admin_bp.route('/admin/webhooks') -@login_required -@admin_required -def webhook_logs(): - """View webhook event logs""" - page = request.args.get('page', 1, type=int) - per_page = 50 - - # Filter options - event_type = request.args.get('event_type') - org_id = request.args.get('org_id', type=int) - processed = request.args.get('processed') - - # Build query - query = SubscriptionEvent.query - - if event_type: - query = query.filter_by(event_type=event_type) - - if org_id: - query = query.filter_by(organization_id=org_id) - - if processed == 'true': - query = query.filter_by(processed=True) - elif processed == 'false': - query = query.filter_by(processed=False) - - # Order by most recent first - query = query.order_by(desc(SubscriptionEvent.created_at)) - - # Paginate - pagination = query.paginate(page=page, per_page=per_page, error_out=False) - events = pagination.items - - # Get unique event types for filter - event_types = db.session.query( - SubscriptionEvent.event_type - ).distinct().order_by(SubscriptionEvent.event_type).all() - event_types = [et[0] for et in event_types] - - # Get organizations for filter - organizations = Organization.query.order_by(Organization.name).all() - - return render_template( - 'admin/webhook_logs.html', - events=events, - pagination=pagination, - event_types=event_types, - organizations=organizations, - current_filters={ - 'event_type': event_type, - 'org_id': org_id, - 'processed': processed - } - ) - -@admin_bp.route('/admin/webhooks/') -@login_required -@admin_required -def webhook_detail(event_id): - """View detailed information about a webhook event""" - event = SubscriptionEvent.query.get_or_404(event_id) - - return render_template( - 'admin/webhook_detail.html', - event=event - ) - -@admin_bp.route('/admin/webhooks//reprocess', methods=['POST']) -@login_required -@admin_required -@limiter.limit("10 per minute") -def reprocess_webhook(event_id): - """Reprocess a failed webhook event""" - event = SubscriptionEvent.query.get_or_404(event_id) - - if event.processed and not event.processing_error: - flash(_('Event has already been processed successfully'), 'info') - return redirect(url_for('admin.webhook_logs')) - - try: - # Mark as unprocessed and reset retry count - event.processed = False - event.processing_error = None - event.retry_count = 0 - db.session.commit() - - flash(_('Event queued for reprocessing'), 'success') - except Exception as e: - current_app.logger.error(f"Error queueing event for reprocessing: {e}") - flash(_(f'Error: {str(e)}'), 'error') - - return redirect(url_for('admin.webhook_logs')) + return redirect(url_for('admin.license_status')) diff --git a/app/routes/analytics.py b/app/routes/analytics.py index ae2a660..64940ef 100644 --- a/app/routes/analytics.py +++ b/app/routes/analytics.py @@ -5,17 +5,11 @@ from app.models import User, Project, TimeEntry, Settings, Task from datetime import datetime, timedelta from sqlalchemy import func, extract import calendar -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) analytics_bp = Blueprint('analytics', __name__) @analytics_bp.route('/analytics') @login_required -@require_organization_access() def analytics_dashboard(): """Main analytics dashboard with charts""" # Check if user agent indicates mobile device @@ -29,20 +23,17 @@ def analytics_dashboard(): @analytics_bp.route('/api/analytics/hours-by-day') @login_required -@require_organization_access() def hours_by_day(): """Get hours worked per day for the last 30 days""" - org_id = get_current_organization_id() days = int(request.args.get('days', 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - # Build query based on user permissions (scoped to organization) + # Build query based on user permissions query = db.session.query( func.date(TimeEntry.start_time).label('date'), func.sum(TimeEntry.duration_seconds).label('total_seconds') ).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date @@ -79,10 +70,8 @@ def hours_by_day(): @analytics_bp.route('/api/analytics/hours-by-project') @login_required -@require_organization_access() def hours_by_project(): """Get total hours per project""" - org_id = get_current_organization_id() days = int(request.args.get('days', 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) @@ -91,8 +80,6 @@ def hours_by_project(): Project.name, func.sum(TimeEntry.duration_seconds).label('total_seconds') ).join(TimeEntry).filter( - Project.organization_id == org_id, - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date, @@ -126,13 +113,11 @@ def hours_by_project(): @analytics_bp.route('/api/analytics/hours-by-user') @login_required -@require_organization_access() def hours_by_user(): """Get total hours per user (admin only)""" if not current_user.is_admin: return jsonify({'error': 'Unauthorized'}), 403 - org_id = get_current_organization_id() days = int(request.args.get('days', 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) @@ -141,7 +126,6 @@ def hours_by_user(): User.username, func.sum(TimeEntry.duration_seconds).label('total_seconds') ).join(TimeEntry).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date, @@ -164,10 +148,8 @@ def hours_by_user(): @analytics_bp.route('/api/analytics/hours-by-hour') @login_required -@require_organization_access() def hours_by_hour(): """Get hours worked by hour of day (24-hour format)""" - org_id = get_current_organization_id() days = int(request.args.get('days', 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) @@ -176,7 +158,6 @@ def hours_by_hour(): extract('hour', TimeEntry.start_time).label('hour'), func.sum(TimeEntry.duration_seconds).label('total_seconds') ).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date @@ -208,10 +189,8 @@ def hours_by_hour(): @analytics_bp.route('/api/analytics/billable-vs-nonbillable') @login_required -@require_organization_access() def billable_vs_nonbillable(): """Get billable vs non-billable hours breakdown""" - org_id = get_current_organization_id() days = int(request.args.get('days', 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) @@ -220,7 +199,6 @@ def billable_vs_nonbillable(): TimeEntry.billable, func.sum(TimeEntry.duration_seconds).label('total_seconds') ).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date @@ -254,10 +232,8 @@ def billable_vs_nonbillable(): @analytics_bp.route('/api/analytics/weekly-trends') @login_required -@require_organization_access() def weekly_trends(): """Get weekly trends over the last 12 weeks""" - org_id = get_current_organization_id() weeks = int(request.args.get('weeks', 12)) end_date = datetime.now().date() start_date = end_date - timedelta(weeks=weeks) @@ -266,7 +242,6 @@ def weekly_trends(): func.date_trunc('week', TimeEntry.start_time).label('week'), func.sum(TimeEntry.duration_seconds).label('total_seconds') ).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date @@ -304,10 +279,8 @@ def weekly_trends(): @analytics_bp.route('/api/analytics/project-efficiency') @login_required -@require_organization_access() def project_efficiency(): """Get project efficiency metrics (hours vs billable amount)""" - org_id = get_current_organization_id() days = int(request.args.get('days', 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) @@ -317,8 +290,6 @@ def project_efficiency(): func.sum(TimeEntry.duration_seconds).label('total_seconds'), Project.hourly_rate ).join(TimeEntry).filter( - Project.organization_id == org_id, - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date, @@ -361,7 +332,6 @@ def project_efficiency(): @analytics_bp.route('/api/analytics/today-by-task') @login_required -@require_organization_access() def today_by_task(): """Get today's total hours grouped by task (includes project-level entries without task). @@ -379,8 +349,7 @@ def today_by_task(): else: target_date = datetime.now().date() - # Base query (scoped to organization) - org_id = get_current_organization_id() + # Base query query = db.session.query( TimeEntry.task_id, Task.name.label('task_name'), @@ -392,8 +361,6 @@ def today_by_task(): ).outerjoin( Task, Task.id == TimeEntry.task_id ).filter( - TimeEntry.organization_id == org_id, - Project.organization_id == org_id, TimeEntry.end_time.isnot(None), func.date(TimeEntry.start_time) == target_date ) diff --git a/app/routes/api.py b/app/routes/api.py index 9e345b3..f418c11 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -11,17 +11,11 @@ import json import os import uuid from werkzeug.utils import secure_filename -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) api_bp = Blueprint('api', __name__) @api_bp.route('/api/timer/status') @login_required -@require_organization_access() def timer_status(): """Get current timer status""" active_timer = current_user.active_timer @@ -47,10 +41,8 @@ def timer_status(): @api_bp.route('/api/search') @login_required -@require_organization_access() def search(): """Global search endpoint for projects, tasks, clients, and time entries""" - org_id = get_current_organization_id() query = request.args.get('q', '').strip() limit = request.args.get('limit', 10, type=int) @@ -60,9 +52,9 @@ def search(): results = [] search_pattern = f'%{query}%' - # Search projects (scoped to organization) + # Search projects try: - projects = scoped_query(Project).filter( + projects = Project.query.filter( Project.status == 'active', or_( Project.name.ilike(search_pattern), @@ -83,9 +75,9 @@ def search(): except Exception as e: current_app.logger.error(f"Error searching projects: {e}") - # Search tasks (scoped to organization) + # Search tasks try: - tasks = scoped_query(Task).join(Project).filter( + tasks = Task.query.join(Project).filter( Project.status == 'active', or_( Task.name.ilike(search_pattern), diff --git a/app/routes/auth.py b/app/routes/auth.py index 103f174..857f9da 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -36,28 +36,25 @@ def login(): if request.method == 'POST': try: username = request.form.get('username', '').strip().lower() - password = request.form.get('password', '') current_app.logger.info("POST /login (username=%s) from %s", username or '', request.headers.get('X-Forwarded-For') or request.remote_addr) if not username: - flash(_('Username or email is required'), 'error') + flash(_('Username is required'), 'error') return render_template('auth/login.html') - # Try to find user by username or email - user = User.query.filter( - db.or_(User.username == username, User.email == username) - ).first() - current_app.logger.info("User lookup for '%s': %s", username, 'found' if user else 'not found') - # Normalize admin usernames from config try: admin_usernames = [u.strip().lower() for u in (Config.ADMIN_USERNAMES or [])] except Exception: admin_usernames = ['admin'] + + # Check if user exists + user = User.query.filter_by(username=username).first() + current_app.logger.info("User lookup for '%s': %s", username, 'found' if user else 'not found') if not user: - # Check if self-registration is allowed (passwordless mode) - if Config.ALLOW_SELF_REGISTER and not password: + # Check if self-registration is allowed + if Config.ALLOW_SELF_REGISTER: # Create new user, promote to admin if username is configured as admin role = 'admin' if username in admin_usernames else 'user' user = User(username=username, role=role) @@ -69,19 +66,9 @@ def login(): current_app.logger.info("Created new user '%s'", username) flash(_('Welcome! Your account has been created.'), 'success') else: - flash(_('Invalid credentials'), 'error') + flash(_('User not found. Please contact an administrator.'), 'error') return render_template('auth/login.html') else: - # If user has password set, require password - if user.has_password: - if not password: - flash(_('Password is required'), 'error') - return render_template('auth/login.html') - - if not user.check_password(password): - flash(_('Invalid credentials'), 'error') - return render_template('auth/login.html') - # If existing user matches admin usernames, ensure admin role if username in admin_usernames and user.role != 'admin': user.role = 'admin' @@ -95,16 +82,8 @@ def login(): flash(_('Account is disabled. Please contact an administrator.'), 'error') return render_template('auth/login.html') - # Check for 2FA - if user.totp_enabled: - # Store user info in session for 2FA verification - session['2fa_user_id'] = user.id - session['2fa_remember'] = request.form.get('remember') == 'on' - return redirect(url_for('auth_extended.verify_2fa', next=request.args.get('next'))) - # Log in the user - remember_me = request.form.get('remember') == 'on' - login_user(user, remember=remember_me) + login_user(user, remember=True) user.update_last_login() current_app.logger.info("User '%s' logged in successfully", user.username) diff --git a/app/routes/auth_extended.py b/app/routes/auth_extended.py deleted file mode 100644 index 566b784..0000000 --- a/app/routes/auth_extended.py +++ /dev/null @@ -1,792 +0,0 @@ -"""Extended authentication routes for signup, password reset, 2FA, and invitations""" -from flask import Blueprint, render_template, request, redirect, url_for, flash, session, current_app, jsonify -from flask_login import login_user, logout_user, login_required, current_user -from app import db, limiter -from app.models import User, Organization, Membership, PasswordResetToken, EmailVerificationToken -from app.utils.db import safe_commit -from app.utils.email_service import email_service -from app.utils.jwt_utils import create_token_pair, refresh_access_token, revoke_refresh_token, revoke_all_user_tokens -from app.utils.totp import generate_totp_secret, get_totp_uri, generate_qr_code, verify_totp_token -from app.utils.permissions import get_current_user, organization_admin_required -from flask_babel import gettext as _ -from datetime import datetime - -auth_extended_bp = Blueprint('auth_extended', __name__) - - -# ============================================================================ -# REGISTRATION / SIGNUP -# ============================================================================ - -@auth_extended_bp.route('/signup', methods=['GET', 'POST']) -@limiter.limit("5 per hour", methods=["POST"]) -def signup(): - """User registration with email and password""" - if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - - # Check if self-registration is allowed - if not current_app.config.get('ALLOW_SELF_REGISTER', True): - flash(_('Self-registration is disabled. Please contact an administrator.'), 'error') - return redirect(url_for('auth.login')) - - if request.method == 'POST': - username = request.form.get('username', '').strip().lower() - email = request.form.get('email', '').strip().lower() - password = request.form.get('password', '') - password_confirm = request.form.get('password_confirm', '') - full_name = request.form.get('full_name', '').strip() - - # Validation - errors = [] - - if not username or len(username) < 3: - errors.append(_('Username must be at least 3 characters')) - - if not email or '@' not in email: - errors.append(_('Valid email is required')) - - if not password or len(password) < 8: - errors.append(_('Password must be at least 8 characters')) - - if password != password_confirm: - errors.append(_('Passwords do not match')) - - # Check if username or email already exists - if User.query.filter_by(username=username).first(): - errors.append(_('Username already taken')) - - if User.query.filter_by(email=email).first(): - errors.append(_('Email already registered')) - - if errors: - for error in errors: - flash(error, 'error') - return render_template('auth/signup.html') - - try: - # Create user - user = User(username=username, email=email, full_name=full_name) - - # Try to set password - this will raise ValueError if password doesn't meet requirements - try: - user.set_password(password) - except ValueError as ve: - # Password validation failed - show the specific error - flash(str(ve), 'error') - return render_template('auth/signup.html') - - user.is_active = True - user.email_verified = False - - db.session.add(user) - db.session.flush() # Get user ID - - # Create default organization for the user - org_name = f"{full_name or username}'s Organization" - organization = Organization(name=org_name, contact_email=email) - db.session.add(organization) - db.session.flush() # Get org ID - - # Create membership with owner/admin role - membership = Membership( - user_id=user.id, - organization_id=organization.id, - role='admin', - status='active' - ) - db.session.add(membership) - - if not safe_commit('signup_user', {'username': username}): - raise RuntimeError('Failed to create user') - - # Send email verification - if email_service.is_configured: - verification_token = EmailVerificationToken.create_token(user.id, email) - email_service.send_email_verification(user, verification_token) - flash(_('Account created! Please check your email to verify your address.'), 'success') - else: - # Auto-verify if email not configured - user.email_verified = True - db.session.commit() - flash(_('Account created successfully!'), 'success') - - # Log the user in - login_user(user, remember=True) - - return redirect(url_for('main.dashboard')) - - except ValueError as ve: - # This catches password validation errors that weren't caught in the inner try - db.session.rollback() - current_app.logger.error(f'Signup password validation error: {ve}') - flash(str(ve), 'error') - return render_template('auth/signup.html') - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Signup error: {e}') - flash(_('An error occurred during signup. Please try again.'), 'error') - return render_template('auth/signup.html') - - return render_template('auth/signup.html') - - -# ============================================================================ -# PASSWORD RESET -# ============================================================================ - -@auth_extended_bp.route('/forgot-password', methods=['GET', 'POST']) -@limiter.limit("3 per hour", methods=["POST"]) -def forgot_password(): - """Request password reset""" - if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': - email = request.form.get('email', '').strip().lower() - - if not email: - flash(_('Email is required'), 'error') - return render_template('auth/forgot_password.html') - - # Always show success message (don't reveal if email exists) - flash(_('If an account exists with that email, a password reset link has been sent.'), 'success') - - # Find user and send reset email - user = User.query.filter_by(email=email).first() - - if user and user.is_active and user.has_password: - try: - # Revoke any existing tokens for this user - PasswordResetToken.revoke_user_tokens(user.id) - - # Create new reset token - ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) - reset_token = PasswordResetToken.create_token(user.id, ip_address) - - # Send email - if email_service.is_configured: - email_service.send_password_reset_email(user, reset_token) - current_app.logger.info(f'Password reset email sent to {email}') - else: - current_app.logger.warning(f'Email service not configured, cannot send reset to {email}') - - except Exception as e: - current_app.logger.exception(f'Error sending password reset: {e}') - - return redirect(url_for('auth.login')) - - return render_template('auth/forgot_password.html') - - -@auth_extended_bp.route('/reset-password/', methods=['GET', 'POST']) -@limiter.limit("5 per hour", methods=["POST"]) -def reset_password(token): - """Reset password with token""" - if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - - # Validate token - reset_token = PasswordResetToken.get_valid_token(token) - - if not reset_token: - flash(_('Invalid or expired password reset link'), 'error') - return redirect(url_for('auth_extended.forgot_password')) - - if request.method == 'POST': - password = request.form.get('password', '') - password_confirm = request.form.get('password_confirm', '') - - if not password or len(password) < 8: - flash(_('Password must be at least 8 characters'), 'error') - return render_template('auth/reset_password.html', token=token) - - if password != password_confirm: - flash(_('Passwords do not match'), 'error') - return render_template('auth/reset_password.html', token=token) - - try: - user = reset_token.user - user.set_password(password) - reset_token.mark_as_used() - - # Revoke all existing sessions/tokens for security - revoke_all_user_tokens(user.id) - - db.session.commit() - - flash(_('Password reset successfully! Please log in.'), 'success') - return redirect(url_for('auth.login')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Password reset error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return render_template('auth/reset_password.html', token=token) - - return render_template('auth/reset_password.html', token=token) - - -@auth_extended_bp.route('/verify-email/') -def verify_email(token): - """Verify email address""" - verification_token = EmailVerificationToken.get_valid_token(token) - - if not verification_token: - flash(_('Invalid or expired verification link'), 'error') - return redirect(url_for('auth.login')) - - try: - user = verification_token.user - user.email = verification_token.email - user.email_verified = True - verification_token.mark_as_verified() - - db.session.commit() - - flash(_('Email verified successfully!'), 'success') - - if current_user.is_authenticated: - return redirect(url_for('auth.profile')) - else: - return redirect(url_for('auth.login')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Email verification error: {e}') - flash(_('An error occurred during verification.'), 'error') - return redirect(url_for('auth.login')) - - -# ============================================================================ -# ACCOUNT SETTINGS -# ============================================================================ - -@auth_extended_bp.route('/settings', methods=['GET']) -@login_required -def settings(): - """User account settings page""" - from app.models import RefreshToken - - # Get active sessions/devices - active_tokens = RefreshToken.get_user_tokens(current_user.id) - - return render_template('auth/settings.html', active_tokens=active_tokens) - - -@auth_extended_bp.route('/settings/change-email', methods=['POST']) -@login_required -@limiter.limit("3 per hour") -def change_email(): - """Change user email address""" - new_email = request.form.get('new_email', '').strip().lower() - password = request.form.get('password', '') - - if not new_email or '@' not in new_email: - flash(_('Valid email is required'), 'error') - return redirect(url_for('auth_extended.settings')) - - if not current_user.check_password(password): - flash(_('Incorrect password'), 'error') - return redirect(url_for('auth_extended.settings')) - - # Check if email already in use - if User.query.filter_by(email=new_email).first(): - flash(_('Email already in use'), 'error') - return redirect(url_for('auth_extended.settings')) - - try: - # Send verification email to new address - verification_token = EmailVerificationToken.create_token(current_user.id, new_email) - - if email_service.is_configured: - email_service.send_email_verification(current_user, verification_token) - flash(_('Verification email sent to new address. Please check your email.'), 'success') - else: - # Auto-update if email not configured - current_user.email = new_email - current_user.email_verified = True - db.session.commit() - flash(_('Email updated successfully'), 'success') - - return redirect(url_for('auth_extended.settings')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Email change error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return redirect(url_for('auth_extended.settings')) - - -@auth_extended_bp.route('/settings/change-password', methods=['POST']) -@login_required -@limiter.limit("5 per hour") -def change_password(): - """Change user password""" - current_password = request.form.get('current_password', '') - new_password = request.form.get('new_password', '') - confirm_password = request.form.get('confirm_password', '') - - if not current_user.check_password(current_password): - flash(_('Current password is incorrect'), 'error') - return redirect(url_for('auth_extended.settings')) - - if len(new_password) < 8: - flash(_('New password must be at least 8 characters'), 'error') - return redirect(url_for('auth_extended.settings')) - - if new_password != confirm_password: - flash(_('New passwords do not match'), 'error') - return redirect(url_for('auth_extended.settings')) - - try: - current_user.set_password(new_password) - - # Revoke all other sessions for security - from app.models import RefreshToken - RefreshToken.revoke_user_tokens(current_user.id) - - db.session.commit() - - flash(_('Password changed successfully. Other sessions have been logged out.'), 'success') - return redirect(url_for('auth_extended.settings')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Password change error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return redirect(url_for('auth_extended.settings')) - - -# ============================================================================ -# TWO-FACTOR AUTHENTICATION (2FA) -# ============================================================================ - -@auth_extended_bp.route('/settings/2fa/enable', methods=['GET', 'POST']) -@login_required -def enable_2fa(): - """Enable two-factor authentication""" - if current_user.totp_enabled: - flash(_('2FA is already enabled'), 'info') - return redirect(url_for('auth_extended.settings')) - - if request.method == 'POST': - token = request.form.get('token', '').strip() - - # Get secret from session - secret = session.get('totp_secret') - - if not secret: - flash(_('2FA setup session expired. Please try again.'), 'error') - return redirect(url_for('auth_extended.enable_2fa')) - - # Verify token - if not verify_totp_token(secret, token): - flash(_('Invalid verification code. Please try again.'), 'error') - return render_template('auth/enable_2fa.html', - secret=secret, - qr_code=session.get('totp_qr')) - - try: - # Enable 2FA - current_user.totp_secret = secret - current_user.totp_enabled = True - - # Generate backup codes - backup_codes = current_user.generate_backup_codes() - - db.session.commit() - - # Clear session - session.pop('totp_secret', None) - session.pop('totp_qr', None) - - flash(_('2FA enabled successfully! Save your backup codes.'), 'success') - return render_template('auth/2fa_backup_codes.html', backup_codes=backup_codes) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'2FA enable error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return redirect(url_for('auth_extended.enable_2fa')) - - # Generate new secret and QR code - secret = generate_totp_secret() - totp_uri = get_totp_uri(secret, current_user.email or current_user.username) - qr_code = generate_qr_code(totp_uri) - - # Store in session temporarily - session['totp_secret'] = secret - session['totp_qr'] = qr_code - - return render_template('auth/enable_2fa.html', secret=secret, qr_code=qr_code) - - -@auth_extended_bp.route('/settings/2fa/disable', methods=['POST']) -@login_required -def disable_2fa(): - """Disable two-factor authentication""" - password = request.form.get('password', '') - - if not current_user.check_password(password): - flash(_('Incorrect password'), 'error') - return redirect(url_for('auth_extended.settings')) - - try: - current_user.totp_enabled = False - current_user.totp_secret = None - current_user.backup_codes = None - - db.session.commit() - - flash(_('2FA disabled successfully'), 'success') - return redirect(url_for('auth_extended.settings')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'2FA disable error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return redirect(url_for('auth_extended.settings')) - - -@auth_extended_bp.route('/2fa/verify', methods=['GET', 'POST']) -def verify_2fa(): - """Verify 2FA token during login""" - # Check if user is in 2FA pending state - user_id = session.get('2fa_user_id') - remember = session.get('2fa_remember', False) - - if not user_id: - return redirect(url_for('auth.login')) - - user = User.query.get(user_id) - - if not user or not user.totp_enabled: - session.pop('2fa_user_id', None) - return redirect(url_for('auth.login')) - - if request.method == 'POST': - token = request.form.get('token', '').strip() - use_backup = request.form.get('use_backup') == 'true' - - verified = False - - if use_backup: - verified = user.verify_backup_code(token) - if verified: - flash(_('Backup code used successfully'), 'info') - else: - verified = user.verify_totp(token) - - if verified: - # Clear 2FA session data - session.pop('2fa_user_id', None) - session.pop('2fa_remember', None) - - # Complete login - login_user(user, remember=remember) - user.update_last_login() - - flash(_('Welcome back, %(username)s!', username=user.username), 'success') - - next_page = request.args.get('next') - if not next_page or not next_page.startswith('/'): - next_page = url_for('main.dashboard') - - return redirect(next_page) - else: - flash(_('Invalid verification code'), 'error') - return render_template('auth/verify_2fa.html') - - return render_template('auth/verify_2fa.html') - - -# ============================================================================ -# ORGANIZATION INVITATIONS -# ============================================================================ - -@auth_extended_bp.route('/invite', methods=['POST']) -@login_required -@organization_admin_required -@limiter.limit("10 per hour") -def send_invitation(organization): - """Send organization invitation to user by email""" - email = request.form.get('email', '').strip().lower() - role = request.form.get('role', 'member') - - if not email or '@' not in email: - flash(_('Valid email is required'), 'error') - return redirect(request.referrer or url_for('organizations.members', org_slug=organization.slug)) - - if role not in ['admin', 'member', 'viewer']: - role = 'member' - - # Check if organization has reached user limit - if organization.has_reached_user_limit: - flash(_('Organization has reached its user limit'), 'error') - return redirect(request.referrer or url_for('organizations.members', org_slug=organization.slug)) - - try: - # Check if user already exists - existing_user = User.query.filter_by(email=email).first() - - if existing_user: - # Check if already a member - if Membership.user_is_member(existing_user.id, organization.id): - flash(_('User is already a member of this organization'), 'info') - return redirect(request.referrer or url_for('organizations.members', org_slug=organization.slug)) - - # Create membership with invited status - membership = Membership( - user_id=existing_user.id, - organization_id=organization.id, - role=role, - status='invited', - invited_by=current_user.id - ) - else: - # Create placeholder user for invitation - # Generate username from email - username = email.split('@')[0] - counter = 1 - while User.query.filter_by(username=username).first(): - username = f"{email.split('@')[0]}{counter}" - counter += 1 - - user = User(username=username, email=email) - user.is_active = False # Inactive until they accept - db.session.add(user) - db.session.flush() - - membership = Membership( - user_id=user.id, - organization_id=organization.id, - role=role, - status='invited', - invited_by=current_user.id - ) - - db.session.add(membership) - db.session.commit() - - # Send invitation email - if email_service.is_configured: - email_service.send_invitation_email(current_user, email, organization, membership) - flash(_('Invitation sent to %(email)s', email=email), 'success') - else: - flash(_('Invitation created but email service not configured'), 'warning') - - return redirect(request.referrer or url_for('organizations.members', org_slug=organization.slug)) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Invitation error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return redirect(request.referrer or url_for('organizations.members', org_slug=organization.slug)) - - -@auth_extended_bp.route('/accept-invitation/', methods=['GET', 'POST']) -def accept_invitation(token): - """Accept organization invitation""" - membership = Membership.get_by_invitation_token(token) - - if not membership: - flash(_('Invalid or expired invitation'), 'error') - return redirect(url_for('auth.login')) - - organization = membership.organization - inviter = membership.inviter - - # If user is not active, they need to complete signup - if not membership.user.is_active: - if request.method == 'POST': - password = request.form.get('password', '') - password_confirm = request.form.get('password_confirm', '') - full_name = request.form.get('full_name', '').strip() - - if len(password) < 8: - flash(_('Password must be at least 8 characters'), 'error') - return render_template('auth/accept_invitation.html', - organization=organization, - inviter=inviter, - token=token) - - if password != password_confirm: - flash(_('Passwords do not match'), 'error') - return render_template('auth/accept_invitation.html', - organization=organization, - inviter=inviter, - token=token) - - try: - # Activate user account - user = membership.user - user.set_password(password) - if full_name: - user.full_name = full_name - user.is_active = True - user.email_verified = True # Email verified through invitation - - # Accept membership - membership.accept_invitation() - - db.session.commit() - - # Log them in - login_user(user, remember=True) - - flash(_('Welcome to %(org)s!', org=organization.name), 'success') - return redirect(url_for('main.dashboard')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Accept invitation error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return render_template('auth/accept_invitation.html', - organization=organization, - inviter=inviter, - token=token) - - return render_template('auth/accept_invitation.html', - organization=organization, - inviter=inviter, - token=token) - - # Existing user - just accept the membership - try: - membership.accept_invitation() - db.session.commit() - - if current_user.is_authenticated and current_user.id == membership.user_id: - flash(_('You joined %(org)s!', org=organization.name), 'success') - return redirect(url_for('main.dashboard')) - else: - flash(_('Please log in to access %(org)s', org=organization.name), 'info') - return redirect(url_for('auth.login')) - - except Exception as e: - db.session.rollback() - current_app.logger.exception(f'Accept invitation error: {e}') - flash(_('An error occurred. Please try again.'), 'error') - return redirect(url_for('auth.login')) - - -# ============================================================================ -# API ENDPOINTS (JWT) -# ============================================================================ - -@auth_extended_bp.route('/api/auth/token', methods=['POST']) -@limiter.limit("10 per minute") -def api_login(): - """API login endpoint - returns JWT tokens""" - data = request.get_json() - - if not data: - return jsonify({'error': 'Invalid request'}), 400 - - username = data.get('username', '').strip().lower() - email = data.get('email', '').strip().lower() - password = data.get('password', '') - totp_token = data.get('totp_token', '').strip() - - # Find user by username or email - user = None - if username: - user = User.query.filter_by(username=username).first() - elif email: - user = User.query.filter_by(email=email).first() - - if not user or not user.check_password(password): - return jsonify({'error': 'Invalid credentials'}), 401 - - if not user.is_active: - return jsonify({'error': 'Account is disabled'}), 401 - - # Check 2FA if enabled - if user.totp_enabled: - if not totp_token: - return jsonify({'error': '2FA required', 'requires_2fa': True}), 401 - - if not user.verify_totp(totp_token): - return jsonify({'error': 'Invalid 2FA code'}), 401 - - # Generate tokens - ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) - user_agent = request.headers.get('User-Agent') - device_name = data.get('device_name', 'API Client') - - token_data = create_token_pair( - user_id=user.id, - device_name=device_name, - ip_address=ip_address, - user_agent=user_agent - ) - - user.update_last_login() - - return jsonify({ - 'user': user.to_dict(), - **token_data - }), 200 - - -@auth_extended_bp.route('/api/auth/refresh', methods=['POST']) -@limiter.limit("20 per minute") -def api_refresh(): - """Refresh access token using refresh token""" - data = request.get_json() - - if not data or 'refresh_token' not in data: - return jsonify({'error': 'Refresh token required'}), 400 - - refresh_token = data.get('refresh_token') - - access_token, new_refresh_token = refresh_access_token(refresh_token) - - if not access_token: - return jsonify({'error': 'Invalid or expired refresh token'}), 401 - - return jsonify({ - 'access_token': access_token, - 'refresh_token': new_refresh_token, - 'token_type': 'Bearer', - 'expires_in': 900 - }), 200 - - -@auth_extended_bp.route('/api/auth/logout', methods=['POST']) -def api_logout(): - """API logout - revoke refresh token""" - data = request.get_json() - - if not data or 'refresh_token' not in data: - return jsonify({'error': 'Refresh token required'}), 400 - - refresh_token = data.get('refresh_token') - - revoke_refresh_token(refresh_token) - - return jsonify({'message': 'Logged out successfully'}), 200 - - -@auth_extended_bp.route('/settings/sessions//revoke', methods=['POST']) -@login_required -def revoke_session(token_id): - """Revoke a specific session/device""" - from app.models import RefreshToken - - token = RefreshToken.query.get(token_id) - - if not token or token.user_id != current_user.id: - flash(_('Session not found'), 'error') - return redirect(url_for('auth_extended.settings')) - - try: - token.revoke() - flash(_('Session revoked successfully'), 'success') - except Exception as e: - current_app.logger.exception(f'Session revoke error: {e}') - flash(_('An error occurred'), 'error') - - return redirect(url_for('auth_extended.settings')) - diff --git a/app/routes/billing.py b/app/routes/billing.py deleted file mode 100644 index ed2050a..0000000 --- a/app/routes/billing.py +++ /dev/null @@ -1,593 +0,0 @@ -""" -Billing Routes - -Handles Stripe webhooks, checkout flows, and billing management. -""" - -from flask import Blueprint, request, jsonify, render_template, redirect, url_for, flash, current_app -from flask_login import login_required, current_user -from datetime import datetime -import stripe -import json - -from app import db -from app.models.organization import Organization -from app.models.subscription_event import SubscriptionEvent -from app.models.membership import Membership -from app.utils.stripe_service import stripe_service -from app.utils.email_service import email_service -from app.utils.provisioning_service import provisioning_service - -bp = Blueprint('billing', __name__, url_prefix='/billing') - - -# ======================================== -# Stripe Webhook Handlers -# ======================================== - -@bp.route('/webhooks/stripe', methods=['POST']) -def stripe_webhook(): - """Handle Stripe webhook events. - - This endpoint processes webhook events from Stripe and updates - the database accordingly. It handles subscription lifecycle events, - payment events, and more. - """ - payload = request.data - sig_header = request.headers.get('Stripe-Signature') - - try: - # Verify webhook signature - event = stripe_service.construct_webhook_event(payload, sig_header) - except ValueError as e: - current_app.logger.error(f"Invalid webhook payload: {e}") - return jsonify({'error': 'Invalid payload'}), 400 - except stripe.error.SignatureVerificationError as e: - current_app.logger.error(f"Invalid webhook signature: {e}") - return jsonify({'error': 'Invalid signature'}), 400 - - # Log the event - current_app.logger.info(f"Received Stripe webhook: {event.type}") - - # Route to appropriate handler - handlers = { - 'invoice.paid': handle_invoice_paid, - 'invoice.payment_failed': handle_invoice_payment_failed, - 'invoice.payment_action_required': handle_invoice_payment_action_required, - 'customer.subscription.created': handle_subscription_created, - 'customer.subscription.updated': handle_subscription_updated, - 'customer.subscription.deleted': handle_subscription_deleted, - 'customer.subscription.trial_will_end': handle_subscription_trial_will_end, - } - - handler = handlers.get(event.type) - if handler: - try: - handler(event) - except Exception as e: - current_app.logger.error(f"Error processing webhook {event.type}: {e}", exc_info=True) - return jsonify({'error': 'Processing failed'}), 500 - else: - current_app.logger.info(f"Unhandled webhook event type: {event.type}") - - return jsonify({'status': 'success'}), 200 - - -def handle_invoice_paid(event): - """Handle invoice.paid webhook event. - - This is triggered when an invoice is successfully paid. - We activate the subscription and provision the tenant. - """ - invoice = event.data.object - customer_id = invoice.customer - subscription_id = invoice.subscription - - # Find organization by customer ID - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - current_app.logger.warning(f"Organization not found for customer {customer_id}") - return - - # Clear any billing issues - organization.update_billing_issue(has_issue=False) - organization.stripe_subscription_status = 'active' - - # Update next billing date - if invoice.period_end: - organization.next_billing_date = datetime.fromtimestamp(invoice.period_end) - - # Activate organization if it was suspended - if organization.status == 'suspended': - organization.activate() - - db.session.commit() - - # Log event - subscription_event = SubscriptionEvent( - organization_id=organization.id, - event_type='invoice.paid', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=subscription_id, - stripe_invoice_id=invoice.id, - amount=invoice.amount_paid / 100, - currency=invoice.currency.upper(), - status='paid', - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()) - ) - db.session.add(subscription_event) - db.session.commit() - - # AUTOMATED PROVISIONING: If this is the first payment, provision the organization - # Check if organization needs provisioning (doesn't have any projects yet) - if organization.projects.count() == 0: - current_app.logger.info(f"First payment detected for {organization.name}, triggering provisioning") - - # Find admin user (first member with admin role) - admin_membership = organization.memberships.filter_by(role='admin', status='active').first() - admin_user = admin_membership.user if admin_membership else None - - # Trigger provisioning - try: - provisioning_result = provisioning_service.provision_organization( - organization=organization, - admin_user=admin_user, - trigger='payment' - ) - - if provisioning_result.get('success'): - current_app.logger.info(f"Successfully provisioned {organization.name}: {provisioning_result}") - else: - current_app.logger.error(f"Provisioning failed for {organization.name}: {provisioning_result.get('errors')}") - except Exception as e: - current_app.logger.error(f"Error during provisioning for {organization.name}: {e}", exc_info=True) - - current_app.logger.info(f"Invoice paid for organization {organization.name} ({organization.id})") - - -def handle_invoice_payment_failed(event): - """Handle invoice.payment_failed webhook event. - - This is triggered when a payment attempt fails. - We mark the billing issue and start the dunning sequence. - """ - invoice = event.data.object - customer_id = invoice.customer - subscription_id = invoice.subscription - - # Find organization - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - current_app.logger.warning(f"Organization not found for customer {customer_id}") - return - - # Mark billing issue - organization.update_billing_issue(has_issue=True) - organization.stripe_subscription_status = 'past_due' - db.session.commit() - - # Log event - SubscriptionEvent( - organization_id=organization.id, - event_type='invoice.payment_failed', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=subscription_id, - stripe_invoice_id=invoice.id, - amount=invoice.amount_due / 100, - currency=invoice.currency.upper(), - status='payment_failed', - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()), - notes=f"Payment failed. Attempt count: {invoice.attempt_count}" - ).mark_processed(success=True) - - # Send notification email to admins - _send_payment_failed_notification(organization, invoice) - - current_app.logger.warning(f"Payment failed for organization {organization.name} ({organization.id})") - - -def handle_invoice_payment_action_required(event): - """Handle invoice.payment_action_required webhook event. - - This is triggered when additional authentication is required (e.g., 3D Secure). - """ - invoice = event.data.object - customer_id = invoice.customer - - # Find organization - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - return - - # Log event - SubscriptionEvent( - organization_id=organization.id, - event_type='invoice.payment_action_required', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=invoice.subscription, - stripe_invoice_id=invoice.id, - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()), - notes="Payment requires additional authentication" - ).mark_processed(success=True) - - # Send notification to admins - _send_action_required_notification(organization, invoice) - - -def handle_subscription_created(event): - """Handle customer.subscription.created webhook event.""" - subscription = event.data.object - customer_id = subscription.customer - - # Find organization - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - return - - # Update organization - stripe_service._update_organization_from_subscription(organization, subscription) - - # Log event - SubscriptionEvent( - organization_id=organization.id, - event_type='customer.subscription.created', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=subscription.id, - status=subscription.status, - quantity=subscription.items.data[0].quantity if subscription.items.data else 1, - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()) - ).mark_processed(success=True) - - -def handle_subscription_updated(event): - """Handle customer.subscription.updated webhook event. - - This handles seat changes, status updates, and proration. - """ - subscription = event.data.object - customer_id = subscription.customer - - # Find organization - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - return - - # Get previous values - previous_status = organization.stripe_subscription_status - previous_quantity = organization.subscription_quantity - - # Update organization - stripe_service._update_organization_from_subscription(organization, subscription) - - # Log event - new_quantity = subscription.items.data[0].quantity if subscription.items.data else 1 - - SubscriptionEvent( - organization_id=organization.id, - event_type='customer.subscription.updated', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=subscription.id, - status=subscription.status, - previous_status=previous_status, - quantity=new_quantity, - previous_quantity=previous_quantity, - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()), - notes=f"Status: {previous_status} → {subscription.status}, Seats: {previous_quantity} → {new_quantity}" - ).mark_processed(success=True) - - current_app.logger.info(f"Subscription updated for organization {organization.name}") - - -def handle_subscription_deleted(event): - """Handle customer.subscription.deleted webhook event. - - This is triggered when a subscription is cancelled or expires. - We disable or downgrade the account. - """ - subscription = event.data.object - customer_id = subscription.customer - - # Find organization - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - return - - # Update organization status - organization.stripe_subscription_status = 'canceled' - organization.subscription_ends_at = datetime.utcnow() - organization.status = 'suspended' # Suspend the organization - organization.subscription_plan = 'free' # Downgrade to free - db.session.commit() - - # Log event - SubscriptionEvent( - organization_id=organization.id, - event_type='customer.subscription.deleted', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=subscription.id, - status='canceled', - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()), - notes="Subscription deleted - organization suspended" - ).mark_processed(success=True) - - # Send notification - _send_subscription_cancelled_notification(organization) - - current_app.logger.warning(f"Subscription deleted for organization {organization.name} ({organization.id})") - - -def handle_subscription_trial_will_end(event): - """Handle customer.subscription.trial_will_end webhook event. - - This is triggered 3 days before a trial ends. - """ - subscription = event.data.object - customer_id = subscription.customer - - # Find organization - organization = Organization.query.filter_by(stripe_customer_id=customer_id).first() - if not organization: - return - - # Log event - SubscriptionEvent( - organization_id=organization.id, - event_type='customer.subscription.trial_will_end', - event_id=event.id, - stripe_customer_id=customer_id, - stripe_subscription_id=subscription.id, - processed=True, - processed_at=datetime.utcnow(), - raw_payload=json.dumps(event.to_dict()), - notes=f"Trial ending soon - {organization.trial_days_remaining} days remaining" - ).mark_processed(success=True) - - # Send reminder email - _send_trial_ending_notification(organization) - - -# ======================================== -# Billing Management Views -# ======================================== - -@bp.route('/') -@login_required -def index(): - """Billing dashboard - view subscription, invoices, and payment methods.""" - # Get current organization (you'll need to implement organization context) - # For now, we'll assume you have a way to get the current organization - organization = _get_current_organization() - - if not organization: - flash('No organization found', 'error') - return redirect(url_for('main.dashboard')) - - # Check if user is admin - if not Membership.user_is_admin(current_user.id, organization.id): - flash('Only admins can access billing settings', 'error') - return redirect(url_for('main.dashboard')) - - # Get billing data - subscription_data = None - invoices = [] - upcoming_invoice = None - payment_methods = [] - - if stripe_service.is_configured() and organization.stripe_customer_id: - try: - invoices = stripe_service.get_invoices(organization, limit=10) - upcoming_invoice = stripe_service.get_upcoming_invoice(organization) - payment_methods = stripe_service.get_payment_methods(organization) - except Exception as e: - current_app.logger.error(f"Error fetching billing data: {e}") - flash('Error loading billing data', 'error') - - return render_template( - 'billing/index.html', - organization=organization, - invoices=invoices, - upcoming_invoice=upcoming_invoice, - payment_methods=payment_methods, - stripe_publishable_key=current_app.config.get('STRIPE_PUBLISHABLE_KEY') - ) - - -@bp.route('/subscribe/') -@login_required -def subscribe(plan): - """Start subscription checkout flow.""" - organization = _get_current_organization() - - if not organization: - flash('No organization found', 'error') - return redirect(url_for('main.dashboard')) - - # Check if user is admin - if not Membership.user_is_admin(current_user.id, organization.id): - flash('Only admins can manage subscriptions', 'error') - return redirect(url_for('main.dashboard')) - - # Determine price ID and quantity - if plan == 'single': - price_id = current_app.config.get('STRIPE_SINGLE_USER_PRICE_ID') - quantity = 1 - elif plan == 'team': - price_id = current_app.config.get('STRIPE_TEAM_PRICE_ID') - # Calculate current active users - quantity = organization.member_count or 1 - else: - flash('Invalid subscription plan', 'error') - return redirect(url_for('billing.index')) - - # Create checkout session - try: - session_data = stripe_service.create_checkout_session( - organization=organization, - price_id=price_id, - quantity=quantity, - success_url=url_for('billing.success', _external=True) + '?session_id={CHECKOUT_SESSION_ID}', - cancel_url=url_for('billing.index', _external=True) - ) - - return redirect(session_data['url']) - except Exception as e: - current_app.logger.error(f"Error creating checkout session: {e}") - flash('Error starting checkout process', 'error') - return redirect(url_for('billing.index')) - - -@bp.route('/success') -@login_required -def success(): - """Subscription checkout success page.""" - session_id = request.args.get('session_id') - - flash('Subscription activated successfully!', 'success') - return redirect(url_for('billing.index')) - - -@bp.route('/portal') -@login_required -def portal(): - """Redirect to Stripe Customer Portal for managing subscription.""" - organization = _get_current_organization() - - if not organization: - flash('No organization found', 'error') - return redirect(url_for('main.dashboard')) - - # Check if user is admin - if not Membership.user_is_admin(current_user.id, organization.id): - flash('Only admins can access billing portal', 'error') - return redirect(url_for('main.dashboard')) - - try: - portal_session = stripe_service.create_billing_portal_session( - organization=organization, - return_url=url_for('billing.index', _external=True) - ) - - return redirect(portal_session['url']) - except Exception as e: - current_app.logger.error(f"Error creating portal session: {e}") - flash('Error accessing billing portal', 'error') - return redirect(url_for('billing.index')) - - -# ======================================== -# Helper Functions -# ======================================== - -def _get_current_organization(): - """Get the current user's organization. - - This is a placeholder - you'll need to implement proper organization - context management based on your application's structure. - """ - # Get the user's active memberships - memberships = Membership.get_user_active_memberships(current_user.id) - - if not memberships: - return None - - # Return the first organization (you might want to implement org switching) - return memberships[0].organization - - -def _send_payment_failed_notification(organization, invoice): - """Send payment failed notification to organization admins.""" - admins = organization.get_admins() - - for membership in admins: - if membership.user.email: - try: - # TODO: Implement template-based email for payment failures - # email_service.send_email( - # to_email=membership.user.email, - # subject=f"Payment Failed - {organization.name}", - # body_text=f"Your payment for {organization.name} has failed. Please update your payment method.", - # body_html=None - # ) - pass - except Exception as e: - current_app.logger.error(f"Failed to send payment notification: {e}") - - # Update last email sent timestamp - organization.last_billing_email_sent_at = datetime.utcnow() - db.session.commit() - - -def _send_action_required_notification(organization, invoice): - """Send payment action required notification.""" - admins = organization.get_admins() - - for membership in admins: - if membership.user.email: - try: - # TODO: Implement template-based email for action required - # email_service.send_email( - # to_email=membership.user.email, - # subject=f"Payment Action Required - {organization.name}", - # body_text=f"Action required for your {organization.name} subscription.", - # body_html=None - # ) - pass - except Exception as e: - current_app.logger.error(f"Failed to send action required notification: {e}") - - -def _send_subscription_cancelled_notification(organization): - """Send subscription cancelled notification.""" - admins = organization.get_admins() - - for membership in admins: - if membership.user.email: - try: - # TODO: Implement template-based email for subscription cancelled - # email_service.send_email( - # to_email=membership.user.email, - # subject=f"Subscription Cancelled - {organization.name}", - # body_text=f"Your subscription for {organization.name} has been cancelled.", - # body_html=None - # ) - pass - except Exception as e: - current_app.logger.error(f"Failed to send cancellation notification: {e}") - - -def _send_trial_ending_notification(organization): - """Send trial ending soon notification.""" - admins = organization.get_admins() - - for membership in admins: - if membership.user.email: - try: - # TODO: Implement template-based email for trial ending - # email_service.send_email( - # to_email=membership.user.email, - # subject=f"Your Trial is Ending Soon - {organization.name}", - # body_text=f"Your trial for {organization.name} ends in {organization.trial_days_remaining} days.", - # body_html=None - # ) - pass - except Exception as e: - current_app.logger.error(f"Failed to send trial ending notification: {e}") - diff --git a/app/routes/clients.py b/app/routes/clients.py index e521a2d..27366fd 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -6,23 +6,17 @@ from app.models import Client, Project from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) clients_bp = Blueprint('clients', __name__) @clients_bp.route('/clients') @login_required -@require_organization_access() def list_clients(): """List all clients""" status = request.args.get('status', 'active') search = request.args.get('search', '').strip() - query = scoped_query(Client) + query = Client.query if status == 'active': query = query.filter_by(status='active') elif status == 'inactive': @@ -45,15 +39,12 @@ def list_clients(): @clients_bp.route('/clients/create', methods=['GET', 'POST']) @login_required -@require_organization_access() def create_client(): """Create a new client""" if not current_user.is_admin: flash(_('Only administrators can create clients'), 'error') return redirect(url_for('clients.list_clients')) - org_id = get_current_organization_id() - if request.method == 'POST': name = request.form.get('name', '').strip() description = request.form.get('description', '').strip() @@ -64,11 +55,10 @@ def create_client(): default_hourly_rate = request.form.get('default_hourly_rate', '').strip() try: current_app.logger.info( - "POST /clients/create user=%s name=%s email=%s org_id=%s", + "POST /clients/create user=%s name=%s email=%s", current_user.username, name or '', - email or '', - org_id + email or '' ) except Exception: pass @@ -82,11 +72,11 @@ def create_client(): pass return render_template('clients/create.html') - # Check if client name already exists (within organization) - if scoped_query(Client).filter_by(name=name).first(): - flash('A client with this name already exists in your organization', 'error') + # Check if client name already exists + if Client.query.filter_by(name=name).first(): + flash('A client with this name already exists', 'error') try: - current_app.logger.warning("Validation failed: duplicate client name '%s' in org %s", name, org_id) + current_app.logger.warning("Validation failed: duplicate client name '%s'", name) except Exception: pass return render_template('clients/create.html') @@ -102,10 +92,9 @@ def create_client(): pass return render_template('clients/create.html') - # Create client with organization_id + # Create client client = Client( name=name, - organization_id=org_id, description=description, contact_person=contact_person, email=email, @@ -126,26 +115,24 @@ def create_client(): @clients_bp.route('/clients/') @login_required -@require_organization_access() def view_client(client_id): """View client details and projects""" - client = scoped_query(Client).filter_by(id=client_id).first_or_404() + client = Client.query.get_or_404(client_id) - # Get projects for this client (scoped to organization) - projects = scoped_query(Project).filter_by(client_id=client.id).order_by(Project.name).all() + # Get projects for this client + projects = Project.query.filter_by(client_id=client.id).order_by(Project.name).all() return render_template('clients/view.html', client=client, projects=projects) @clients_bp.route('/clients//edit', methods=['GET', 'POST']) @login_required -@require_organization_access() def edit_client(client_id): """Edit client details""" if not current_user.is_admin: flash('Only administrators can edit clients', 'error') return redirect(url_for('clients.view_client', client_id=client_id)) - client = scoped_query(Client).filter_by(id=client_id).first_or_404() + client = Client.query.get_or_404(client_id) if request.method == 'POST': name = request.form.get('name', '').strip() @@ -161,10 +148,10 @@ def edit_client(client_id): flash('Client name is required', 'error') return render_template('clients/edit.html', client=client) - # Check if client name already exists (excluding current client, within organization) - existing = scoped_query(Client).filter_by(name=name).first() + # Check if client name already exists (excluding current client) + existing = Client.query.filter_by(name=name).first() if existing and existing.id != client.id: - flash('A client with this name already exists in your organization', 'error') + flash('A client with this name already exists', 'error') return render_template('clients/edit.html', client=client) # Validate hourly rate @@ -195,14 +182,13 @@ def edit_client(client_id): @clients_bp.route('/clients//archive', methods=['POST']) @login_required -@require_organization_access() def archive_client(client_id): """Archive a client""" if not current_user.is_admin: flash('Only administrators can archive clients', 'error') return redirect(url_for('clients.view_client', client_id=client_id)) - client = scoped_query(Client).filter_by(id=client_id).first_or_404() + client = Client.query.get_or_404(client_id) if client.status == 'inactive': flash('Client is already inactive', 'info') @@ -214,14 +200,13 @@ def archive_client(client_id): @clients_bp.route('/clients//activate', methods=['POST']) @login_required -@require_organization_access() def activate_client(client_id): """Activate a client""" if not current_user.is_admin: flash('Only administrators can activate clients', 'error') return redirect(url_for('clients.view_client', client_id=client_id)) - client = scoped_query(Client).filter_by(id=client_id).first_or_404() + client = Client.query.get_or_404(client_id) if client.status == 'active': flash('Client is already active', 'info') @@ -233,10 +218,13 @@ def activate_client(client_id): @clients_bp.route('/clients//delete', methods=['POST']) @login_required -@require_organization_access(admin_only=True) def delete_client(client_id): """Delete a client (only if no projects exist)""" - client = scoped_query(Client).filter_by(id=client_id).first_or_404() + if not current_user.is_admin: + flash('Only administrators can delete clients', 'error') + return redirect(url_for('clients.view_client', client_id=client_id)) + + client = Client.query.get_or_404(client_id) # Check if client has projects if client.projects.count() > 0: @@ -254,8 +242,7 @@ def delete_client(client_id): @clients_bp.route('/api/clients') @login_required -@require_organization_access() def api_clients(): """API endpoint to get clients for dropdowns""" - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() + clients = Client.get_active_clients() return {'clients': [{'id': c.id, 'name': c.name, 'default_rate': float(c.default_hourly_rate) if c.default_hourly_rate else None} for c in clients]} diff --git a/app/routes/comments.py b/app/routes/comments.py index d9c548d..e5fe3c5 100644 --- a/app/routes/comments.py +++ b/app/routes/comments.py @@ -4,21 +4,14 @@ from flask_login import login_required, current_user from app import db from app.models import Comment, Project, Task from app.utils.db import safe_commit -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) comments_bp = Blueprint('comments', __name__) @comments_bp.route('/comments/create', methods=['POST']) @login_required -@require_organization_access() def create_comment(): """Create a new comment for a project or task""" try: - org_id = get_current_organization_id() content = request.form.get('content', '').strip() project_id = request.form.get('project_id', type=int) task_id = request.form.get('task_id', type=int) @@ -37,29 +30,28 @@ def create_comment(): flash(_('Comment cannot be associated with both a project and a task'), 'error') return redirect(request.referrer or url_for('main.dashboard')) - # Verify project or task exists (scoped to organization) + # Verify project or task exists if project_id: - target = scoped_query(Project).filter_by(id=project_id).first_or_404() + target = Project.query.get_or_404(project_id) target_type = 'project' else: - target = scoped_query(Task).filter_by(id=task_id).first_or_404() + target = Task.query.get_or_404(task_id) target_type = 'task' project_id = target.project_id # For redirects - # If this is a reply, verify parent comment exists (scoped to organization) + # If this is a reply, verify parent comment exists if parent_id: - parent_comment = scoped_query(Comment).filter_by(id=parent_id).first_or_404() + parent_comment = Comment.query.get_or_404(parent_id) # Verify parent is for the same target if (project_id and parent_comment.project_id != project_id) or \ (task_id and parent_comment.task_id != task_id): flash(_('Invalid parent comment'), 'error') return redirect(request.referrer or url_for('main.dashboard')) - # Create the comment with organization_id + # Create the comment comment = Comment( content=content, user_id=current_user.id, - organization_id=org_id, project_id=project_id if target_type == 'project' else None, task_id=task_id if target_type == 'task' else None, parent_id=parent_id @@ -84,10 +76,9 @@ def create_comment(): @comments_bp.route('/comments//edit', methods=['GET', 'POST']) @login_required -@require_organization_access() def edit_comment(comment_id): """Edit an existing comment""" - comment = scoped_query(Comment).filter_by(id=comment_id).first_or_404() + comment = Comment.query.get_or_404(comment_id) # Check permissions if not comment.can_edit(current_user): @@ -120,10 +111,9 @@ def edit_comment(comment_id): @comments_bp.route('/comments//delete', methods=['POST']) @login_required -@require_organization_access() def delete_comment(comment_id): """Delete a comment""" - comment = scoped_query(Comment).filter_by(id=comment_id).first_or_404() + comment = Comment.query.get_or_404(comment_id) # Check permissions if not comment.can_delete(current_user): @@ -151,7 +141,6 @@ def delete_comment(comment_id): @comments_bp.route('/api/comments') @login_required -@require_organization_access() def list_comments(): """API endpoint to get comments for a project or task""" project_id = request.args.get('project_id', type=int) @@ -166,12 +155,12 @@ def list_comments(): try: if project_id: - # Verify project exists (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id).first_or_404() + # Verify project exists + project = Project.query.get_or_404(project_id) comments = Comment.get_project_comments(project_id, include_replies) else: - # Verify task exists (scoped to organization) - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + # Verify task exists + task = Task.query.get_or_404(task_id) comments = Comment.get_task_comments(task_id, include_replies) return jsonify({ @@ -184,11 +173,10 @@ def list_comments(): @comments_bp.route('/api/comments/') @login_required -@require_organization_access() def get_comment(comment_id): """API endpoint to get a single comment""" try: - comment = scoped_query(Comment).filter_by(id=comment_id).first_or_404() + comment = Comment.query.get_or_404(comment_id) return jsonify({ 'success': True, 'comment': comment.to_dict() @@ -199,14 +187,12 @@ def get_comment(comment_id): @comments_bp.route('/api/comments/recent') @login_required -@require_organization_access() def get_recent_comments(): """API endpoint to get recent comments""" limit = request.args.get('limit', 10, type=int) try: - # Get recent comments scoped to organization - comments = scoped_query(Comment).order_by(Comment.created_at.desc()).limit(limit).all() + comments = Comment.get_recent_comments(limit) return jsonify({ 'success': True, 'comments': [comment.to_dict() for comment in comments] @@ -217,7 +203,6 @@ def get_recent_comments(): @comments_bp.route('/api/comments/user/') @login_required -@require_organization_access() def get_user_comments(user_id): """API endpoint to get comments by a specific user""" limit = request.args.get('limit', type=int) @@ -227,11 +212,7 @@ def get_user_comments(user_id): return jsonify({'error': 'Permission denied'}), 403 try: - # Get user comments scoped to organization - query = scoped_query(Comment).filter_by(user_id=user_id).order_by(Comment.created_at.desc()) - if limit: - query = query.limit(limit) - comments = query.all() + comments = Comment.get_user_comments(user_id, limit) return jsonify({ 'success': True, 'comments': [comment.to_dict() for comment in comments] diff --git a/app/routes/gdpr.py b/app/routes/gdpr.py deleted file mode 100644 index e05854c..0000000 --- a/app/routes/gdpr.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -GDPR Routes - Data Export and Deletion -""" - -from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app, jsonify -from flask_login import login_required, current_user -from app import db, limiter -from app.models import Organization -from app.utils.gdpr import GDPRExporter, GDPRDeleter -from app.utils.tenancy import get_current_organization_id, require_organization_access -from flask_babel import gettext as _ -import json -import io -from datetime import datetime - -gdpr_bp = Blueprint('gdpr', __name__, url_prefix='/gdpr') - - -@gdpr_bp.route('/export', methods=['GET', 'POST']) -@login_required -@limiter.limit("5 per hour") -@require_organization_access() -def export_data(): - """Export organization data for GDPR compliance""" - org_id = get_current_organization_id() - - if not org_id: - flash(_('No organization selected.'), 'error') - return redirect(url_for('main.dashboard')) - - organization = Organization.query.get(org_id) - - # Check if user is an admin of the organization - if not organization.is_admin(current_user.id): - flash(_('You must be an organization admin to export data.'), 'error') - return redirect(url_for('main.dashboard')) - - if request.method == 'GET': - return render_template('gdpr/export.html', organization=organization) - - # POST - perform export - export_format = request.form.get('format', 'json') - - try: - data = GDPRExporter.export_organization_data(org_id, format=export_format) - - if export_format == 'json': - # Create JSON file - buffer = io.BytesIO() - buffer.write(json.dumps(data, indent=2).encode('utf-8')) - buffer.seek(0) - - filename = f"gdpr_export_{organization.slug}_{datetime.utcnow().strftime('%Y%m%d')}.json" - - return send_file( - buffer, - as_attachment=True, - download_name=filename, - mimetype='application/json' - ) - else: - # CSV format - buffer = io.BytesIO() - buffer.write(data.encode('utf-8')) - buffer.seek(0) - - filename = f"gdpr_export_{organization.slug}_{datetime.utcnow().strftime('%Y%m%d')}.csv" - - return send_file( - buffer, - as_attachment=True, - download_name=filename, - mimetype='text/csv' - ) - - except Exception as e: - current_app.logger.error(f"GDPR export failed: {e}") - flash(_('Failed to export data. Please try again.'), 'error') - return redirect(url_for('gdpr.export_data')) - - -@gdpr_bp.route('/export/user', methods=['GET', 'POST']) -@login_required -@limiter.limit("5 per hour") -def export_user_data(): - """Export current user's data for GDPR compliance""" - if request.method == 'GET': - return render_template('gdpr/export_user.html') - - # POST - perform export - export_format = request.form.get('format', 'json') - - try: - data = GDPRExporter.export_user_data(current_user.id, format=export_format) - - if export_format == 'json': - buffer = io.BytesIO() - buffer.write(json.dumps(data, indent=2).encode('utf-8')) - buffer.seek(0) - - filename = f"user_data_export_{current_user.username}_{datetime.utcnow().strftime('%Y%m%d')}.json" - - return send_file( - buffer, - as_attachment=True, - download_name=filename, - mimetype='application/json' - ) - else: - buffer = io.BytesIO() - buffer.write(data.encode('utf-8')) - buffer.seek(0) - - filename = f"user_data_export_{current_user.username}_{datetime.utcnow().strftime('%Y%m%d')}.csv" - - return send_file( - buffer, - as_attachment=True, - download_name=filename, - mimetype='text/csv' - ) - - except Exception as e: - current_app.logger.error(f"User data export failed: {e}") - flash(_('Failed to export data. Please try again.'), 'error') - return redirect(url_for('gdpr.export_user_data')) - - -@gdpr_bp.route('/delete/request', methods=['GET', 'POST']) -@login_required -@limiter.limit("3 per hour") -@require_organization_access() -def request_deletion(): - """Request deletion of organization data""" - org_id = get_current_organization_id() - - if not org_id: - flash(_('No organization selected.'), 'error') - return redirect(url_for('main.dashboard')) - - organization = Organization.query.get(org_id) - - # Check if user is an admin - if not organization.is_admin(current_user.id): - flash(_('You must be an organization admin to request deletion.'), 'error') - return redirect(url_for('main.dashboard')) - - if request.method == 'GET': - grace_days = current_app.config.get('GDPR_DELETION_DELAY_DAYS', 30) - return render_template('gdpr/delete_request.html', - organization=organization, - grace_days=grace_days) - - # POST - request deletion - confirmation = request.form.get('confirmation', '') - - if confirmation != organization.name: - flash(_('Organization name confirmation does not match.'), 'error') - return redirect(url_for('gdpr.request_deletion')) - - try: - result = GDPRDeleter.request_organization_deletion(org_id, current_user.id) - - flash( - _('Organization deletion has been scheduled for %(date)s. You can cancel this request until that date.', - date=result['deletion_scheduled_for']), - 'warning' - ) - - return redirect(url_for('main.dashboard')) - - except PermissionError: - flash(_('You do not have permission to request deletion.'), 'error') - return redirect(url_for('main.dashboard')) - except Exception as e: - current_app.logger.error(f"Deletion request failed: {e}") - flash(_('Failed to request deletion. Please try again.'), 'error') - return redirect(url_for('gdpr.request_deletion')) - - -@gdpr_bp.route('/delete/cancel', methods=['POST']) -@login_required -@limiter.limit("5 per hour") -@require_organization_access() -def cancel_deletion(): - """Cancel a pending deletion request""" - org_id = get_current_organization_id() - - if not org_id: - flash(_('No organization selected.'), 'error') - return redirect(url_for('main.dashboard')) - - try: - GDPRDeleter.cancel_organization_deletion(org_id, current_user.id) - flash(_('Organization deletion request has been cancelled.'), 'success') - - except PermissionError: - flash(_('You do not have permission to cancel deletion.'), 'error') - except ValueError as e: - flash(str(e), 'error') - except Exception as e: - current_app.logger.error(f"Deletion cancellation failed: {e}") - flash(_('Failed to cancel deletion. Please try again.'), 'error') - - return redirect(url_for('main.dashboard')) - - -@gdpr_bp.route('/delete/user', methods=['GET', 'POST']) -@login_required -@limiter.limit("2 per hour") -def delete_user_account(): - """Delete current user's account""" - if request.method == 'GET': - return render_template('gdpr/delete_user.html') - - # POST - delete account - password = request.form.get('password', '') - confirmation = request.form.get('confirmation', '') - - # Verify password - if not current_user.check_password(password): - flash(_('Incorrect password.'), 'error') - return redirect(url_for('gdpr.delete_user_account')) - - # Verify confirmation - if confirmation != current_user.username: - flash(_('Username confirmation does not match.'), 'error') - return redirect(url_for('gdpr.delete_user_account')) - - try: - user_id = current_user.id - - # Log out before deleting - from flask_login import logout_user - logout_user() - - # Delete user data - GDPRDeleter.delete_user_data(user_id) - - flash(_('Your account has been deleted.'), 'info') - return redirect(url_for('auth.login')) - - except Exception as e: - current_app.logger.error(f"User deletion failed: {e}") - flash(_('Failed to delete account. Please contact support.'), 'error') - return redirect(url_for('main.dashboard')) - diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 72ddc60..ec95114 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -9,24 +9,18 @@ import io import csv import json from app.utils.db import safe_commit -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) invoices_bp = Blueprint('invoices', __name__) @invoices_bp.route('/invoices') @login_required -@require_organization_access() def list_invoices(): """List all invoices""" - # Get invoices scoped to organization (scope by user unless admin) + # Get invoices (scope by user unless admin) if current_user.is_admin: - invoices = scoped_query(Invoice).order_by(Invoice.created_at.desc()).all() + invoices = Invoice.query.order_by(Invoice.created_at.desc()).all() else: - invoices = scoped_query(Invoice).filter_by(created_by=current_user.id).order_by(Invoice.created_at.desc()).all() + invoices = Invoice.query.filter_by(created_by=current_user.id).order_by(Invoice.created_at.desc()).all() # Get summary statistics total_invoices = len(invoices) @@ -52,11 +46,8 @@ def list_invoices(): @invoices_bp.route('/invoices/create', methods=['GET', 'POST']) @login_required -@require_organization_access() def create_invoice(): """Create a new invoice""" - org_id = get_current_organization_id() - if request.method == 'POST': # Get form data project_id = request.form.get('project_id', type=int) @@ -85,19 +76,18 @@ def create_invoice(): flash('Invalid tax rate format', 'error') return render_template('invoices/create.html') - # Get project (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id).first() + # Get project + project = Project.query.get(project_id) if not project: - flash('Selected project not found in your organization', 'error') + flash('Selected project not found', 'error') return render_template('invoices/create.html') - # Generate invoice number (scoped to organization) - invoice_number = Invoice.generate_invoice_number(org_id) + # Generate invoice number + invoice_number = Invoice.generate_invoice_number() - # Create invoice with organization_id + # Create invoice invoice = Invoice( invoice_number=invoice_number, - organization_id=org_id, project_id=project_id, client_name=client_name, due_date=due_date, @@ -118,8 +108,8 @@ def create_invoice(): flash(f'Invoice {invoice_number} created successfully', 'success') return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) - # GET request - show form (scoped to organization) - projects = scoped_query(Project).filter_by(status='active', billable=True).order_by(Project.name).all() + # GET request - show form + projects = Project.query.filter_by(status='active', billable=True).order_by(Project.name).all() settings = Settings.get_settings() # Set default due date to 30 days from now @@ -132,10 +122,9 @@ def create_invoice(): @invoices_bp.route('/invoices/') @login_required -@require_organization_access() def view_invoice(invoice_id): """View invoice details""" - invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + invoice = Invoice.query.get_or_404(invoice_id) # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: @@ -146,10 +135,9 @@ def view_invoice(invoice_id): @invoices_bp.route('/invoices//edit', methods=['GET', 'POST']) @login_required -@require_organization_access() def edit_invoice(invoice_id): """Edit invoice""" - invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + invoice = Invoice.query.get_or_404(invoice_id) # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: @@ -197,21 +185,20 @@ def edit_invoice(invoice_id): invoice.calculate_totals() if not safe_commit('edit_invoice', {'invoice_id': invoice.id}): flash('Could not update invoice due to a database error. Please check server logs.', 'error') - return render_template('invoices/edit.html', invoice=invoice, projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all()) + return render_template('invoices/edit.html', invoice=invoice, projects=Project.query.filter_by(status='active').order_by(Project.name).all()) flash('Invoice updated successfully', 'success') return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) - # GET request - show edit form (scoped to organization) - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # GET request - show edit form + projects = Project.query.filter_by(status='active').order_by(Project.name).all() return render_template('invoices/edit.html', invoice=invoice, projects=projects) @invoices_bp.route('/invoices//status', methods=['POST']) @login_required -@require_organization_access() def update_invoice_status(invoice_id): """Update invoice status""" - invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + invoice = Invoice.query.get_or_404(invoice_id) # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: @@ -237,10 +224,9 @@ def update_invoice_status(invoice_id): @invoices_bp.route('/invoices//payment', methods=['GET', 'POST']) @login_required -@require_organization_access() def record_payment(invoice_id): """Record payment for invoice""" - invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + invoice = Invoice.query.get_or_404(invoice_id) # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: @@ -336,7 +322,7 @@ def generate_from_time(invoice_id): invoice.items.delete() # Group time entries by task/project and create invoice items - time_entries = scoped_query(TimeEntry).filter(TimeEntry.id.in_(selected_entries)).all() + time_entries = TimeEntry.query.filter(TimeEntry.id.in_(selected_entries)).all() # Group by task (if available) or project grouped_entries = {} @@ -386,7 +372,7 @@ def generate_from_time(invoice_id): # GET request - show time entry selection # Get unbilled time entries for this project - time_entries = scoped_query(TimeEntry).filter( + time_entries = TimeEntry.query.filter( TimeEntry.project_id == invoice.project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True @@ -424,10 +410,9 @@ def generate_from_time(invoice_id): @invoices_bp.route('/invoices//export/csv') @login_required -@require_organization_access() def export_invoice_csv(invoice_id): """Export invoice as CSV""" - invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + invoice = Invoice.query.get_or_404(invoice_id) # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: @@ -475,10 +460,9 @@ def export_invoice_csv(invoice_id): @invoices_bp.route('/invoices//export/pdf') @login_required -@require_organization_access() def export_invoice_pdf(invoice_id): """Export invoice as PDF""" - invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + 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 redirect(request.referrer or url_for('invoices.list_invoices')) @@ -513,24 +497,21 @@ def export_invoice_pdf(invoice_id): @invoices_bp.route('/invoices//duplicate') @login_required -@require_organization_access() def duplicate_invoice(invoice_id): """Duplicate an existing invoice""" - org_id = get_current_organization_id() - original_invoice = scoped_query(Invoice).filter_by(id=invoice_id).first_or_404() + original_invoice = Invoice.query.get_or_404(invoice_id) # Check access permissions if not current_user.is_admin and original_invoice.created_by != current_user.id: flash('You do not have permission to duplicate this invoice', 'error') return redirect(url_for('invoices.list_invoices')) - # Generate new invoice number (scoped to organization) - new_invoice_number = Invoice.generate_invoice_number(org_id) + # Generate new invoice number + new_invoice_number = Invoice.generate_invoice_number() - # Create new invoice with organization_id + # Create new invoice new_invoice = Invoice( invoice_number=new_invoice_number, - organization_id=org_id, project_id=original_invoice.project_id, client_name=original_invoice.client_name, client_email=original_invoice.client_email, diff --git a/app/routes/main.py b/app/routes/main.py index dc5af9b..6dd62bb 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -9,34 +9,12 @@ from sqlalchemy import text from flask import make_response, current_app import json import os -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) main_bp = Blueprint('main', __name__) @main_bp.route('/') -def index(): - """Landing page or dashboard based on auth status""" - if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - return render_template('marketing/landing.html') - -@main_bp.route('/pricing') -def pricing(): - """Pricing page redirect to landing""" - return redirect(url_for('main.index', _anchor='pricing')) - -@main_bp.route('/faq') -def faq(): - """FAQ page""" - return render_template('marketing/faq.html') - @main_bp.route('/dashboard') @login_required -@require_organization_access() def dashboard(): """Main dashboard showing active timer and recent entries""" # Get user's active timer @@ -45,8 +23,8 @@ def dashboard(): # Get recent entries for the user recent_entries = current_user.get_recent_entries(limit=10) - # Get active projects for timer dropdown (scoped to organization) - active_projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get active projects for timer dropdown + active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() # Get user statistics today = datetime.utcnow().date() @@ -150,7 +128,6 @@ def set_language(): @main_bp.route('/search') @login_required -@require_organization_access() def search(): """Search time entries""" query = request.args.get('q', '').strip() @@ -159,9 +136,9 @@ def search(): if not query: return redirect(url_for('main.dashboard')) - # Search in time entries (scoped to organization) + # Search in time entries from sqlalchemy import or_ - entries = scoped_query(TimeEntry).filter( + entries = TimeEntry.query.filter( TimeEntry.user_id == current_user.id, TimeEntry.end_time.isnot(None), or_( diff --git a/app/routes/onboarding.py b/app/routes/onboarding.py deleted file mode 100644 index 92f2667..0000000 --- a/app/routes/onboarding.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Onboarding Routes - -Handles onboarding checklist display, task completion tracking, and -user guidance for new organizations. -""" - -from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for -from flask_login import login_required, current_user -from app import db -from app.models.onboarding_checklist import OnboardingChecklist -from app.models.membership import Membership -from app.utils.tenancy import require_organization_access, get_current_organization - - -bp = Blueprint('onboarding', __name__, url_prefix='/onboarding') - - -@bp.route('/') -@bp.route('/checklist') -@login_required -@require_organization_access() -def checklist(): - """Display onboarding checklist for current organization.""" - organization = get_current_organization() - - if not organization: - flash('No organization found', 'error') - return redirect(url_for('main.dashboard')) - - # Get or create checklist - checklist_data = OnboardingChecklist.get_or_create(organization.id) - - return render_template( - 'onboarding/checklist.html', - organization=organization, - checklist=checklist_data, - tasks=checklist_data.get_tasks_with_status() - ) - - -@bp.route('/api/checklist', methods=['GET']) -@login_required -@require_organization_access() -def get_checklist(): - """Get onboarding checklist data (API endpoint).""" - organization = get_current_organization() - - if not organization: - return jsonify({'error': 'No organization found'}), 404 - - # Get or create checklist - checklist_data = OnboardingChecklist.get_or_create(organization.id) - - return jsonify(checklist_data.to_dict(include_tasks=True)) - - -@bp.route('/api/checklist/complete/', methods=['POST']) -@login_required -@require_organization_access() -def complete_task(task_key): - """Mark a task as complete (API endpoint). - - Args: - task_key: Key of the task to complete - """ - organization = get_current_organization() - - if not organization: - return jsonify({'error': 'No organization found'}), 404 - - # Get checklist - checklist_data = OnboardingChecklist.get_or_create(organization.id) - - # Mark task complete - success = checklist_data.mark_task_complete(task_key) - - if not success: - return jsonify({'error': f'Invalid task key: {task_key}'}), 400 - - return jsonify({ - 'success': True, - 'task_key': task_key, - 'completion_percentage': checklist_data.completion_percentage, - 'is_complete': checklist_data.is_complete, - 'next_task': checklist_data.get_next_task() - }) - - -@bp.route('/api/checklist/dismiss', methods=['POST']) -@login_required -@require_organization_access() -def dismiss_checklist(): - """Dismiss the onboarding checklist.""" - organization = get_current_organization() - - if not organization: - return jsonify({'error': 'No organization found'}), 404 - - # Check if user is admin - if not Membership.user_is_admin(current_user.id, organization.id): - return jsonify({'error': 'Only admins can dismiss the checklist'}), 403 - - # Get checklist and dismiss - checklist_data = OnboardingChecklist.get_or_create(organization.id) - checklist_data.dismiss() - - return jsonify({ - 'success': True, - 'dismissed': True - }) - - -@bp.route('/api/checklist/restore', methods=['POST']) -@login_required -@require_organization_access() -def restore_checklist(): - """Restore a dismissed onboarding checklist.""" - organization = get_current_organization() - - if not organization: - return jsonify({'error': 'No organization found'}), 404 - - # Check if user is admin - if not Membership.user_is_admin(current_user.id, organization.id): - return jsonify({'error': 'Only admins can restore the checklist'}), 403 - - # Get checklist and restore - checklist_data = OnboardingChecklist.get_or_create(organization.id) - checklist_data.undismiss() - - return jsonify({ - 'success': True, - 'dismissed': False - }) - - -@bp.route('/guide') -@login_required -@require_organization_access() -def guide(): - """Display comprehensive onboarding guide.""" - organization = get_current_organization() - - if not organization: - flash('No organization found', 'error') - return redirect(url_for('main.dashboard')) - - return render_template( - 'onboarding/guide.html', - organization=organization - ) - - -@bp.route('/welcome') -@login_required -def welcome(): - """Welcome page for newly created organizations.""" - # Get user's first active organization (newly created) - memberships = Membership.get_user_active_memberships(current_user.id) - - if not memberships: - flash('No organization found', 'error') - return redirect(url_for('main.dashboard')) - - organization = memberships[0].organization - - # Get checklist - checklist_data = OnboardingChecklist.get_or_create(organization.id) - - return render_template( - 'onboarding/welcome.html', - organization=organization, - checklist=checklist_data - ) - diff --git a/app/routes/organizations.py b/app/routes/organizations.py deleted file mode 100644 index 9a0dd06..0000000 --- a/app/routes/organizations.py +++ /dev/null @@ -1,403 +0,0 @@ -""" -Routes for organization management. - -Handles: -- Organization CRUD operations -- Membership management -- Organization switching -- User invitations -""" - -from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, session -from flask_login import login_required, current_user -from sqlalchemy.exc import IntegrityError -from app import db -from app.models import Organization, Membership, User -from app.utils.tenancy import ( - get_current_organization, get_current_organization_id, - switch_organization, require_organization_access, - get_user_organizations, user_is_organization_admin -) -from app.utils.seat_sync import seat_sync_service, check_can_add_member -from app.utils.provisioning_service import provisioning_service - -organizations_bp = Blueprint('organizations', __name__, url_prefix='/organizations') - - -@organizations_bp.route('/') -@login_required -def index(): - """List all organizations the current user belongs to.""" - memberships = Membership.get_user_active_memberships(current_user.id) - current_org_id = get_current_organization_id() - - organizations = [ - { - 'id': m.organization.id, - 'name': m.organization.name, - 'slug': m.organization.slug, - 'role': m.role, - 'member_count': m.organization.member_count, - 'project_count': m.organization.project_count, - 'is_current': m.organization.id == current_org_id, - 'is_admin': m.is_admin - } - for m in memberships - ] - - return render_template('organizations/index.html', organizations=organizations) - - -@organizations_bp.route('/') -@login_required -@require_organization_access() -def detail(org_id): - """View organization details.""" - org = Organization.query.get_or_404(org_id) - - # Check if user is admin of this org - is_admin = user_is_organization_admin(current_user.id, org_id) - - # Get membership info - membership = Membership.find_membership(current_user.id, org_id) - - # Get members if admin - members = None - if is_admin: - members = org.get_members() - - return render_template( - 'organizations/detail.html', - organization=org, - membership=membership, - members=members, - is_admin=is_admin - ) - - -@organizations_bp.route('/new', methods=['GET', 'POST']) -@login_required -def create(): - """Create a new organization with optional trial provisioning.""" - if request.method == 'POST': - try: - # Check if user wants to start with a trial - start_trial = request.form.get('start_trial', 'true').lower() == 'true' - - org = Organization( - name=request.form['name'], - slug=request.form.get('slug'), # Optional, will be auto-generated - contact_email=request.form.get('contact_email', current_user.email), - subscription_plan='trial' if start_trial else 'free' - ) - - db.session.add(org) - db.session.flush() # Get org.id - - # Create membership for creator as admin - membership = Membership( - user_id=current_user.id, - organization_id=org.id, - role='admin', - status='active' - ) - - db.session.add(membership) - db.session.commit() - - # AUTOMATED PROVISIONING: If trial, provision immediately - if start_trial: - try: - provisioning_result = provisioning_service.provision_trial_organization( - organization=org, - admin_user=current_user - ) - - if provisioning_result.get('success'): - flash(f'🎉 Organization "{org.name}" created successfully with a free trial!', 'success') - # Redirect to welcome page for new trial users - return redirect(url_for('onboarding.welcome')) - else: - flash(f'Organization "{org.name}" created, but provisioning had issues.', 'warning') - except Exception as prov_error: - # Log the provisioning error but don't fail the org creation - from flask import current_app - current_app.logger.error(f"Provisioning error for {org.name}: {prov_error}") - flash(f'Organization "{org.name}" created successfully!', 'success') - else: - flash(f'Organization "{org.name}" created successfully!', 'success') - - return redirect(url_for('organizations.detail', org_id=org.id)) - - except IntegrityError as e: - db.session.rollback() - flash('An organization with that name or slug already exists.', 'danger') - except Exception as e: - db.session.rollback() - flash(f'Error creating organization: {str(e)}', 'danger') - - return render_template('organizations/create.html') - - -@organizations_bp.route('//edit', methods=['GET', 'POST']) -@login_required -@require_organization_access(admin_only=True) -def edit(org_id): - """Edit organization settings (admin only).""" - org = Organization.query.get_or_404(org_id) - - if request.method == 'POST': - try: - org.name = request.form['name'] - org.contact_email = request.form.get('contact_email') - org.contact_phone = request.form.get('contact_phone') - org.billing_email = request.form.get('billing_email') - org.timezone = request.form.get('timezone', 'UTC') - org.currency = request.form.get('currency', 'EUR') - - db.session.commit() - - flash('Organization settings updated successfully!', 'success') - return redirect(url_for('organizations.detail', org_id=org_id)) - - except Exception as e: - db.session.rollback() - flash(f'Error updating organization: {str(e)}', 'danger') - - return render_template('organizations/edit.html', organization=org) - - -@organizations_bp.route('//switch', methods=['POST']) -@login_required -def switch(org_id): - """Switch to a different organization.""" - try: - org = switch_organization(org_id) - flash(f'Switched to organization: {org.name}', 'success') - return redirect(request.referrer or url_for('main.index')) - - except PermissionError: - flash('You do not have access to that organization.', 'danger') - return redirect(url_for('organizations.index')) - except ValueError as e: - flash(str(e), 'danger') - return redirect(url_for('organizations.index')) - - -@organizations_bp.route('//members') -@login_required -@require_organization_access(admin_only=True) -def members(org_id): - """List organization members (admin only).""" - org = Organization.query.get_or_404(org_id) - members = org.get_members() - - return render_template( - 'organizations/members.html', - organization=org, - members=members - ) - - -@organizations_bp.route('//members/invite', methods=['GET', 'POST']) -@login_required -@require_organization_access(admin_only=True) -def invite_member(org_id): - """Invite a user to the organization (admin only).""" - org = Organization.query.get_or_404(org_id) - - if request.method == 'POST': - try: - email = request.form['email'] - role = request.form.get('role', 'member') - - # Check if organization can add more members - seat_check = check_can_add_member(org_id) - if not seat_check['can_add']: - flash(seat_check['message'], 'warning') - return redirect(url_for('organizations.members', org_id=org_id)) - - # Find or create user - user = User.query.filter_by(email=email).first() - - if not user: - # Create new user with invitation - username = email.split('@')[0] - # Ensure unique username - base_username = username - counter = 1 - while User.query.filter_by(username=username).first(): - username = f"{base_username}{counter}" - counter += 1 - - user = User(username=username, email=email, role='user') - user.is_active = False # Inactive until they accept - db.session.add(user) - db.session.flush() - - # Check if already a member - existing = Membership.find_membership(user.id, org_id) - if existing and existing.status == 'active': - flash('User is already a member of this organization.', 'warning') - return redirect(url_for('organizations.members', org_id=org_id)) - - # Create membership with invitation - membership = Membership( - user_id=user.id, - organization_id=org_id, - role=role, - status='invited', - invited_by=current_user.id - ) - - db.session.add(membership) - db.session.commit() - - # Note: Seat sync will happen when invitation is accepted - # No need to sync here since invited members don't count until active - - # TODO: Send invitation email with token - # send_invitation_email(user.email, membership.invitation_token) - - flash(f'Invitation sent to {email}!', 'success') - return redirect(url_for('organizations.members', org_id=org_id)) - - except Exception as e: - db.session.rollback() - flash(f'Error inviting user: {str(e)}', 'danger') - - return render_template('organizations/invite.html', organization=org) - - -@organizations_bp.route('//members//role', methods=['POST']) -@login_required -@require_organization_access(admin_only=True) -def change_member_role(org_id, user_id): - """Change a member's role (admin only).""" - org = Organization.query.get_or_404(org_id) - membership = Membership.find_membership(user_id, org_id) - - if not membership: - flash('Member not found.', 'danger') - return redirect(url_for('organizations.members', org_id=org_id)) - - # Don't allow changing own role - if user_id == current_user.id: - flash('You cannot change your own role.', 'warning') - return redirect(url_for('organizations.members', org_id=org_id)) - - try: - new_role = request.form['role'] - membership.change_role(new_role) - - flash(f'Role updated to {new_role}.', 'success') - except Exception as e: - flash(f'Error updating role: {str(e)}', 'danger') - - return redirect(url_for('organizations.members', org_id=org_id)) - - -@organizations_bp.route('//members//remove', methods=['POST']) -@login_required -@require_organization_access(admin_only=True) -def remove_member(org_id, user_id): - """Remove a member from the organization (admin only).""" - org = Organization.query.get_or_404(org_id) - membership = Membership.find_membership(user_id, org_id) - - if not membership: - flash('Member not found.', 'danger') - return redirect(url_for('organizations.members', org_id=org_id)) - - # Don't allow removing self - if user_id == current_user.id: - flash('You cannot remove yourself from the organization.', 'warning') - return redirect(url_for('organizations.members', org_id=org_id)) - - # Check if this is the last admin - if membership.is_admin and org.admin_count <= 1: - flash('Cannot remove the last admin from the organization.', 'danger') - return redirect(url_for('organizations.members', org_id=org_id)) - - try: - user = membership.user - membership.remove() - - # Sync seats with Stripe - sync_result = seat_sync_service.on_member_removed(org, user) - if not sync_result['success']: - flash(f"Member removed but seat sync failed: {sync_result['message']}", 'warning') - else: - flash('Member removed from organization.', 'success') - except Exception as e: - flash(f'Error removing member: {str(e)}', 'danger') - - return redirect(url_for('organizations.members', org_id=org_id)) - - -# ======================================== -# API Endpoints -# ======================================== - -@organizations_bp.route('/api/list', methods=['GET']) -@login_required -def api_list(): - """API: List user's organizations.""" - memberships = Membership.get_user_active_memberships(current_user.id) - current_org_id = get_current_organization_id() - - organizations = [ - { - **m.organization.to_dict(include_stats=True), - 'role': m.role, - 'is_current': m.organization.id == current_org_id - } - for m in memberships - ] - - return jsonify({'organizations': organizations}) - - -@organizations_bp.route('/api/', methods=['GET']) -@login_required -@require_organization_access() -def api_detail(org_id): - """API: Get organization details.""" - org = Organization.query.get_or_404(org_id) - membership = Membership.find_membership(current_user.id, org_id) - - data = org.to_dict(include_stats=True) - data['current_user_role'] = membership.role if membership else None - - return jsonify(data) - - -@organizations_bp.route('/api//switch', methods=['POST']) -@login_required -def api_switch(org_id): - """API: Switch to different organization.""" - try: - org = switch_organization(org_id) - return jsonify({ - 'success': True, - 'organization': org.to_dict() - }) - except (PermissionError, ValueError) as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 403 - - -@organizations_bp.route('/api//members', methods=['GET']) -@login_required -@require_organization_access(admin_only=True) -def api_members(org_id): - """API: List organization members.""" - org = Organization.query.get_or_404(org_id) - members = org.get_members() - - return jsonify({ - 'members': [m.to_dict(include_user=True) for m in members] - }) - diff --git a/app/routes/projects.py b/app/routes/projects.py index a3b1564..86eefae 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -6,19 +6,11 @@ from app.models import Project, TimeEntry, Task, Client from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit -from app.utils.tenancy import ( - get_current_organization_id, - get_current_organization, - scoped_query, - require_organization_access, - ensure_organization_access -) projects_bp = Blueprint('projects', __name__) @projects_bp.route('/projects') @login_required -@require_organization_access() def list_projects(): """List all projects""" page = request.args.get('page', 1, type=int) @@ -26,8 +18,7 @@ def list_projects(): client_name = request.args.get('client', '').strip() search = request.args.get('search', '').strip() - # Use scoped query to automatically filter by organization - query = scoped_query(Project) + query = Project.query if status == 'active': query = query.filter_by(status='active') elif status == 'archived': @@ -51,8 +42,8 @@ def list_projects(): error_out=False ) - # Get clients for filter dropdown (scoped to current org) - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() + # Get clients for filter dropdown + clients = Client.get_active_clients() client_list = [c.name for c in clients] return render_template( @@ -64,7 +55,6 @@ def list_projects(): @projects_bp.route('/projects/create', methods=['GET', 'POST']) @login_required -@require_organization_access() def create_project(): """Create a new project""" if not current_user.is_admin: @@ -75,9 +65,6 @@ def create_project(): flash('Only administrators can create projects', 'error') return redirect(url_for('projects.list_projects')) - # Get current organization - org_id = get_current_organization_id() - if request.method == 'POST': name = request.form.get('name', '').strip() client_id = request.form.get('client_id', '').strip() @@ -90,12 +77,11 @@ def create_project(): budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip() try: current_app.logger.info( - "POST /projects/create user=%s name=%s client_id=%s billable=%s org_id=%s", + "POST /projects/create user=%s name=%s client_id=%s billable=%s", current_user.username, name or '', client_id or '', billable, - org_id, ) except Exception: pass @@ -107,19 +93,17 @@ def create_project(): current_app.logger.warning("Validation failed: missing required fields for project creation") except Exception: pass - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/create.html', clients=clients) + return render_template('projects/create.html', clients=Client.get_active_clients()) - # Get client and validate (must be in same organization) - client = scoped_query(Client).filter_by(id=client_id).first() + # Get client and validate + client = Client.query.get(client_id) if not client: - flash('Selected client not found in your organization', 'error') + flash('Selected client not found', 'error') try: - current_app.logger.warning("Validation failed: client not found or not in org (id=%s, org=%s)", client_id, org_id) + current_app.logger.warning("Validation failed: client not found (id=%s)", client_id) except Exception: pass - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/create.html', clients=clients) + return render_template('projects/create.html', clients=Client.get_active_clients()) # Validate hourly rate try: @@ -146,20 +130,18 @@ def create_project(): flash('Invalid budget threshold percent (0-100)', 'error') return render_template('projects/create.html', clients=Client.get_active_clients()) - # Check if project name already exists (within organization) - if scoped_query(Project).filter_by(name=name).first(): - flash('A project with this name already exists in your organization', 'error') + # Check if project name already exists + if Project.query.filter_by(name=name).first(): + flash('A project with this name already exists', 'error') try: - current_app.logger.warning("Validation failed: duplicate project name '%s' in org %s", name, org_id) + current_app.logger.warning("Validation failed: duplicate project name '%s'", name) except Exception: pass - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/create.html', clients=clients) + return render_template('projects/create.html', clients=Client.get_active_clients()) - # Create project with organization_id + # Create project project = Project( name=name, - organization_id=org_id, # ✅ Set organization client_id=client_id, description=description, billable=billable, @@ -170,24 +152,20 @@ def create_project(): ) db.session.add(project) - if not safe_commit('create_project', {'name': name, 'client_id': client_id, 'org_id': org_id}): + if not safe_commit('create_project', {'name': name, 'client_id': client_id}): flash('Could not create project due to a database error. Please check server logs.', 'error') - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/create.html', clients=clients) + return render_template('projects/create.html', clients=Client.get_active_clients()) flash(f'Project "{name}" created successfully', 'success') return redirect(url_for('projects.view_project', project_id=project.id)) - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/create.html', clients=clients) + return render_template('projects/create.html', clients=Client.get_active_clients()) @projects_bp.route('/projects/') @login_required -@require_organization_access() def view_project(project_id): """View project details and time entries""" - # Use scoped query to ensure project belongs to current organization - project = scoped_query(Project).filter_by(id=project_id).first_or_404() + project = Project.query.get_or_404(project_id) # Get time entries for this project page = request.args.get('page', 1, type=int) @@ -221,15 +199,13 @@ def view_project(project_id): @projects_bp.route('/projects//edit', methods=['GET', 'POST']) @login_required -@require_organization_access() def edit_project(project_id): """Edit project details""" if not current_user.is_admin: flash('Only administrators can edit projects', 'error') return redirect(url_for('projects.view_project', project_id=project_id)) - # Use scoped query to ensure project belongs to current organization - project = scoped_query(Project).filter_by(id=project_id).first_or_404() + project = Project.query.get_or_404(project_id) if request.method == 'POST': name = request.form.get('name', '').strip() @@ -246,12 +222,11 @@ def edit_project(project_id): flash('Project name and client are required', 'error') return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - # Get client and validate (must be in same organization) - client = scoped_query(Client).filter_by(id=client_id).first() + # Get client and validate + client = Client.query.get(client_id) if not client: - flash('Selected client not found in your organization', 'error') - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/edit.html', project=project, clients=clients) + flash('Selected client not found', 'error') + return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) # Validate hourly rate try: @@ -278,15 +253,13 @@ def edit_project(project_id): raise ValueError('Invalid threshold') except Exception: flash('Invalid budget threshold percent (0-100)', 'error') - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/edit.html', project=project, clients=clients) + return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - # Check if project name already exists (excluding current project, within organization) - existing = scoped_query(Project).filter_by(name=name).first() + # Check if project name already exists (excluding current project) + existing = Project.query.filter_by(name=name).first() if existing and existing.id != project.id: - flash('A project with this name already exists in your organization', 'error') - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/edit.html', project=project, clients=clients) + flash('A project with this name already exists', 'error') + return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) # Update project project.name = name @@ -301,25 +274,22 @@ def edit_project(project_id): if not safe_commit('edit_project', {'project_id': project.id}): flash('Could not update project due to a database error. Please check server logs.', 'error') - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/edit.html', project=project, clients=clients) + return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) flash(f'Project "{name}" updated successfully', 'success') return redirect(url_for('projects.view_project', project_id=project.id)) - clients = scoped_query(Client).filter_by(status='active').order_by(Client.name).all() - return render_template('projects/edit.html', project=project, clients=clients) + return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) @projects_bp.route('/projects//archive', methods=['POST']) @login_required -@require_organization_access() def archive_project(project_id): """Archive a project""" if not current_user.is_admin: flash('Only administrators can archive projects', 'error') return redirect(url_for('projects.view_project', project_id=project_id)) - project = scoped_query(Project).filter_by(id=project_id).first_or_404() + project = Project.query.get_or_404(project_id) if project.status == 'archived': flash('Project is already archived', 'info') @@ -331,14 +301,13 @@ def archive_project(project_id): @projects_bp.route('/projects//unarchive', methods=['POST']) @login_required -@require_organization_access() def unarchive_project(project_id): """Unarchive a project""" if not current_user.is_admin: flash('Only administrators can unarchive projects', 'error') return redirect(url_for('projects.view_project', project_id=project_id)) - project = scoped_query(Project).filter_by(id=project_id).first_or_404() + project = Project.query.get_or_404(project_id) if project.status == 'active': flash('Project is already active', 'info') @@ -350,10 +319,13 @@ def unarchive_project(project_id): @projects_bp.route('/projects//delete', methods=['POST']) @login_required -@require_organization_access(admin_only=True) def delete_project(project_id): """Delete a project (only if no time entries exist)""" - project = scoped_query(Project).filter_by(id=project_id).first_or_404() + if not current_user.is_admin: + flash('Only administrators can delete projects', 'error') + return redirect(url_for('projects.view_project', project_id=project_id)) + + project = Project.query.get_or_404(project_id) # Check if project has time entries if project.time_entries.count() > 0: diff --git a/app/routes/promo_codes.py b/app/routes/promo_codes.py deleted file mode 100644 index dc3744a..0000000 --- a/app/routes/promo_codes.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Promo Code Routes""" - -from flask import Blueprint, request, jsonify, render_template, redirect, url_for, flash -from flask_login import login_required, current_user -from app.utils.promo_code_service import promo_code_service -from app.utils.tenancy import get_current_organization -from app.models.promo_code import PromoCode -from app import db - -promo_codes_bp = Blueprint('promo_codes', __name__, url_prefix='/promo-codes') - - -@promo_codes_bp.route('/validate', methods=['POST']) -def validate_promo_code(): - """Validate a promo code (public endpoint for signup flow)""" - data = request.get_json() or {} - code = data.get('code', '').strip() - - if not code: - return jsonify({ - 'valid': False, - 'message': 'Please enter a promo code' - }), 400 - - is_valid, promo_code, message = promo_code_service.validate_promo_code(code) - - if is_valid: - return jsonify({ - 'valid': True, - 'code': promo_code.code, - 'description': promo_code.get_discount_description(), - 'message': message - }) - else: - return jsonify({ - 'valid': False, - 'message': message - }), 400 - - -@promo_codes_bp.route('/apply', methods=['POST']) -@login_required -def apply_promo_code(): - """Apply a promo code to the current organization""" - organization = get_current_organization() - - if not organization: - return jsonify({ - 'success': False, - 'message': 'Organization not found' - }), 404 - - data = request.get_json() or {} - code = data.get('code', '').strip() - - if not code: - return jsonify({ - 'success': False, - 'message': 'Please enter a promo code' - }), 400 - - success, stripe_coupon_id, message = promo_code_service.apply_promo_code( - code=code, - organization=organization, - user_id=current_user.id - ) - - if success: - return jsonify({ - 'success': True, - 'stripe_coupon_id': stripe_coupon_id, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 400 - - -@promo_codes_bp.route('/admin', methods=['GET']) -@login_required -def admin_promo_codes(): - """Admin page for managing promo codes""" - # Check if user is admin - if not current_user.is_admin: - flash('Access denied. Admin privileges required.', 'error') - return redirect(url_for('main.dashboard')) - - promo_codes = PromoCode.query.order_by(PromoCode.created_at.desc()).all() - - return render_template('admin/promo_codes.html', promo_codes=promo_codes) - - -@promo_codes_bp.route('/admin/create', methods=['POST']) -@login_required -def admin_create_promo_code(): - """Create a new promo code (admin only)""" - # Check if user is admin - if not current_user.is_admin: - return jsonify({'success': False, 'message': 'Access denied'}), 403 - - data = request.get_json() or {} - - try: - from datetime import datetime - - # Parse valid_until if provided - valid_until = None - if data.get('valid_until'): - valid_until = datetime.fromisoformat(data['valid_until']) - - promo_code = promo_code_service.create_promo_code( - code=data['code'], - discount_type=data['discount_type'], - discount_value=float(data['discount_value']), - duration=data.get('duration', 'once'), - duration_in_months=data.get('duration_in_months'), - description=data.get('description'), - max_redemptions=data.get('max_redemptions'), - valid_until=valid_until, - first_time_only=data.get('first_time_only', False), - sync_to_stripe=True - ) - - return jsonify({ - 'success': True, - 'message': 'Promo code created successfully', - 'promo_code': { - 'id': promo_code.id, - 'code': promo_code.code, - 'description': promo_code.get_discount_description() - } - }) - - except Exception as e: - return jsonify({ - 'success': False, - 'message': str(e) - }), 400 - - -@promo_codes_bp.route('/admin//deactivate', methods=['POST']) -@login_required -def admin_deactivate_promo_code(promo_code_id): - """Deactivate a promo code (admin only)""" - # Check if user is admin - if not current_user.is_admin: - return jsonify({'success': False, 'message': 'Access denied'}), 403 - - promo_code = PromoCode.query.get_or_404(promo_code_id) - - promo_code.is_active = False - db.session.commit() - - # Deactivate in Stripe - promo_code_service.deactivate_promo_code(promo_code.code) - - return jsonify({ - 'success': True, - 'message': 'Promo code deactivated' - }) - - -@promo_codes_bp.route('/admin//stats', methods=['GET']) -@login_required -def admin_promo_code_stats(promo_code_id): - """Get statistics for a promo code (admin only)""" - # Check if user is admin - if not current_user.is_admin: - return jsonify({'success': False, 'message': 'Access denied'}), 403 - - promo_code = PromoCode.query.get_or_404(promo_code_id) - redemptions = promo_code_service.get_redemptions(promo_code) - - return jsonify({ - 'code': promo_code.code, - 'description': promo_code.description, - 'times_redeemed': promo_code.times_redeemed, - 'max_redemptions': promo_code.max_redemptions, - 'is_active': promo_code.is_active, - 'is_valid': promo_code.is_valid, - 'redemptions': [{ - 'organization_id': r.organization_id, - 'redeemed_at': r.redeemed_at.isoformat(), - 'redeemed_by': r.redeemed_by - } for r in redemptions] - }) - diff --git a/app/routes/reports.py b/app/routes/reports.py index 9b248de..618f14a 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -6,33 +6,23 @@ from datetime import datetime, timedelta import csv import io import pytz -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) reports_bp = Blueprint('reports', __name__) @reports_bp.route('/reports') @login_required -@require_organization_access() def reports(): """Main reports page""" - org_id = get_current_organization_id() - - # Aggregate totals (scope by organization and user unless admin) + # Aggregate totals (scope by user unless admin) totals_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None) ) billable_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.organization_id == org_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True ) - entries_query = scoped_query(TimeEntry).filter(TimeEntry.end_time.isnot(None)) + entries_query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) if not current_user.is_admin: totals_query = totals_query.filter(TimeEntry.user_id == current_user.id) @@ -45,7 +35,7 @@ def reports(): summary = { 'total_hours': round(total_seconds / 3600, 2), 'billable_hours': round(billable_seconds / 3600, 2), - 'active_projects': scoped_query(Project).filter_by(status='active').count(), + 'active_projects': Project.query.filter_by(status='active').count(), 'total_users': User.query.filter_by(is_active=True).count(), } @@ -55,17 +45,15 @@ def reports(): @reports_bp.route('/reports/project') @login_required -@require_organization_access() def project_report(): """Project-based time report""" - org_id = get_current_organization_id() project_id = request.args.get('project_id', type=int) start_date = request.args.get('start_date') end_date = request.args.get('end_date') user_id = request.args.get('user_id', type=int) - # Get projects for filter (scoped to organization) - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get projects for filter + projects = Project.query.filter_by(status='active').order_by(Project.name).all() users = User.query.filter_by(is_active=True).order_by(User.username).all() # Parse dates @@ -81,8 +69,8 @@ def project_report(): flash('Invalid date format', 'error') return render_template('reports/project_report.html', projects=projects, users=users) - # Get time entries (scoped to organization) - query = scoped_query(TimeEntry).filter( + # Get time entries + query = TimeEntry.query.filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt @@ -165,10 +153,8 @@ def project_report(): @reports_bp.route('/reports/user') @login_required -@require_organization_access() def user_report(): """User-based time report""" - org_id = get_current_organization_id() user_id = request.args.get('user_id', type=int) start_date = request.args.get('start_date') end_date = request.args.get('end_date') @@ -176,7 +162,7 @@ def user_report(): # Get users for filter users = User.query.filter_by(is_active=True).order_by(User.username).all() - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status='active').order_by(Project.name).all() # Parse dates if not start_date: @@ -192,7 +178,7 @@ def user_report(): return render_template('reports/user_report.html', users=users, projects=projects) # Get time entries - query = scoped_query(TimeEntry).filter( + query = TimeEntry.query.filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt @@ -251,10 +237,8 @@ def user_report(): @reports_bp.route('/reports/export/csv') @login_required -@require_organization_access() def export_csv(): """Export time entries as CSV""" - org_id = get_current_organization_id() start_date = request.args.get('start_date') end_date = request.args.get('end_date') user_id = request.args.get('user_id', type=int) @@ -274,7 +258,7 @@ def export_csv(): return redirect(url_for('reports.reports')) # Get time entries - query = scoped_query(TimeEntry).filter( + query = TimeEntry.query.filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt @@ -335,7 +319,6 @@ def export_csv(): @reports_bp.route('/reports/summary') @login_required -@require_organization_access() def summary_report(): """Summary report with key metrics""" # Get date range @@ -361,14 +344,14 @@ def summary_report(): # Get top projects if current_user.is_admin: # For admins, show all projects - projects = scoped_query(Project).filter_by(status='active').all() + projects = Project.query.filter_by(status='active').all() else: # For users, show only their projects project_ids = db.session.query(TimeEntry.project_id).filter( TimeEntry.user_id == current_user.id ).distinct().all() project_ids = [pid[0] for pid in project_ids] - projects = scoped_query(Project).filter(Project.id.in_(project_ids)).all() + projects = Project.query.filter(Project.id.in_(project_ids)).all() # Sort projects by total hours project_stats = [] @@ -395,17 +378,15 @@ def summary_report(): @reports_bp.route('/reports/tasks') @login_required -@require_organization_access() def task_report(): """Report of finished tasks within a project, including hours spent per task""" - org_id = get_current_organization_id() project_id = request.args.get('project_id', type=int) user_id = request.args.get('user_id', type=int) start_date = request.args.get('start_date') end_date = request.args.get('end_date') - # Filters data (scoped to organization) - projects = scoped_query(Project).order_by(Project.name).all() + # Filters data + projects = Project.query.order_by(Project.name).all() users = User.query.filter_by(is_active=True).order_by(User.username).all() # Default date range: last 30 days @@ -421,8 +402,8 @@ def task_report(): flash('Invalid date format', 'error') return render_template('reports/task_report.html', projects=projects, users=users) - # Base tasks query: finished tasks (scoped to organization) - tasks_query = scoped_query(Task).filter(Task.status == 'done') + # Base tasks query: finished tasks + tasks_query = Task.query.filter(Task.status == 'done') if project_id: tasks_query = tasks_query.filter(Task.project_id == project_id) @@ -441,7 +422,7 @@ def task_report(): task_rows = [] total_hours = 0.0 for task in tasks: - te_query = scoped_query(TimeEntry).filter( + te_query = TimeEntry.query.filter( TimeEntry.task_id == task.id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, diff --git a/app/routes/security.py b/app/routes/security.py deleted file mode 100644 index e4ada40..0000000 --- a/app/routes/security.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -Security Routes - 2FA/MFA Management -""" - -from flask import Blueprint, render_template, request, redirect, url_for, flash, session, current_app, jsonify -from flask_login import login_required, current_user -from app import db, limiter -from app.models import User -from app.utils.db import safe_commit -from flask_babel import gettext as _ -import pyotp -import qrcode -import io -import base64 - -security_bp = Blueprint('security', __name__, url_prefix='/security') - - -@security_bp.route('/2fa/setup', methods=['GET']) -@login_required -@limiter.limit("10 per hour") -def setup_2fa(): - """Display 2FA setup page""" - if current_user.totp_enabled: - flash(_('Two-factor authentication is already enabled for your account.'), 'info') - return redirect(url_for('security.manage_2fa')) - - # Generate a new TOTP secret - secret = pyotp.random_base32() - session['pending_totp_secret'] = secret - - # Generate QR code - totp = pyotp.TOTP(secret) - provisioning_uri = totp.provisioning_uri( - name=current_user.email or current_user.username, - issuer_name=current_app.config.get('COMPANY_NAME', 'TimeTracker') - ) - - # Create QR code image - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(provisioning_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buffer = io.BytesIO() - img.save(buffer, format='PNG') - buffer.seek(0) - qr_code_data = base64.b64encode(buffer.getvalue()).decode() - - return render_template('security/setup_2fa.html', - secret=secret, - qr_code_data=qr_code_data, - provisioning_uri=provisioning_uri) - - -@security_bp.route('/2fa/verify', methods=['POST']) -@login_required -@limiter.limit("10 per hour") -def verify_2fa_setup(): - """Verify and enable 2FA""" - token = request.form.get('token', '').strip() - secret = session.get('pending_totp_secret') - - if not secret: - flash(_('2FA setup session expired. Please try again.'), 'error') - return redirect(url_for('security.setup_2fa')) - - if not token: - flash(_('Please enter the 6-digit code from your authenticator app.'), 'error') - return redirect(url_for('security.setup_2fa')) - - # Verify the token - totp = pyotp.TOTP(secret) - if not totp.verify(token, valid_window=1): - flash(_('Invalid code. Please try again.'), 'error') - return redirect(url_for('security.setup_2fa')) - - # Enable 2FA for the user - current_user.totp_secret = secret - current_user.totp_enabled = True - - # Generate backup codes - backup_codes = current_user.generate_backup_codes() - - if not safe_commit(): - flash(_('Failed to enable two-factor authentication. Please try again.'), 'error') - return redirect(url_for('security.setup_2fa')) - - # Clear the pending secret from session - session.pop('pending_totp_secret', None) - - flash(_('Two-factor authentication has been enabled successfully!'), 'success') - - return render_template('security/backup_codes.html', - backup_codes=backup_codes, - is_setup=True) - - -@security_bp.route('/2fa/disable', methods=['POST']) -@login_required -@limiter.limit("5 per hour") -def disable_2fa(): - """Disable 2FA for the current user""" - password = request.form.get('password', '') - - # Verify password before disabling 2FA - if not current_user.check_password(password): - flash(_('Incorrect password. Two-factor authentication was not disabled.'), 'error') - return redirect(url_for('security.manage_2fa')) - - current_user.totp_secret = None - current_user.totp_enabled = False - current_user.backup_codes = None - - if not safe_commit(): - flash(_('Failed to disable two-factor authentication. Please try again.'), 'error') - return redirect(url_for('security.manage_2fa')) - - flash(_('Two-factor authentication has been disabled.'), 'warning') - return redirect(url_for('main.dashboard')) - - -@security_bp.route('/2fa/manage', methods=['GET']) -@login_required -def manage_2fa(): - """Manage 2FA settings""" - return render_template('security/manage_2fa.html') - - -@security_bp.route('/2fa/backup-codes/regenerate', methods=['POST']) -@login_required -@limiter.limit("3 per hour") -def regenerate_backup_codes(): - """Regenerate backup codes""" - if not current_user.totp_enabled: - flash(_('Two-factor authentication is not enabled.'), 'error') - return redirect(url_for('security.manage_2fa')) - - password = request.form.get('password', '') - - # Verify password before regenerating backup codes - if not current_user.check_password(password): - flash(_('Incorrect password. Backup codes were not regenerated.'), 'error') - return redirect(url_for('security.manage_2fa')) - - # Generate new backup codes - backup_codes = current_user.generate_backup_codes() - - if not safe_commit(): - flash(_('Failed to regenerate backup codes. Please try again.'), 'error') - return redirect(url_for('security.manage_2fa')) - - flash(_('New backup codes have been generated. Please save them securely.'), 'success') - - return render_template('security/backup_codes.html', - backup_codes=backup_codes, - is_setup=False) - - -@security_bp.route('/2fa/verify-login', methods=['GET', 'POST']) -@limiter.limit("10 per hour") -def verify_2fa_login(): - """Verify 2FA during login""" - # Check if user is in the 2FA verification flow - pending_user_id = session.get('pending_2fa_user_id') - if not pending_user_id: - flash(_('No pending 2FA verification. Please log in again.'), 'error') - return redirect(url_for('auth.login')) - - user = User.query.get(pending_user_id) - if not user or not user.totp_enabled: - session.pop('pending_2fa_user_id', None) - flash(_('Invalid 2FA verification state. Please log in again.'), 'error') - return redirect(url_for('auth.login')) - - if request.method == 'GET': - return render_template('security/verify_2fa_login.html') - - # POST - verify the token - token = request.form.get('token', '').strip() - use_backup = request.form.get('use_backup') == 'true' - - if not token: - flash(_('Please enter the verification code.'), 'error') - return render_template('security/verify_2fa_login.html') - - is_valid = False - - if use_backup: - # Verify backup code - is_valid = user.verify_backup_code(token) - if is_valid: - safe_commit() - flash(_('Backup code used successfully. Please generate new backup codes.'), 'warning') - else: - # Verify TOTP token - is_valid = user.verify_totp(token) - - if not is_valid: - flash(_('Invalid verification code. Please try again.'), 'error') - return render_template('security/verify_2fa_login.html') - - # 2FA verification successful - complete login - from flask_login import login_user - session.pop('pending_2fa_user_id', None) - login_user(user, remember=session.get('remember_me', False)) - user.update_last_login() - - flash(_('Login successful!'), 'success') - - next_page = session.pop('next_after_2fa', None) or url_for('main.dashboard') - return redirect(next_page) - - -@security_bp.route('/password/change', methods=['GET', 'POST']) -@login_required -@limiter.limit("10 per hour") -def change_password(): - """Change user password""" - from app.utils.password_policy import PasswordPolicy - - if request.method == 'GET': - # Check if password is expired - is_expired, days_remaining = PasswordPolicy.check_password_expiry(current_user) - policy_description = PasswordPolicy.get_policy_description() - - return render_template('security/change_password.html', - password_expired=is_expired, - days_remaining=days_remaining, - policy_description=policy_description) - - # POST - change password - current_password = request.form.get('current_password', '') - new_password = request.form.get('new_password', '') - confirm_password = request.form.get('confirm_password', '') - - # Verify current password - if not current_user.check_password(current_password): - flash(_('Current password is incorrect.'), 'error') - return redirect(url_for('security.change_password')) - - # Verify new passwords match - if new_password != confirm_password: - flash(_('New passwords do not match.'), 'error') - return redirect(url_for('security.change_password')) - - # Set new password (validation happens in set_password) - try: - current_user.set_password(new_password, validate=True) - - if not safe_commit(): - flash(_('Failed to change password. Please try again.'), 'error') - return redirect(url_for('security.change_password')) - - flash(_('Your password has been changed successfully.'), 'success') - return redirect(url_for('main.dashboard')) - - except ValueError as e: - flash(str(e), 'error') - return redirect(url_for('security.change_password')) - diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 335a030..12de846 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -7,17 +7,11 @@ from datetime import datetime, date from decimal import Decimal from app.utils.db import safe_commit from app.utils.timezone import now_in_app_timezone -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) tasks_bp = Blueprint('tasks', __name__) @tasks_bp.route('/tasks') @login_required -@require_organization_access() def list_tasks(): """List all tasks with filtering options""" page = request.args.get('page', 1, type=int) @@ -29,7 +23,7 @@ def list_tasks(): overdue_param = request.args.get('overdue', '').strip().lower() overdue = overdue_param in ['1', 'true', 'on', 'yes'] - query = scoped_query(Task) + query = Task.query # Apply filters if status: @@ -76,9 +70,9 @@ def list_tasks(): error_out=False ) - # Get filter options (scoped to organization) - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() - users = User.query.order_by(User.username).all() # Users are global, not org-scoped + # Get filter options + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + users = User.query.order_by(User.username).all() return render_template( 'tasks/list.html', @@ -96,11 +90,8 @@ def list_tasks(): @tasks_bp.route('/tasks/create', methods=['GET', 'POST']) @login_required -@require_organization_access() def create_task(): """Create a new task""" - org_id = get_current_organization_id() - if request.method == 'POST': project_id = request.form.get('project_id', type=int) name = request.form.get('name', '').strip() @@ -115,10 +106,10 @@ def create_task(): flash('Project and task name are required', 'error') return render_template('tasks/create.html') - # Validate project exists (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id).first() + # Validate project exists + project = Project.query.get(project_id) if not project: - flash('Selected project does not exist in your organization', 'error') + flash('Selected project does not exist', 'error') return render_template('tasks/create.html') # Parse estimated hours @@ -137,10 +128,9 @@ def create_task(): flash('Invalid due date format', 'error') return render_template('tasks/create.html') - # Create task with organization_id + # Create task task = Task( project_id=project_id, - organization_id=org_id, name=name, description=description, priority=priority, @@ -158,18 +148,17 @@ def create_task(): flash(f'Task "{name}" created successfully', 'success') return redirect(url_for('tasks.view_task', task_id=task.id)) - # Get available projects and users for form (scoped to organization) - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get available projects and users for form + 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) @tasks_bp.route('/tasks/') @login_required -@require_organization_access() def view_task(task_id): """View task details""" - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) # Check if user has access to this task if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: @@ -189,11 +178,9 @@ def view_task(task_id): @tasks_bp.route('/tasks//edit', methods=['GET', 'POST']) @login_required -@require_organization_access() def edit_task(task_id): """Edit task details""" - org_id = get_current_organization_id() - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) # Check if user can edit this task if not current_user.is_admin and task.created_by != current_user.id: @@ -250,21 +237,21 @@ def edit_task(task_id): if not task.started_at: task.started_at = now_in_app_timezone() task.updated_at = now_in_app_timezone() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) if not safe_commit('edit_task_reopen_in_progress', {'task_id': task.id}): flash('Could not update status due to a database error. Please check server logs.', 'error') return render_template('tasks/edit.html', task=task) else: task.start_task() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) safe_commit('log_task_start_from_edit', {'task_id': task.id}) elif selected_status == 'done': task.complete_task() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='complete', details='Task completed')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed')) safe_commit('log_task_complete_from_edit', {'task_id': task.id}) elif selected_status == 'cancelled': task.cancel_task() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='cancel', details='Task cancelled')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled')) safe_commit('log_task_cancel_from_edit', {'task_id': task.id}) else: # Reopen or move to non-special states @@ -274,7 +261,7 @@ def edit_task(task_id): task.status = selected_status task.updated_at = now_in_app_timezone() event_name = 'reopen' if previous_status == 'done' and selected_status in ['todo', 'review'] else ('pause' if selected_status == 'todo' else ('review' if selected_status == 'review' else 'status_change')) - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {selected_status}")) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {selected_status}")) if not safe_commit('edit_task_status_change', {'task_id': task.id, 'status': selected_status}): flash('Could not update status due to a database error. Please check server logs.', 'error') return render_template('tasks/edit.html', task=task) @@ -292,19 +279,17 @@ def edit_task(task_id): flash(f'Task "{name}" updated successfully', 'success') return redirect(url_for('tasks.view_task', task_id=task.id)) - # Get available projects and users for form (scoped to organization) - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get available projects and users for form + projects = Project.query.filter_by(status='active').order_by(Project.name).all() users = User.query.order_by(User.username).all() return render_template('tasks/edit.html', task=task, projects=projects, users=users) @tasks_bp.route('/tasks//status', methods=['POST']) @login_required -@require_organization_access() def update_task_status(task_id): """Update task status""" - org_id = get_current_organization_id() - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) new_status = request.form.get('status', '').strip() # Check if user can update this task @@ -329,22 +314,22 @@ def update_task_status(task_id): if not task.started_at: task.started_at = now_in_app_timezone() task.updated_at = now_in_app_timezone() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) if not safe_commit('update_task_status_reopen_in_progress', {'task_id': task.id, 'status': new_status}): flash('Could not update status due to a database error. Please check server logs.', 'error') return redirect(url_for('tasks.view_task', task_id=task.id)) else: previous_status = task.status task.start_task() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) safe_commit('log_task_start', {'task_id': task.id}) elif new_status == 'done': task.complete_task() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='complete', details='Task completed')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed')) safe_commit('log_task_complete', {'task_id': task.id}) elif new_status == 'cancelled': task.cancel_task() - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event='cancel', details='Task cancelled')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled')) safe_commit('log_task_cancel', {'task_id': task.id}) else: # For other transitions, handle reopening from done and local timestamps @@ -362,7 +347,7 @@ def update_task_status(task_id): 'review': 'review', } event_name = event_map.get(new_status, 'status_change') - db.session.add(TaskActivity(task_id=task.id, organization_id=org_id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {new_status}")) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {new_status}")) if not safe_commit('update_task_status', {'task_id': task.id, 'status': new_status}): flash('Could not update status due to a database error. Please check server logs.', 'error') return redirect(url_for('tasks.view_task', task_id=task.id)) @@ -375,10 +360,9 @@ def update_task_status(task_id): @tasks_bp.route('/tasks//priority', methods=['POST']) @login_required -@require_organization_access() def update_task_priority(task_id): """Update task priority""" - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) new_priority = request.form.get('priority', '').strip() # Check if user can update this task @@ -396,10 +380,9 @@ def update_task_priority(task_id): @tasks_bp.route('/tasks//assign', methods=['POST']) @login_required -@require_organization_access() def assign_task(task_id): """Assign task to a user""" - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) user_id = request.form.get('user_id', type=int) # Check if user can assign this task @@ -423,10 +406,9 @@ def assign_task(task_id): @tasks_bp.route('/tasks//delete', methods=['POST']) @login_required -@require_organization_access() def delete_task(task_id): """Delete a task""" - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) # Check if user can delete this task if not current_user.is_admin and task.created_by != current_user.id: @@ -449,7 +431,6 @@ def delete_task(task_id): @tasks_bp.route('/tasks/my-tasks') @login_required -@require_organization_access() def my_tasks(): """Show current user's tasks with filters and pagination""" page = request.args.get('page', 1, type=int) @@ -461,7 +442,7 @@ def my_tasks(): overdue_param = request.args.get('overdue', '').strip().lower() overdue = overdue_param in ['1', 'true', 'on', 'yes'] - query = scoped_query(Task) + query = Task.query # Restrict to current user's tasks depending on task_type filter if task_type == 'assigned': @@ -509,8 +490,8 @@ def my_tasks(): Task.created_at.asc() ).paginate(page=page, per_page=20, error_out=False) - # Provide projects for filter dropdown (scoped to organization) - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Provide projects for filter dropdown + projects = Project.query.filter_by(status='active').order_by(Project.name).all() return render_template( 'tasks/my_tasks.html', @@ -527,29 +508,21 @@ def my_tasks(): @tasks_bp.route('/tasks/overdue') @login_required -@require_organization_access() def overdue_tasks(): """Show all overdue tasks""" if not current_user.is_admin: flash('Only administrators can view all overdue tasks', 'error') return redirect(url_for('tasks.list_tasks')) - # Get overdue tasks scoped to organization - org_id = get_current_organization_id() - today = date.today() - tasks = scoped_query(Task).filter( - Task.due_date < today, - Task.status.in_(['todo', 'in_progress', 'review']) - ).order_by(Task.priority.desc(), Task.due_date.asc()).all() + tasks = Task.get_overdue_tasks() return render_template('tasks/overdue.html', tasks=tasks) @tasks_bp.route('/api/tasks/') @login_required -@require_organization_access() def api_task(task_id): """API endpoint to get task details""" - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) # Check if user has access to this task if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: @@ -559,10 +532,9 @@ def api_task(task_id): @tasks_bp.route('/api/tasks//status', methods=['PUT']) @login_required -@require_organization_access() def api_update_status(task_id): """API endpoint to update task status""" - task = scoped_query(Task).filter_by(id=task_id).first_or_404() + task = Task.query.get_or_404(task_id) data = request.get_json() new_status = data.get('status', '').strip() diff --git a/app/routes/timer.py b/app/routes/timer.py index 7f705c3..bdc9ae8 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -7,43 +7,36 @@ from app.utils.timezone import parse_local_datetime, utc_to_local from datetime import datetime import json from app.utils.db import safe_commit -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) timer_bp = Blueprint('timer', __name__) @timer_bp.route('/timer/start', methods=['POST']) @login_required -@require_organization_access() def start_timer(): """Start a new timer for the current user""" - org_id = get_current_organization_id() project_id = request.form.get('project_id', type=int) task_id = request.form.get('task_id', type=int) notes = request.form.get('notes', '').strip() - current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s org_id=%s", current_user.username, project_id, task_id, org_id) + current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s", current_user.username, project_id, task_id) if not project_id: flash('Project is required', 'error') current_app.logger.warning("Start timer failed: missing project_id") return redirect(url_for('main.dashboard')) - # Check if project exists and is active (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id, status='active').first() + # Check if project exists and is active + project = Project.query.filter_by(id=project_id, status='active').first() if not project: flash('Invalid project selected', 'error') - current_app.logger.warning("Start timer failed: invalid or inactive project_id=%s in org_id=%s", project_id, org_id) + current_app.logger.warning("Start timer failed: invalid or inactive project_id=%s", project_id) return redirect(url_for('main.dashboard')) - # If a task is provided, validate it belongs to the project (and organization) + # If a task is provided, validate it belongs to the project if task_id: - task = scoped_query(Task).filter_by(id=task_id, project_id=project_id).first() + task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: flash('Selected task is invalid for the chosen project', 'error') - current_app.logger.warning("Start timer failed: task_id=%s does not belong to project_id=%s in org_id=%s", task_id, project_id, org_id) + current_app.logger.warning("Start timer failed: task_id=%s does not belong to project_id=%s", task_id, project_id) return redirect(url_for('main.dashboard')) else: task = None @@ -55,12 +48,11 @@ def start_timer(): current_app.logger.info("Start timer blocked: user already has an active timer") return redirect(url_for('main.dashboard')) - # Create new timer with organization_id + # Create new timer from app.models.time_entry import local_now new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, - organization_id=org_id, task_id=task.id if task else None, start_time=local_now(), notes=notes if notes else None, @@ -96,18 +88,16 @@ def start_timer(): @timer_bp.route('/timer/start/') @login_required -@require_organization_access() def start_timer_for_project(project_id): """Start a timer for a specific project (GET route for direct links)""" - org_id = get_current_organization_id() task_id = request.args.get('task_id', type=int) - current_app.logger.info("GET /timer/start/%s user=%s task_id=%s org_id=%s", project_id, current_user.username, task_id, org_id) + current_app.logger.info("GET /timer/start/%s user=%s task_id=%s", project_id, current_user.username, task_id) - # Check if project exists and is active (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id, status='active').first() + # Check if project exists and is active + project = Project.query.filter_by(id=project_id, status='active').first() if not project: flash('Invalid project selected', 'error') - current_app.logger.warning("Start timer (GET) failed: invalid or inactive project_id=%s in org_id=%s", project_id, org_id) + current_app.logger.warning("Start timer (GET) failed: invalid or inactive project_id=%s", project_id) return redirect(url_for('main.dashboard')) # Check if user already has an active timer @@ -117,12 +107,11 @@ def start_timer_for_project(project_id): current_app.logger.info("Start timer (GET) blocked: user already has an active timer") return redirect(url_for('main.dashboard')) - # Create new timer with organization_id + # Create new timer from app.models.time_entry import local_now new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, - organization_id=org_id, task_id=task_id, start_time=local_now(), source='auto' @@ -147,7 +136,7 @@ def start_timer_for_project(project_id): current_app.logger.warning("Socket emit failed for timer_started (GET): %s", e) if task_id: - task = scoped_query(Task).filter_by(id=task_id).first() + task = Task.query.get(task_id) task_name = task.name if task else "Unknown Task" flash(f'Timer started for {project.name} - {task_name}', 'success') else: @@ -157,7 +146,6 @@ def start_timer_for_project(project_id): @timer_bp.route('/timer/stop', methods=['POST']) @login_required -@require_organization_access() def stop_timer(): """Stop the current user's active timer""" active_timer = current_user.active_timer @@ -189,7 +177,6 @@ def stop_timer(): @timer_bp.route('/timer/status') @login_required -@require_organization_access() def timer_status(): """Get current timer status as JSON""" active_timer = current_user.active_timer @@ -213,10 +200,9 @@ def timer_status(): @timer_bp.route('/timer/edit/', methods=['GET', 'POST']) @login_required -@require_organization_access() def edit_timer(timer_id): """Edit a completed timer entry""" - timer = scoped_query(TimeEntry).filter_by(id=timer_id).first_or_404() + timer = TimeEntry.query.get_or_404(timer_id) # Check if user can edit this timer if timer.user_id != current_user.id and not current_user.is_admin: @@ -234,27 +220,27 @@ def edit_timer(timer_id): # Update project if changed new_project_id = request.form.get('project_id', type=int) if new_project_id and new_project_id != timer.project_id: - new_project = scoped_query(Project).filter_by(id=new_project_id, status='active').first() + new_project = Project.query.filter_by(id=new_project_id, status='active').first() if new_project: timer.project_id = new_project_id else: flash('Invalid project selected', 'error') return render_template('timer/edit_timer.html', timer=timer, - projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all(), - tasks=[] if not new_project_id else scoped_query(Task).filter_by(project_id=new_project_id).order_by(Task.name).all()) + projects=Project.query.filter_by(status='active').order_by(Project.name).all(), + tasks=[] if not new_project_id else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all()) # Update task if changed new_task_id = request.form.get('task_id', type=int) if new_task_id != timer.task_id: if new_task_id: - new_task = scoped_query(Task).filter_by(id=new_task_id, project_id=timer.project_id).first() + new_task = Task.query.filter_by(id=new_task_id, project_id=timer.project_id).first() if new_task: timer.task_id = new_task_id else: flash('Invalid task selected for the chosen project', 'error') return render_template('timer/edit_timer.html', timer=timer, - projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all(), - tasks=scoped_query(Task).filter_by(project_id=timer.project_id).order_by(Task.name).all()) + projects=Project.query.filter_by(status='active').order_by(Project.name).all(), + tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()) else: timer.task_id = None @@ -276,15 +262,15 @@ def edit_timer(timer_id): if new_start_time > current_time: flash('Start time cannot be in the future', 'error') return render_template('timer/edit_timer.html', timer=timer, - projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all(), - tasks=scoped_query(Task).filter_by(project_id=timer.project_id).order_by(Task.name).all()) + projects=Project.query.filter_by(status='active').order_by(Project.name).all(), + tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()) timer.start_time = new_start_time except ValueError: flash('Invalid start date/time format', 'error') return render_template('timer/edit_timer.html', timer=timer, - projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all(), - tasks=scoped_query(Task).filter_by(project_id=timer.project_id).order_by(Task.name).all()) + projects=Project.query.filter_by(status='active').order_by(Project.name).all(), + tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()) if end_date and end_time: try: @@ -296,8 +282,8 @@ def edit_timer(timer_id): if new_end_time <= timer.start_time: flash('End time must be after start time', 'error') return render_template('timer/edit_timer.html', timer=timer, - projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all(), - tasks=scoped_query(Task).filter_by(project_id=timer.project_id).order_by(Task.name).all()) + projects=Project.query.filter_by(status='active').order_by(Project.name).all(), + tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()) timer.end_time = new_end_time # Recalculate duration @@ -305,8 +291,8 @@ def edit_timer(timer_id): except ValueError: flash('Invalid end date/time format', 'error') return render_template('timer/edit_timer.html', timer=timer, - projects=scoped_query(Project).filter_by(status='active').order_by(Project.name).all(), - tasks=scoped_query(Task).filter_by(project_id=timer.project_id).order_by(Task.name).all()) + projects=Project.query.filter_by(status='active').order_by(Project.name).all(), + tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()) # Update source if provided new_source = request.form.get('source') @@ -320,22 +306,21 @@ def edit_timer(timer_id): flash('Timer updated successfully', 'success') return redirect(url_for('main.dashboard')) - # Get projects and tasks for admin users (scoped to organization) + # Get projects and tasks for admin users projects = [] tasks = [] if current_user.is_admin: - projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status='active').order_by(Project.name).all() if timer.project_id: - tasks = scoped_query(Task).filter_by(project_id=timer.project_id).order_by(Task.name).all() + tasks = Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all() return render_template('timer/edit_timer.html', timer=timer, projects=projects, tasks=tasks) @timer_bp.route('/timer/delete/', methods=['POST']) @login_required -@require_organization_access() def delete_timer(timer_id): """Delete a timer entry""" - timer = scoped_query(TimeEntry).filter_by(id=timer_id).first_or_404() + timer = TimeEntry.query.get_or_404(timer_id) # Check if user can delete this timer if timer.user_id != current_user.id and not current_user.is_admin: @@ -358,12 +343,10 @@ def delete_timer(timer_id): @timer_bp.route('/timer/manual', methods=['GET', 'POST']) @login_required -@require_organization_access() def manual_entry(): """Create a manual time entry""" - org_id = get_current_organization_id() - # Get active projects for dropdown (scoped to organization) - active_projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get active projects for dropdown (used for both GET and error re-renders on POST) + active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() # Get project_id and task_id from query parameters for pre-filling project_id = request.args.get('project_id', type=int) @@ -386,16 +369,16 @@ def manual_entry(): return render_template('timer/manual_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) - # Check if project exists and is active (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id, status='active').first() + # Check if project exists and is active + project = Project.query.filter_by(id=project_id, status='active').first() if not project: flash('Invalid project selected', 'error') return render_template('timer/manual_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) - # Validate task if provided (scoped to organization) + # Validate task if provided if task_id: - task = scoped_query(Task).filter_by(id=task_id, project_id=project_id).first() + task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: flash('Invalid task selected', 'error') return render_template('timer/manual_entry.html', projects=active_projects, @@ -416,11 +399,10 @@ def manual_entry(): return render_template('timer/manual_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) - # Create manual entry with organization_id + # Create manual entry entry = TimeEntry( user_id=current_user.id, project_id=project_id, - organization_id=org_id, task_id=task_id, start_time=start_time_parsed, end_time=end_time_parsed, @@ -437,7 +419,7 @@ def manual_entry(): selected_project_id=project_id, selected_task_id=task_id) if task_id: - task = scoped_query(Task).filter_by(id=task_id).first() + task = Task.query.get(task_id) task_name = task.name if task else "Unknown Task" flash(f'Manual entry created for {project.name} - {task_name}', 'success') else: @@ -450,31 +432,28 @@ def manual_entry(): @timer_bp.route('/timer/manual/') @login_required -@require_organization_access() def manual_entry_for_project(project_id): """Create a manual time entry for a specific project""" task_id = request.args.get('task_id', type=int) - # Check if project exists and is active (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id, status='active').first() + # Check if project exists and is active + project = Project.query.filter_by(id=project_id, status='active').first() if not project: flash('Invalid project selected', 'error') return redirect(url_for('main.dashboard')) - # Get active projects for dropdown (scoped to organization) - active_projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get active projects for dropdown + active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() return render_template('timer/manual_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) @timer_bp.route('/timer/bulk', methods=['GET', 'POST']) @login_required -@require_organization_access() def bulk_entry(): """Create bulk time entries for multiple days""" - org_id = get_current_organization_id() - # Get active projects for dropdown (scoped to organization) - active_projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get active projects for dropdown + active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() # Get project_id and task_id from query parameters for pre-filling project_id = request.args.get('project_id', type=int) @@ -498,16 +477,16 @@ def bulk_entry(): return render_template('timer/bulk_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) - # Check if project exists and is active (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id, status='active').first() + # Check if project exists and is active + project = Project.query.filter_by(id=project_id, status='active').first() if not project: flash('Invalid project selected', 'error') return render_template('timer/bulk_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) - # Validate task if provided (scoped to organization) + # Validate task if provided if task_id: - task = scoped_query(Task).filter_by(id=task_id, project_id=project_id).first() + task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: flash('Invalid task selected', 'error') return render_template('timer/bulk_entry.html', projects=active_projects, @@ -574,8 +553,8 @@ def bulk_entry(): start_datetime = datetime.combine(date_obj, start_time_obj) end_datetime = datetime.combine(date_obj, end_time_obj) - # Check for overlapping entries (scoped to organization) - overlapping = scoped_query(TimeEntry).filter( + # Check for overlapping entries + overlapping = TimeEntry.query.filter( TimeEntry.user_id == current_user.id, TimeEntry.start_time <= end_datetime, TimeEntry.end_time >= start_datetime, @@ -601,7 +580,6 @@ def bulk_entry(): entry = TimeEntry( user_id=current_user.id, project_id=project_id, - organization_id=org_id, task_id=task_id, start_time=start_datetime, end_time=end_datetime, @@ -621,7 +599,7 @@ def bulk_entry(): task_name = "" if task_id: - task = scoped_query(Task).filter_by(id=task_id).first() + task = Task.query.get(task_id) task_name = f" - {task.name}" if task else "" flash(f'Successfully created {len(created_entries)} time entries for {project.name}{task_name}', 'success') @@ -639,28 +617,26 @@ def bulk_entry(): @timer_bp.route('/timer/calendar') @login_required -@require_organization_access() def calendar_view(): """Calendar UI combining day/week/month with list toggle.""" - # Provide projects for quick assignment during drag-create (scoped to organization) - active_projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Provide projects for quick assignment during drag-create + active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() return render_template('timer/calendar.html', projects=active_projects) @timer_bp.route('/timer/bulk/') @login_required -@require_organization_access() def bulk_entry_for_project(project_id): """Create bulk time entries for a specific project""" task_id = request.args.get('task_id', type=int) - # Check if project exists and is active (scoped to organization) - project = scoped_query(Project).filter_by(id=project_id, status='active').first() + # Check if project exists and is active + project = Project.query.filter_by(id=project_id, status='active').first() if not project: flash('Invalid project selected', 'error') return redirect(url_for('main.dashboard')) - # Get active projects for dropdown (scoped to organization) - active_projects = scoped_query(Project).filter_by(status='active').order_by(Project.name).all() + # Get active projects for dropdown + active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() return render_template('timer/bulk_entry.html', projects=active_projects, selected_project_id=project_id, selected_task_id=task_id) diff --git a/app/templates/auth/2fa_backup_codes.html b/app/templates/auth/2fa_backup_codes.html deleted file mode 100644 index cf54b90..0000000 --- a/app/templates/auth/2fa_backup_codes.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Backup Codes - TimeTracker{% endblock %} - -{% block content %} -
-
-
-
-
-

2FA Enabled Successfully!

-
-
-
- Important: Save these backup codes in a safe place. Each code can only be used once. -
- -
-
- {% for code in backup_codes %} -
- {{ code }} -
- {% endfor %} -
-
- -
- - - - Continue to Settings - -
-
-
-
-
-
- - - - -{% endblock %} - diff --git a/app/templates/auth/accept_invitation.html b/app/templates/auth/accept_invitation.html deleted file mode 100644 index 4fc8770..0000000 --- a/app/templates/auth/accept_invitation.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Accept Invitation - TimeTracker{% endblock %} - -{% block content %} -
-
-
-
-
-
-

You're Invited!

-

- {{ inviter.display_name if inviter else 'Someone' }} has invited you to join -

-
- -
-
-
{{ organization.name }}
-

- Your Role: - {{ membership.role.capitalize() }} -

- {% if inviter %} -

- Invited by: {{ inviter.display_name }} -

- {% endif %} -
-
- -
- - -
- - -
- -
- - - At least 8 characters -
- -
- - -
- - - -
-

- By accepting, you agree to join this organization -

-
-
-
-
-
-
-
- - -{% endblock %} - diff --git a/app/templates/auth/enable_2fa.html b/app/templates/auth/enable_2fa.html deleted file mode 100644 index 3ad40e5..0000000 --- a/app/templates/auth/enable_2fa.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Enable 2FA - TimeTracker{% endblock %} - -{% block content %} -
-
-
-
-
-

Enable Two-Factor Authentication

-
-
-
-

Scan this QR code with your authenticator app:

- QR Code -
- -
- Can't scan? Enter this code manually: {{ secret }} -
- -
- - -
- - - Enter the 6-digit code from your authenticator app -
- -
- - Cancel -
-
-
-
- -
-
-
Recommended Authenticator Apps:
-
    -
  • Google Authenticator (iOS/Android)
  • -
  • Microsoft Authenticator (iOS/Android)
  • -
  • Authy (iOS/Android/Desktop)
  • -
  • 1Password (Cross-platform)
  • -
-
-
-
-
-
-{% endblock %} - diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html deleted file mode 100644 index bee9b6b..0000000 --- a/app/templates/auth/forgot_password.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Forgot Password - TimeTracker{% endblock %} - -{% block content %} -
-
-
-
-
-
-

Reset Password

-

Enter your email to receive a reset link

-
- -
- - -
- - -
- - - -
-

Remember your password? Log in

-
-
-
-
-
-
-
- - -{% endblock %} - diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index a1fc953..6c92e2a 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -551,29 +551,6 @@ -
- -
- - -
- - {{ _('Leave blank for passwordless login if enabled') }} - -
- -
- -
- - - - - - - - - - - -{% endblock %} - diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html deleted file mode 100644 index 36b9361..0000000 --- a/app/templates/auth/settings.html +++ /dev/null @@ -1,200 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Account Settings - TimeTracker{% endblock %} - -{% block content %} -
-
- - -
-
- -
-
-
-
Profile Information
-
-
-
- - -
- - -
- -
- - - {% if current_user.email %} - - {% if current_user.email_verified %} - Verified - {% else %} - Not Verified - {% endif %} - - {% endif %} -
- -
- - -
- -
- - -
- - -
-
-
-
- - -
- -
-
-
Change Email
-
-
-
- - -
- - -
- -
- - -
- - -
-
-
- - -
-
-
Change Password
-
-
-
- - -
- - -
- -
- - -
- -
- - -
- - -
-
-
- - -
-
-
Two-Factor Authentication
-
-
- {% if current_user.totp_enabled %} -
- 2FA is currently enabled -
-
- -
- - -
- -
- {% else %} -

Add an extra layer of security to your account with two-factor authentication.

- Enable 2FA - {% endif %} -
-
-
- - -
-
-
-
Active Sessions
-
-
- {% if active_tokens %} -
- {% for token in active_tokens %} -
-
-
-
{{ token.device_name or 'Unknown Device' }}
-

- {{ token.ip_address or 'Unknown location' }} -

-

- Last active: {{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Unknown' }} -

-
-
- - -
-
-
- {% endfor %} -
- {% else %} -

No active sessions

- {% endif %} -
-
-
-
-
-
-
-{% endblock %} - diff --git a/app/templates/auth/signup.html b/app/templates/auth/signup.html deleted file mode 100644 index 7767fd2..0000000 --- a/app/templates/auth/signup.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Sign Up - TimeTracker{% endblock %} - -{% block content %} -
-
-
-
-
-
-

Create Account

-

Join TimeTracker today

-
- - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - -
- - -
- - - At least 3 characters -
- -
- - -
- -
- - -
- -
- - - Must be at least 12 characters, with uppercase, lowercase, number, and special character -
- -
- - -
- - - -
-

Already have an account? Log in

-
-
-
-
-
-
-
- - -{% endblock %} - diff --git a/app/templates/auth/verify_2fa.html b/app/templates/auth/verify_2fa.html deleted file mode 100644 index 768d581..0000000 --- a/app/templates/auth/verify_2fa.html +++ /dev/null @@ -1,106 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Two-Factor Authentication - TimeTracker{% endblock %} - -{% block content %} -
-
-
-
-
-
-

Two-Factor Authentication

-

Enter the code from your authenticator app

-
- -
- - - -
- - -
- - - -
- -
-
- - - -
-
-
-
-
- - - - -{% endblock %} - diff --git a/app/templates/billing/action_required.html b/app/templates/billing/action_required.html deleted file mode 100644 index c0416c2..0000000 --- a/app/templates/billing/action_required.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - Payment Action Required - - -
-

⚡ Payment Action Required

-
- -
-

Hello {{ user.display_name }},

- -

Your payment for {{ organization.name }} requires additional authentication.

- -
-

Your bank requires you to confirm this payment (e.g., 3D Secure, two-factor authentication).

-
- -

What you need to do:

-
    -
  1. Click the button below to complete authentication
  2. -
  3. Follow your bank's instructions
  4. -
  5. Complete the verification process
  6. -
- - - -

Why is this required?

-

Strong Customer Authentication (SCA) is a European regulation that requires additional verification for online payments to reduce fraud and make online payments more secure.

- -

Need help?

-

If you have any questions or encounter any issues, please contact our support team.

- -

Best regards,
- The TimeTracker Team

-
- -
-

This is an automated message from TimeTracker

-
- - - diff --git a/app/templates/billing/index.html b/app/templates/billing/index.html deleted file mode 100644 index f90c436..0000000 --- a/app/templates/billing/index.html +++ /dev/null @@ -1,308 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Billing & Subscription{% endblock %} - -{% block content %} -
-
-
-

- Billing & Subscription -

-
-
- - -
-
-
-
-
Current Subscription
-
-
- {% if organization.has_active_subscription %} -
-
-

{{ organization.subscription_plan_display }}

-

- {% if organization.subscription_plan == 'team' %} - {{ organization.subscription_quantity }} seat(s) - {% endif %} -

-
-
- {% if organization.is_on_trial %} - - Trial: {{ organization.trial_days_remaining }} days left - - {% else %} - - Active - - {% endif %} -
-
- - {% if organization.has_billing_issue %} -
- - Billing Issue Detected -

There was a problem with your last payment. Please update your payment method.

-
- {% endif %} - -
-
-

Status: {{ organization.stripe_subscription_status|title }}

-

Members: {{ organization.member_count }}

- {% if organization.subscription_plan == 'team' %} -

Seats: {{ organization.subscription_quantity }}

- {% endif %} -
-
- {% if organization.next_billing_date %} -

Next Billing Date: {{ organization.next_billing_date.strftime('%B %d, %Y') }}

- {% endif %} - {% if organization.is_on_trial and organization.trial_ends_at %} -

Trial Ends: {{ organization.trial_ends_at.strftime('%B %d, %Y') }}

- {% endif %} -
-
- -
- - Manage Subscription - - {% if organization.subscription_plan == 'team' %} - - {% endif %} -
- {% else %} - -
- -

No Active Subscription

-

Choose a plan to get started

- -
-
-
-
-
Single User
-

€5/month

-
    -
  • 1 User
  • -
  • Unlimited Projects
  • -
  • Time Tracking
  • -
  • Reports & Invoicing
  • -
- - Subscribe - -
-
-
- -
-
-
- Popular -
Team
-

€6/user/month

-
    -
  • Unlimited Users
  • -
  • Unlimited Projects
  • -
  • Time Tracking
  • -
  • Reports & Invoicing
  • -
  • Team Collaboration
  • -
- - Subscribe - -
-
-
-
-
- {% endif %} -
-
-
- - -
-
-
-
Usage Summary
-
-
-
-
- Members - {{ organization.member_count }} -
- {% if organization.subscription_plan == 'team' and organization.subscription_quantity %} -
-
-
-
- {{ organization.subscription_quantity - organization.member_count }} seat(s) available - {% endif %} -
- -
-
- Projects - {{ organization.project_count }} -
-
- - {% if upcoming_invoice %} -
-
Next Invoice
-
- Amount - {{ upcoming_invoice.currency }} {{ "%.2f"|format(upcoming_invoice.amount_due) }} -
-
- Due: {{ upcoming_invoice.period_end.strftime('%b %d, %Y') }} -
- {% endif %} -
-
- - - {% if payment_methods %} -
-
-
Payment Method
-
-
- {% for pm in payment_methods %} -
- -
-
{{ pm.card.brand|upper }} •••• {{ pm.card.last4 }}
- Expires {{ pm.card.exp_month }}/{{ pm.card.exp_year }} -
-
- {% endfor %} - - Update Payment Method - -
-
- {% endif %} -
-
- - - {% if invoices %} -
-
-
-
-
Recent Invoices
-
-
-
- - - - - - - - - - - - {% for invoice in invoices %} - - - - - - - - {% endfor %} - -
InvoiceDateAmountStatusActions
{{ invoice.number or invoice.id[:8] }}{{ invoice.created.strftime('%b %d, %Y') }}{{ invoice.currency|upper }} {{ "%.2f"|format(invoice.amount_paid) }} - {% if invoice.paid %} - Paid - {% elif invoice.status == 'open' %} - Open - {% else %} - {{ invoice.status|title }} - {% endif %} - - {% if invoice.invoice_pdf %} - - PDF - - {% endif %} - {% if invoice.hosted_invoice_url %} - - View - - {% endif %} -
-
-
-
-
-
- {% endif %} -
- - - - - -{% endblock %} - diff --git a/app/templates/billing/payment_failed.html b/app/templates/billing/payment_failed.html deleted file mode 100644 index f3853aa..0000000 --- a/app/templates/billing/payment_failed.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - Payment Failed - - -
-

⚠️ Payment Failed

-
- -
-

Hello {{ user.display_name }},

- -

We were unable to process your payment for {{ organization.name }}.

- -
-

Invoice Amount: {{ invoice.currency|upper }} {{ "%.2f"|format(invoice.amount_due / 100) }}

-

Attempt Count: {{ invoice.attempt_count }}

-
- -

What happens next?

-
    -
  • We'll automatically retry the payment in a few days
  • -
  • Your service will continue during this grace period
  • -
  • If payment continues to fail, your subscription may be suspended
  • -
- -

Action Required:

-

Please update your payment method to avoid service interruption:

- - - -

If you have any questions or need assistance, please don't hesitate to contact us.

- -

Best regards,
- The TimeTracker Team

-
- -
-

This is an automated message from TimeTracker

-
- - - diff --git a/app/templates/billing/subscription_cancelled.html b/app/templates/billing/subscription_cancelled.html deleted file mode 100644 index 08c2d19..0000000 --- a/app/templates/billing/subscription_cancelled.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - Subscription Cancelled - - -
-

Subscription Cancelled

-
- -
-

Hello {{ user.display_name }},

- -

We're writing to let you know that your subscription for {{ organization.name }} has been cancelled.

- -
-

Organization: {{ organization.name }}

-

Status: Suspended

-
- -

What this means:

-
    -
  • Your account has been suspended
  • -
  • You no longer have access to premium features
  • -
  • Your data is safely stored and can be restored if you resubscribe
  • -
- -

Want to keep using TimeTracker?

-

You can reactivate your subscription at any time.

- - - -

If you cancelled by mistake or have any questions, please contact our support team.

- -

We're sorry to see you go!
- The TimeTracker Team

-
- -
-

This is an automated message from TimeTracker

-
- - - diff --git a/app/templates/billing/trial_ending.html b/app/templates/billing/trial_ending.html deleted file mode 100644 index 197d016..0000000 --- a/app/templates/billing/trial_ending.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - Your Trial is Ending Soon - - -
-

⏰ Your Trial is Ending Soon

-
- -
-

Hello {{ user.display_name }},

- -

Your free trial for {{ organization.name }} is ending soon!

- -
-

{{ days_remaining }} Days Remaining

-
- -

What happens when the trial ends?

-
    -
  • Your subscription will automatically convert to a paid plan
  • -
  • You'll be charged based on your current plan and number of users
  • -
  • All your data and settings will be preserved
  • -
- -

Current Plan: {{ organization.subscription_plan_display }}

- - - -

Want to make changes? You can:

-
    -
  • Change your plan
  • -
  • Update payment methods
  • -
  • Adjust the number of seats
  • -
- -

If you have any questions, please contact us. We're here to help!

- -

Best regards,
- The TimeTracker Team

-
- -
-

This is an automated message from TimeTracker

-
- - - diff --git a/app/templates/components/onboarding_widget.html b/app/templates/components/onboarding_widget.html deleted file mode 100644 index 98622bf..0000000 --- a/app/templates/components/onboarding_widget.html +++ /dev/null @@ -1,85 +0,0 @@ -{% if checklist and not checklist.dismissed and not checklist.is_complete %} -
-
-
-
- - {{ _('Getting Started') }} -
- - {{ checklist.completion_percentage }}% {{ _('Complete') }} - -
- -
-
-
-
- -

- {{ _('Complete these tasks to get the most out of TimeTracker:') }} -

- - {% set next_task = checklist.get_next_task() %} - {% if next_task %} -
-
- -
-
-
- {{ _('Next:') }} {{ _(next_task.title) }} -
-
- {{ _(next_task.description) }} -
-
-
- {% endif %} - - -
-
- - -{% endif %} - diff --git a/app/templates/components/trial_banner.html b/app/templates/components/trial_banner.html deleted file mode 100644 index 0337efc..0000000 --- a/app/templates/components/trial_banner.html +++ /dev/null @@ -1,86 +0,0 @@ -{% if organization and organization.is_on_trial %} - -{% endif %} - -{% if organization and organization.has_billing_issue %} - -{% endif %} - diff --git a/app/templates/marketing/faq.html b/app/templates/marketing/faq.html deleted file mode 100644 index 636c39e..0000000 --- a/app/templates/marketing/faq.html +++ /dev/null @@ -1,718 +0,0 @@ - - - - - - - FAQ - TimeTracker - - - - - - - - - - - - - -
-
-

Frequently Asked Questions

-

Find answers to common questions about TimeTracker

-
-
- - -
-
- - - - -
-

Getting Started

- -
-
-

How do I get started with TimeTracker?

- -
-
-

Getting started is easy:

-
    -
  1. Cloud Hosted: Click "Start Free Trial" on our homepage, create an account, and start tracking immediately. No credit card required for the 14-day trial.
  2. -
  3. Self-Hosted: Clone the repository from GitHub, follow the Docker setup instructions in the README, and deploy to your own server.
  4. -
-

Both options include all features from day one.

-
-
- -
-
-

Do I need a credit card for the free trial?

- -
-
-

No! You can start using TimeTracker immediately without providing any payment information. After 14 days, you'll be prompted to add payment details if you want to continue using the cloud-hosted service.

-
-
- -
-
-

What's the difference between cloud-hosted and self-hosted?

- -
-
-

Cloud-Hosted (Paid):

-
    -
  • We host, maintain, and update everything
  • -
  • Automatic backups and 99.9% uptime SLA
  • -
  • Email support included
  • -
  • SSL/HTTPS included
  • -
  • Pay monthly per user
  • -
-

Self-Hosted (Free):

-
    -
  • You manage your own server
  • -
  • Full control over data and infrastructure
  • -
  • All features included at no cost
  • -
  • Community support via GitHub
  • -
  • You handle updates and backups
  • -
-
-
-
- - -
-

Privacy & Security

- -
-
-

Is my data secure and private?

- -
-
-

Absolutely! We take security and privacy very seriously:

-
    -
  • Encryption: All data is encrypted in transit (SSL/TLS) and at rest
  • -
  • GDPR Compliant: We follow all EU data protection regulations
  • -
  • No Data Sharing: We never sell or share your data with third parties
  • -
  • Regular Backups: Automated daily backups with point-in-time recovery
  • -
  • Access Control: Role-based permissions and secure authentication
  • -
-
- 💡 Maximum Privacy: For complete control, use the self-hosted option to keep all data on your own servers. -
-
-
- -
-
-

Where is my data stored?

- -
-
-

Cloud-Hosted: Data is stored in secure EU data centers (Frankfurt, Germany) to ensure GDPR compliance.

-

Self-Hosted: Data is stored wherever you deploy the application—your own server, cloud provider, or on-premises.

-
-
- -
-
-

Who can access my data?

- -
-
-

Only you and your team members. Each organization's data is completely isolated using row-level security. Our staff cannot access your data except in rare cases with your explicit permission for technical support.

-
-
- -
-
-

Do you use cookies or tracking?

- -
-
-

We use essential cookies only:

-
    -
  • Session cookies: Required for login and authentication
  • -
  • No marketing cookies: We don't use Google Analytics or other tracking
  • -
  • No third-party trackers: Your activity is private
  • -
-
-
-
- - -
-

Data Export & Portability

- -
-
-

Can I export my data?

- -
-
-

Yes! You can export all your data at any time:

-
    -
  • CSV Export: Export time entries, projects, invoices to CSV format
  • -
  • PDF Reports: Generate and download professional PDF reports
  • -
  • Full Data Export: Contact support for a complete database export
  • -
  • API Access: Use our REST API to programmatically export data
  • -
-

There is no vendor lock-in—your data is always yours to take with you.

-
-
- -
-
-

What format is the exported data in?

- -
-
-

Exports are available in:

-
    -
  • CSV: Compatible with Excel, Google Sheets, and most accounting software
  • -
  • PDF: Professional invoices and reports
  • -
  • JSON: Machine-readable format via API
  • -
-

All timestamps use ISO 8601 format for maximum compatibility.

-
-
- -
-
-

Can I import data from other time tracking tools?

- -
-
-

Yes! If your current tool exports to CSV, you can import that data. Contact support for assistance with bulk imports or migration from specific tools.

-
-
-
- - -
-

Pricing & Billing

- -
-
-

How does pricing work?

- -
-
-

We offer three options:

-
    -
  • Self-Hosted: Free forever with all features
  • -
  • Single User: €5/month for one user account
  • -
  • Team: €6/user/month with unlimited users
  • -
-

All cloud-hosted plans include automatic backups, updates, SSL, and support.

-
- 🎁 Early Adopter Discount: Use code EARLY2025 for 20% off your first 3 months! -
-
-
- -
-
-

What payment methods do you accept?

- -
-
-

We accept:

-
    -
  • All major credit cards (Visa, MasterCard, American Express)
  • -
  • Debit cards
  • -
  • SEPA direct debit (EU customers)
  • -
-

All payments are processed securely through Stripe.

-
-
- -
-
-

Can I upgrade or downgrade my plan?

- -
-
-

Yes! You can change your plan at any time:

-
    -
  • Upgrade: Immediate access to new features, prorated billing
  • -
  • Add/Remove Users: Changes take effect immediately with automatic proration
  • -
  • Downgrade: Applies at next billing cycle, unused time credited
  • -
-

All changes are handled automatically through your billing portal.

-
-
- -
-
-

Do you offer annual billing?

- -
-
-

Yes! Annual billing includes a 15% discount (equivalent to 2 months free). Combined with the early adopter discount, you can save significantly!

-
-
-
- - -
-

Refunds & Cancellation

- -
-
-

What's your refund policy?

- -
-
-

We offer a 30-day money-back guarantee:

-
    -
  • If you're not satisfied within 30 days, contact us for a full refund
  • -
  • No questions asked—we want happy customers
  • -
  • Refunds typically processed within 5-7 business days
  • -
-

The free trial doesn't count toward the 30 days—it starts from your first paid billing cycle.

-
-
- -
-
-

How do I cancel my subscription?

- -
-
-

You can cancel anytime:

-
    -
  1. Go to Settings → Billing
  2. -
  3. Click "Cancel Subscription"
  4. -
  5. Confirm cancellation
  6. -
-

Your account remains active until the end of your billing period. After cancellation, you can export all your data before the account closes.

-
-
- -
-
-

What happens to my data after I cancel?

- -
-
-

After cancellation:

-
    -
  • 30 days: Your account is frozen but data is retained
  • -
  • During freeze: You can reactivate or export data anytime
  • -
  • After 30 days: Data is permanently deleted per GDPR requirements
  • -
-

We recommend exporting your data before canceling to ensure you have a backup.

-
-
-
- - -
-

VAT & Invoicing

- -
-
-

How does VAT work for EU customers?

- -
-
-

VAT is handled automatically:

-
    -
  • Individuals: VAT is applied based on your country
  • -
  • EU Businesses: Provide your VAT ID for reverse charge (no VAT charged)
  • -
  • Non-EU: No VAT applied
  • -
-

All invoices include proper VAT information and are compliant with local regulations.

-
-
- -
-
-

Do you provide invoices?

- -
-
-

Yes! Invoices are generated automatically:

-
    -
  • Sent via email after each billing cycle
  • -
  • Available in your billing portal
  • -
  • Include all required tax information
  • -
  • PDF format, ready for accounting
  • -
-
-
- -
-
-

Can I add my company details to invoices?

- -
-
-

Yes! In your billing settings, you can add:

-
    -
  • Company name
  • -
  • Billing address
  • -
  • VAT ID
  • -
  • Tax registration number
  • -
-

These details will appear on all future invoices.

-
-
-
- - -
-

Support & Help

- -
-
-

What kind of support do you provide?

- -
-
-

Support varies by plan:

-
    -
  • Self-Hosted: Community support via GitHub issues and discussions
  • -
  • Single User: Email support with 24-48 hour response time
  • -
  • Team: Priority email support (12-24 hours) + video call onboarding
  • -
-

All plans include access to our comprehensive documentation and knowledge base.

-
-
- -
-
-

Do you offer training or onboarding?

- -
-
-

Team plan subscribers receive:

-
    -
  • One-on-one onboarding call (30 minutes)
  • -
  • Walkthrough of all features
  • -
  • Help setting up projects and workflows
  • -
  • Best practices guide
  • -
-

For larger teams (10+ users), we can provide custom training sessions.

-
-
- -
-
-

Is there a system status page?

- -
-
-

Yes! Visit status.timetracker.com to see:

-
    -
  • Current system status
  • -
  • Uptime statistics
  • -
  • Scheduled maintenance
  • -
  • Incident history
  • -
-
-
-
-
-
- - -
-
-

Still have questions?

-

We're here to help! Contact our support team or start your free trial today.

- -
-
- - - - - - - - - - diff --git a/app/templates/marketing/landing.html b/app/templates/marketing/landing.html deleted file mode 100644 index 2908036..0000000 --- a/app/templates/marketing/landing.html +++ /dev/null @@ -1,989 +0,0 @@ - - - - - - - - TimeTracker - Professional Time Tracking Made Simple - - - - - - - - - - - - - -
-
-

Professional Time Tracking Made Simple

-

Track time, manage projects, and generate invoices with ease. Self-hosted or cloud-hosted.

- -

- 14-day free trial • No credit card required • Cancel anytime -

-
-
- - -
-
-
-

Everything You Need to Track Time

-

Powerful features for freelancers, teams, and businesses

-
- -
-
-
- -

Smart Time Tracking

-

Automatic timers with idle detection, manual entry, and real-time updates. Never lose track of your time.

-
-
- -
-
- -

Project Management

-

Organize work by clients and projects with billing rates, estimates, and progress tracking.

-
-
- -
-
- -

Professional Invoicing

-

Generate branded PDF invoices with customizable layouts and automatic calculations.

-
-
- -
-
- -

Analytics & Reports

-

Comprehensive reports with visual analytics, trends, and data export capabilities.

-
-
- -
-
- -

Team Collaboration

-

Multi-user support with role-based access control and team productivity metrics.

-
-
- -
-
- -

Mobile Optimized

-

Responsive design that works perfectly on all devices - desktop, tablet, and mobile.

-
-
-
-
-
- - -
-
-
-

Simple, Transparent Pricing

-

Choose the plan that fits your needs

-
- Early Adopter Discount: Use code EARLY2025 for 20% off first 3 months! -
-
- -
- -
-
-

Self-Hosted

-
- Free -
Forever -
-
    -
  • Unlimited users
  • -
  • All features included
  • -
  • Full data control
  • -
  • No monthly fees
  • -
  • Docker support
  • -
  • Self-manage updates
  • -
  • Self-manage backups
  • -
  • Community support only
  • -
- - Get Started - -
-
- - -
-
-

Single User

-
- €5 -
/month -
-
    -
  • 1 user account
  • -
  • All features included
  • -
  • Cloud hosted
  • -
  • Automatic backups
  • -
  • Automatic updates
  • -
  • Email support
  • -
  • 99.9% uptime SLA
  • -
  • SSL included
  • -
- - Start Free Trial - -

14-day free trial

-
-
- - -
- -
-
- - -
-
-

Detailed Feature Comparison

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FeatureSelf-HostedSingle UserTeam
Time Tracking
Project Management
Professional Invoicing
Reports & Analytics
Number of UsersUnlimited1Unlimited
Cloud Hosting
Automatic Backups
Automatic Updates
Email SupportCommunityStandardPriority
99.9% Uptime SLA
Custom Branding
-
-
-
-
- - - - - -
-
-
-

Frequently Asked Questions

-
- -
-
-
-
-

- How does the 14-day free trial work? - -

-
-
-

Start using TimeTracker immediately with full access to all features. No credit card required to start. After 14 days, you'll be prompted to add payment information to continue using the service. Cancel anytime during the trial with no charges.

-
-
- -
-
-

- What's the difference between self-hosted and cloud-hosted? - -

-
-
-

Self-hosted: Download and run TimeTracker on your own server. You manage updates, backups, and maintenance. Great for maximum control and privacy.

-

Cloud-hosted: We handle everything - hosting, updates, backups, security, and support. Just sign up and start tracking time immediately.

-
-
- -
-
-

- Is my data secure and private? - -

-
-
-

Yes! We take security seriously. All data is encrypted in transit (SSL/TLS) and at rest. We're GDPR compliant and never share your data with third parties. For maximum privacy, use the self-hosted option to keep all data on your own servers.

-
-
- -
-
-

- Can I export my data? - -

-
-
-

Absolutely! You can export all your time entries, projects, and reports in CSV format at any time. No vendor lock-in - your data is always yours to take with you.

-
-
- -
-
-

- What payment methods do you accept? - -

-
-
-

We accept all major credit cards (Visa, MasterCard, American Express) through our secure payment processor Stripe. We also support SEPA direct debit for EU customers.

-
-
- -
-
-

- How does VAT work for EU customers? - -

-
-
-

VAT is automatically calculated based on your location. If you're a business in the EU, you can provide your VAT ID for reverse charge. All invoices include proper VAT information for your accounting needs.

-
-
- -
-
-

- What's your refund policy? - -

-
-
-

We offer a 30-day money-back guarantee. If you're not satisfied with TimeTracker within the first 30 days, contact us for a full refund. No questions asked.

-
-
- -
-
-

- Can I upgrade or downgrade my plan? - -

-
-
-

Yes! You can upgrade from Single User to Team at any time. For Team plans, you can add or remove users as needed - billing is automatically prorated. Downgrading is also possible with unused time credited to your account.

-
-
- -
-
-

- Do you offer discounts for annual plans? - -

-
-
-

Yes! Annual plans receive a 15% discount. Pay yearly and save approximately 2 months of fees. Plus, use code EARLY2025 for an additional 20% off your first year as an early adopter!

-
-
- -
-
-

- What support do you provide? - -

-
-
-

Self-hosted: Community support via GitHub issues and discussions.

-

Single User: Email support with 24-48 hour response time.

-

Team: Priority email support with 12-24 hour response time, plus video call support for onboarding.

-
-
-
-
-
-
- - -
-
-

Ready to Take Control of Your Time?

-

Start your 14-day free trial today. No credit card required.

- -
-
- - - - - - - - - - diff --git a/app/templates/onboarding/checklist.html b/app/templates/onboarding/checklist.html deleted file mode 100644 index 7ebcc56..0000000 --- a/app/templates/onboarding/checklist.html +++ /dev/null @@ -1,390 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Onboarding Checklist') }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-

{{ _('Welcome to TimeTracker!') }}

-

{{ _('Let\'s get you started with') }} {{ organization.name }}

- - -
-
-
- -
-
- {{ _('Progress:') }} {{ checklist.get_completed_count() }} / {{ checklist.get_total_count() }} {{ _('tasks completed') }} -
-
- {% if checklist.is_complete %} - - {{ _('Complete!') }} - - {% else %} - - {{ checklist.completion_percentage }}% {{ _('Complete') }} - - {% endif %} -
-
-
- - - {% if checklist.dismissed %} -
- {{ _('This checklist has been dismissed. You can still complete tasks!') }} - -
- {% endif %} - - - {% if checklist.is_complete %} -
- {{ _('Congratulations!') }} - {{ _('You\'ve completed all onboarding tasks. You\'re all set!') }} -
- {% endif %} - - -
-
-

{{ _('Getting Started Checklist') }}

- {% if not checklist.dismissed and not checklist.is_complete %} - - {% endif %} -
- - {% for task in tasks %} -
-
- {% if task.completed %} - - {% endif %} -
- -
-
- - {{ _(task.title) }} - {{ _(task.category) }} -
- -
- {{ _(task.description) }} -
- - {% if not task.completed %} -
- {% if task.key == 'invited_team_member' %} - - {{ _('Invite Team Member') }} - - {% elif task.key == 'created_project' %} - - {{ _('Create Project') }} - - {% elif task.key == 'created_time_entry' %} - - {{ _('Start Timer') }} - - {% elif task.key == 'created_client' %} - - {{ _('Add Client') }} - - {% elif task.key == 'added_billing_info' %} - - {{ _('Go to Billing') }} - - {% elif task.key == 'generated_report' %} - - {{ _('View Reports') }} - - {% endif %} -
- {% else %} -
- {{ _('Completed') }} {{ task.completed_at }} -
- {% endif %} -
-
- {% endfor %} -
- - -
-

- {{ _('Need help getting started?') }} - {{ _('View the complete guide') }} -

-
-
- - -{% endblock %} - diff --git a/app/templates/onboarding/welcome.html b/app/templates/onboarding/welcome.html deleted file mode 100644 index 39bd602..0000000 --- a/app/templates/onboarding/welcome.html +++ /dev/null @@ -1,159 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Welcome!') }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

🎉 {{ _('Welcome to TimeTracker!') }}

-

{{ _('Your organization') }} {{ organization.name }} {{ _('is ready to go!') }}

-
- - {% if organization.is_on_trial %} -
-
- -
-
- {{ _('Free Trial Active') }}
- {{ _('You have') }} {{ organization.trial_days_remaining }} {{ _('days') }} {{ _('left in your trial. Explore all features!') }} -
-
- {% endif %} - -

{{ _('Quick Start Guide') }}

- - - - -
-{% endblock %} - diff --git a/app/utils/billing_gates.py b/app/utils/billing_gates.py deleted file mode 100644 index 516525d..0000000 --- a/app/utils/billing_gates.py +++ /dev/null @@ -1,368 +0,0 @@ -""" -Billing Gates and Subscription Checks - -This module provides decorators and utilities for enforcing subscription -requirements and limits throughout the application. -""" - -from functools import wraps -from flask import redirect, url_for, flash, request, current_app -from flask_login import current_user -from app.models.organization import Organization -from app.utils.tenancy import get_current_organization - - -def require_active_subscription(allow_trial=True, redirect_to='billing.index'): - """Decorator to require an active subscription for a route. - - Args: - allow_trial: Whether to allow trial subscriptions (default: True) - redirect_to: Route name to redirect to if check fails - - Usage: - @app.route('/premium-feature') - @login_required - @require_active_subscription() - def premium_feature(): - return render_template('premium.html') - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - org = get_current_organization() - - if not org: - flash('No organization selected', 'warning') - return redirect(url_for('organizations.index')) - - # Check if subscription is active - if not org.has_active_subscription: - flash('This feature requires an active subscription. Please upgrade your plan.', 'warning') - return redirect(url_for(redirect_to)) - - # Check trial if not allowed - if not allow_trial and org.is_on_trial: - flash('This feature is not available during trial period. Please upgrade to a paid plan.', 'warning') - return redirect(url_for(redirect_to)) - - # Check for billing issues - if org.has_billing_issue: - flash('Your account has a billing issue. Please update your payment method to continue.', 'danger') - return redirect(url_for(redirect_to)) - - return f(*args, **kwargs) - - return decorated_function - return decorator - - -def require_plan(min_plan='single_user', redirect_to='billing.index'): - """Decorator to require a specific subscription plan or higher. - - Plan hierarchy: free < single_user < team < enterprise - - Args: - min_plan: Minimum required plan ('single_user', 'team', 'enterprise') - redirect_to: Route name to redirect to if check fails - - Usage: - @app.route('/team-feature') - @login_required - @require_plan('team') - def team_feature(): - return render_template('team.html') - """ - plan_hierarchy = { - 'free': 0, - 'single_user': 1, - 'team': 2, - 'enterprise': 3 - } - - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - org = get_current_organization() - - if not org: - flash('No organization selected', 'warning') - return redirect(url_for('organizations.index')) - - current_level = plan_hierarchy.get(org.subscription_plan, 0) - required_level = plan_hierarchy.get(min_plan, 1) - - if current_level < required_level: - flash(f'This feature requires a {min_plan.replace("_", " ").title()} plan or higher. Please upgrade.', 'warning') - return redirect(url_for(redirect_to)) - - return f(*args, **kwargs) - - return decorated_function - return decorator - - -def check_user_limit(organization): - """Check if organization can add more users. - - Args: - organization: Organization instance - - Returns: - dict with 'allowed', 'current', 'limit', 'message' - """ - if not organization: - return { - 'allowed': False, - 'current': 0, - 'limit': 0, - 'message': 'No organization found' - } - - current_count = organization.member_count - - # For team plans, check against subscription quantity - if organization.subscription_plan == 'team': - limit = organization.subscription_quantity - - if current_count >= limit: - return { - 'allowed': False, - 'current': current_count, - 'limit': limit, - 'message': f'User limit reached ({limit} users). Upgrade seats to add more users.', - 'can_upgrade': True - } - - return { - 'allowed': True, - 'current': current_count, - 'limit': limit, - 'remaining': limit - current_count, - 'message': f'{limit - current_count} user slot(s) available' - } - - # For single user plan - if organization.subscription_plan == 'single_user': - if current_count >= 1: - return { - 'allowed': False, - 'current': current_count, - 'limit': 1, - 'message': 'Single User plan supports only 1 user. Upgrade to Team plan for multiple users.', - 'can_upgrade': True, - 'upgrade_to': 'team' - } - - return { - 'allowed': True, - 'current': current_count, - 'limit': 1, - 'message': 'Can add 1 user' - } - - # For free plan or others, check max_users - if organization.max_users is not None: - if current_count >= organization.max_users: - return { - 'allowed': False, - 'current': current_count, - 'limit': organization.max_users, - 'message': f'User limit reached ({organization.max_users} users). Upgrade to add more users.', - 'can_upgrade': True - } - - # No limit - return { - 'allowed': True, - 'current': current_count, - 'limit': organization.max_users, - 'message': 'Can add users' - } - - -def check_project_limit(organization): - """Check if organization can add more projects. - - Args: - organization: Organization instance - - Returns: - dict with 'allowed', 'current', 'limit', 'message' - """ - if not organization: - return { - 'allowed': False, - 'current': 0, - 'limit': 0, - 'message': 'No organization found' - } - - current_count = organization.project_count - - # Free plan limits - if organization.subscription_plan == 'free': - limit = organization.max_projects or 3 # Default limit for free - - if current_count >= limit: - return { - 'allowed': False, - 'current': current_count, - 'limit': limit, - 'message': f'Project limit reached ({limit} projects). Upgrade to create more projects.', - 'can_upgrade': True - } - - return { - 'allowed': True, - 'current': current_count, - 'limit': limit, - 'remaining': limit - current_count, - 'message': f'{limit - current_count} project slot(s) available' - } - - # Paid plans - check max_projects if set - if organization.max_projects is not None: - if current_count >= organization.max_projects: - return { - 'allowed': False, - 'current': current_count, - 'limit': organization.max_projects, - 'message': f'Project limit reached ({organization.max_projects} projects)' - } - - # No limit for paid plans - return { - 'allowed': True, - 'current': current_count, - 'limit': organization.max_projects, - 'message': 'Can create projects' - } - - -def check_feature_access(organization, feature_name): - """Check if organization has access to a specific feature. - - Args: - organization: Organization instance - feature_name: Name of feature to check - - Returns: - dict with 'allowed', 'reason', 'upgrade_required' - """ - if not organization: - return { - 'allowed': False, - 'reason': 'No organization found', - 'upgrade_required': False - } - - # Define feature access matrix - features = { - 'advanced_reports': ['team', 'enterprise'], - 'api_access': ['team', 'enterprise'], - 'custom_branding': ['enterprise'], - 'priority_support': ['team', 'enterprise'], - 'integrations': ['team', 'enterprise'], - 'audit_logs': ['enterprise'], - 'sso': ['enterprise'], - } - - allowed_plans = features.get(feature_name, []) - - if not allowed_plans: - # Feature not defined, allow access - return { - 'allowed': True, - 'reason': 'Feature available to all' - } - - if organization.subscription_plan in allowed_plans: - return { - 'allowed': True, - 'reason': f'Available on {organization.subscription_plan_display} plan' - } - - return { - 'allowed': False, - 'reason': f'Requires {" or ".join([p.replace("_", " ").title() for p in allowed_plans])} plan', - 'upgrade_required': True, - 'required_plans': allowed_plans - } - - -def get_subscription_warning(organization): - """Get any warnings related to subscription status. - - Args: - organization: Organization instance - - Returns: - dict with 'type' ('warning', 'danger', 'info'), 'message', or None - """ - if not organization: - return None - - # Billing issue - if organization.has_billing_issue: - return { - 'type': 'danger', - 'message': 'Payment failed. Please update your payment method to avoid service interruption.', - 'action_url': url_for('billing.portal'), - 'action_text': 'Update Payment Method' - } - - # Trial ending soon (< 3 days) - if organization.is_on_trial and organization.trial_days_remaining <= 3: - return { - 'type': 'warning', - 'message': f'Your trial ends in {organization.trial_days_remaining} day(s). Upgrade to continue using premium features.', - 'action_url': url_for('billing.index'), - 'action_text': 'View Plans' - } - - # Subscription ending soon - if organization.subscription_ends_at: - from datetime import datetime - days_until_end = (organization.subscription_ends_at - datetime.utcnow()).days - - if days_until_end <= 7: - return { - 'type': 'warning', - 'message': f'Your subscription ends in {days_until_end} day(s). Renew to continue service.', - 'action_url': url_for('billing.index'), - 'action_text': 'Manage Subscription' - } - - # Near user limit (> 90% capacity) - if organization.subscription_plan == 'team' and organization.subscription_quantity: - usage_percent = (organization.member_count / organization.subscription_quantity) * 100 - - if usage_percent >= 90: - return { - 'type': 'info', - 'message': f'You\'re using {organization.member_count} of {organization.subscription_quantity} seats. Consider adding more seats.', - 'action_url': url_for('billing.portal'), - 'action_text': 'Add Seats' - } - - return None - - -# Context processor to inject billing info into all templates -def inject_billing_context(): - """Inject billing context into all templates.""" - if not current_user.is_authenticated: - return {} - - org = get_current_organization() - if not org: - return {} - - return { - 'subscription_warning': get_subscription_warning(org), - 'has_active_subscription': org.has_active_subscription, - 'has_billing_issue': org.has_billing_issue, - 'is_on_trial': org.is_on_trial, - 'trial_days_remaining': org.trial_days_remaining if org.is_on_trial else 0, - } - diff --git a/app/utils/data_retention.py b/app/utils/data_retention.py deleted file mode 100644 index 05d3786..0000000 --- a/app/utils/data_retention.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -Data Retention Policy Enforcement - -This module provides utilities for enforcing data retention policies -and cleaning up old data according to configured rules. -""" - -from datetime import datetime, timedelta -from flask import current_app -from app import db -from app.models import TimeEntry, Task, Invoice, Organization -from app.utils.gdpr import GDPRDeleter - - -class DataRetentionPolicy: - """Enforce data retention policies""" - - @staticmethod - def cleanup_old_data(): - """ - Clean up data that has exceeded retention period. - This should be run as a scheduled task (e.g., daily cron job). - - Returns: - dict: Summary of cleanup operations - """ - retention_days = current_app.config.get('DATA_RETENTION_DAYS', 0) - - summary = { - 'retention_days': retention_days, - 'cleanup_date': datetime.utcnow().isoformat(), - 'items_cleaned': { - 'time_entries': 0, - 'completed_tasks': 0, - 'old_invoices': 0, - 'pending_deletions': 0, - }, - 'errors': [] - } - - if retention_days == 0: - current_app.logger.info("Data retention not configured, skipping cleanup") - return summary - - cutoff_date = datetime.utcnow() - timedelta(days=retention_days) - current_app.logger.info(f"Starting data retention cleanup. Cutoff date: {cutoff_date}") - - try: - # Clean up old completed time entries - # Only delete entries that are completed and not associated with unpaid invoices - old_entries = TimeEntry.query.filter( - TimeEntry.created_at < cutoff_date, - TimeEntry.end_time.isnot(None) - ).all() - - entries_to_delete = [] - for entry in old_entries: - # Check if entry is part of an unpaid invoice - if not DataRetentionPolicy._is_entry_in_unpaid_invoice(entry): - entries_to_delete.append(entry) - - for entry in entries_to_delete: - db.session.delete(entry) - - summary['items_cleaned']['time_entries'] = len(entries_to_delete) - - except Exception as e: - current_app.logger.error(f"Error cleaning up time entries: {e}") - summary['errors'].append(f"Time entries cleanup failed: {str(e)}") - - try: - # Clean up old completed tasks - old_tasks = Task.query.filter( - Task.created_at < cutoff_date, - Task.status.in_(['completed', 'cancelled']) - ).all() - - for task in old_tasks: - db.session.delete(task) - - summary['items_cleaned']['completed_tasks'] = len(old_tasks) - - except Exception as e: - current_app.logger.error(f"Error cleaning up tasks: {e}") - summary['errors'].append(f"Tasks cleanup failed: {str(e)}") - - try: - # Clean up very old draft invoices - # Keep paid/sent invoices for longer (e.g., 7 years for tax purposes) - draft_cutoff = datetime.utcnow() - timedelta(days=90) # Delete drafts older than 90 days - - old_drafts = Invoice.query.filter( - Invoice.created_at < draft_cutoff, - Invoice.status == 'draft' - ).all() - - for invoice in old_drafts: - db.session.delete(invoice) - - summary['items_cleaned']['old_invoices'] = len(old_drafts) - - except Exception as e: - current_app.logger.error(f"Error cleaning up invoices: {e}") - summary['errors'].append(f"Invoices cleanup failed: {str(e)}") - - try: - # Process pending organization deletions - pending_deletions = Organization.query.filter( - Organization.status == 'pending_deletion', - Organization.deleted_at <= datetime.utcnow() - ).all() - - deleted_count = 0 - for org in pending_deletions: - try: - GDPRDeleter.execute_organization_deletion(org.id) - deleted_count += 1 - except Exception as e: - current_app.logger.error(f"Failed to delete organization {org.id}: {e}") - summary['errors'].append(f"Organization {org.id} deletion failed: {str(e)}") - - summary['items_cleaned']['pending_deletions'] = deleted_count - - except Exception as e: - current_app.logger.error(f"Error processing pending deletions: {e}") - summary['errors'].append(f"Pending deletions processing failed: {str(e)}") - - # Commit all changes - try: - db.session.commit() - current_app.logger.info(f"Data retention cleanup completed: {summary}") - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Failed to commit cleanup changes: {e}") - summary['errors'].append(f"Commit failed: {str(e)}") - - return summary - - @staticmethod - def _is_entry_in_unpaid_invoice(time_entry): - """ - Check if a time entry is part of an unpaid invoice. - - Args: - time_entry: TimeEntry object - - Returns: - bool: True if entry is in an unpaid invoice - """ - from app.models import InvoiceItem - - # Find invoice items that reference this time entry - invoice_items = InvoiceItem.query.filter( - InvoiceItem.time_entry_ids.contains(str(time_entry.id)) - ).all() - - for item in invoice_items: - if item.invoice and item.invoice.status in ['sent', 'overdue']: - return True - - return False - - @staticmethod - def get_retention_summary(): - """ - Get a summary of data subject to retention policies. - - Returns: - dict: Summary of retainable data - """ - retention_days = current_app.config.get('DATA_RETENTION_DAYS', 0) - - if retention_days == 0: - return { - 'enabled': False, - 'message': 'Data retention policies are not configured' - } - - cutoff_date = datetime.utcnow() - timedelta(days=retention_days) - - # Count items subject to cleanup - old_entries_count = TimeEntry.query.filter( - TimeEntry.created_at < cutoff_date, - TimeEntry.end_time.isnot(None) - ).count() - - old_tasks_count = Task.query.filter( - Task.created_at < cutoff_date, - Task.status.in_(['completed', 'cancelled']) - ).count() - - draft_cutoff = datetime.utcnow() - timedelta(days=90) - old_drafts_count = Invoice.query.filter( - Invoice.created_at < draft_cutoff, - Invoice.status == 'draft' - ).count() - - pending_deletions_count = Organization.query.filter( - Organization.status == 'pending_deletion', - Organization.deleted_at <= datetime.utcnow() - ).count() - - return { - 'enabled': True, - 'retention_days': retention_days, - 'cutoff_date': cutoff_date.isoformat(), - 'items_eligible_for_cleanup': { - 'time_entries': old_entries_count, - 'completed_tasks': old_tasks_count, - 'draft_invoices': old_drafts_count, - 'pending_organization_deletions': pending_deletions_count, - }, - 'next_cleanup': 'Manual trigger required or configure scheduled task' - } - - @staticmethod - def export_before_deletion(organization_id): - """ - Export organization data before deletion for archival purposes. - - Args: - organization_id: Organization ID to export - - Returns: - dict: Export summary - """ - from app.utils.gdpr import GDPRExporter - import json - import os - - try: - # Create exports directory if it doesn't exist - exports_dir = os.path.join(current_app.root_path, '..', 'data', 'exports') - os.makedirs(exports_dir, exist_ok=True) - - # Export data - data = GDPRExporter.export_organization_data(organization_id, format='json') - - # Save to file - filename = f"org_{organization_id}_export_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json" - filepath = os.path.join(exports_dir, filename) - - with open(filepath, 'w') as f: - json.dump(data, f, indent=2) - - current_app.logger.info(f"Exported organization {organization_id} data to {filepath}") - - return { - 'success': True, - 'filename': filename, - 'filepath': filepath, - 'export_date': datetime.utcnow().isoformat() - } - - except Exception as e: - current_app.logger.error(f"Failed to export organization {organization_id}: {e}") - return { - 'success': False, - 'error': str(e) - } - diff --git a/app/utils/email_service.py b/app/utils/email_service.py deleted file mode 100644 index a84594f..0000000 --- a/app/utils/email_service.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Email service for sending transactional emails""" -import os -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from flask import current_app, url_for, render_template_string -from datetime import datetime - - -class EmailService: - """Service for sending emails""" - - def __init__(self, app=None): - self.app = app - if app: - self.init_app(app) - - def init_app(self, app): - """Initialize email service with Flask app""" - self.smtp_host = app.config.get('SMTP_HOST', os.getenv('SMTP_HOST', 'localhost')) - self.smtp_port = int(app.config.get('SMTP_PORT', os.getenv('SMTP_PORT', 587))) - self.smtp_username = app.config.get('SMTP_USERNAME', os.getenv('SMTP_USERNAME')) - self.smtp_password = app.config.get('SMTP_PASSWORD', os.getenv('SMTP_PASSWORD')) - self.smtp_use_tls = app.config.get('SMTP_USE_TLS', os.getenv('SMTP_USE_TLS', 'true').lower() == 'true') - self.smtp_from_email = app.config.get('SMTP_FROM_EMAIL', os.getenv('SMTP_FROM_EMAIL', 'noreply@timetracker.local')) - self.smtp_from_name = app.config.get('SMTP_FROM_NAME', os.getenv('SMTP_FROM_NAME', 'TimeTracker')) - - @property - def is_configured(self): - """Check if email service is properly configured""" - return bool(self.smtp_host and self.smtp_from_email) - - def send_email(self, to_email, subject, body_text, body_html=None): - """Send an email. - - Args: - to_email: Recipient email address - subject: Email subject - body_text: Plain text email body - body_html: Optional HTML email body - - Returns: - bool: True if sent successfully, False otherwise - """ - if not self.is_configured: - current_app.logger.warning('Email service not configured, skipping email send') - return False - - try: - # Create message - msg = MIMEMultipart('alternative') - msg['From'] = f'{self.smtp_from_name} <{self.smtp_from_email}>' - msg['To'] = to_email - msg['Subject'] = subject - msg['Date'] = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +0000') - - # Attach text and HTML parts - msg.attach(MIMEText(body_text, 'plain')) - if body_html: - msg.attach(MIMEText(body_html, 'html')) - - # Connect to SMTP server and send - if self.smtp_use_tls: - server = smtplib.SMTP(self.smtp_host, self.smtp_port) - server.starttls() - else: - server = smtplib.SMTP(self.smtp_host, self.smtp_port) - - if self.smtp_username and self.smtp_password: - server.login(self.smtp_username, self.smtp_password) - - server.send_message(msg) - server.quit() - - current_app.logger.info(f'Email sent successfully to {to_email}') - return True - - except Exception as e: - current_app.logger.error(f'Failed to send email to {to_email}: {e}') - return False - - def send_password_reset_email(self, user, reset_token): - """Send password reset email to user. - - Args: - user: User object - reset_token: PasswordResetToken object - - Returns: - bool: True if sent successfully - """ - reset_url = url_for('auth_extended.reset_password', token=reset_token.token, _external=True) - - subject = 'Reset Your Password - TimeTracker' - - body_text = f""" -Hello {user.display_name}, - -You requested to reset your password for your TimeTracker account. - -Click the link below to reset your password: -{reset_url} - -This link will expire in 24 hours. - -If you didn't request this password reset, please ignore this email. - -Best regards, -TimeTracker Team -""" - - body_html = f""" - - - - - - -
-

Reset Your Password

-

Hello {user.display_name},

-

You requested to reset your password for your TimeTracker account.

-

Click the button below to reset your password:

- Reset Password -

Or copy and paste this link into your browser:

-

{reset_url}

-

This link will expire in 24 hours.

-

If you didn't request this password reset, please ignore this email.

- -
- - -""" - - return self.send_email(user.email, subject, body_text, body_html) - - def send_invitation_email(self, inviter, invitee_email, organization, membership): - """Send organization invitation email. - - Args: - inviter: User who sent the invitation - invitee_email: Email of the person being invited - organization: Organization object - membership: Membership object with invitation token - - Returns: - bool: True if sent successfully - """ - accept_url = url_for('auth.accept_invitation', token=membership.invitation_token, _external=True) - - subject = f'{inviter.display_name} invited you to join {organization.name} on TimeTracker' - - body_text = f""" -Hello, - -{inviter.display_name} has invited you to join "{organization.name}" on TimeTracker as a {membership.role}. - -Click the link below to accept the invitation: -{accept_url} - -If you don't have an account yet, you'll be able to create one during the acceptance process. - -This invitation link will expire in 7 days. - -Best regards, -TimeTracker Team -""" - - body_html = f""" - - - - - - -
-

You've Been Invited!

-

Hello,

-

{inviter.display_name} has invited you to join their organization on TimeTracker.

-
-

Organization: {organization.name}

-

Your Role: {membership.role.capitalize()}

-
- Accept Invitation -

Or copy and paste this link into your browser:

-

{accept_url}

-

If you don't have an account yet, you'll be able to create one during the acceptance process.

-

This invitation link will expire in 7 days.

- -
- - -""" - - return self.send_email(invitee_email, subject, body_text, body_html) - - def send_email_verification(self, user, verification_token): - """Send email verification email. - - Args: - user: User object - verification_token: EmailVerificationToken object - - Returns: - bool: True if sent successfully - """ - verify_url = url_for('auth_extended.verify_email', token=verification_token.token, _external=True) - - subject = 'Verify Your Email - TimeTracker' - - body_text = f""" -Hello {user.display_name}, - -Please verify your email address for your TimeTracker account. - -Click the link below to verify your email: -{verify_url} - -This link will expire in 48 hours. - -If you didn't create this account, please ignore this email. - -Best regards, -TimeTracker Team -""" - - body_html = f""" - - - - - - -
-

Verify Your Email

-

Hello {user.display_name},

-

Please verify your email address for your TimeTracker account.

- Verify Email -

Or copy and paste this link into your browser:

-

{verify_url}

-

This link will expire in 48 hours.

-

If you didn't create this account, please ignore this email.

- -
- - -""" - - return self.send_email(user.email, subject, body_text, body_html) - - def send_welcome_email(self, user, organization=None): - """Send welcome email to new user. - - Args: - user: User object - organization: Optional organization they're joining - - Returns: - bool: True if sent successfully - """ - subject = 'Welcome to TimeTracker!' - - org_info = f'\n\nYou\'ve been added to the "{organization.name}" organization.' if organization else '' - - body_text = f""" -Hello {user.display_name}, - -Welcome to TimeTracker! We're excited to have you on board.{org_info} - -You can now start tracking your time and managing your projects. - -Log in here: {url_for('auth.login', _external=True)} - -If you have any questions, feel free to reach out to our support team. - -Best regards, -TimeTracker Team -""" - - org_html = f'

You\'ve been added to the "{organization.name}" organization.

' if organization else '' - - body_html = f""" - - - - - - -
-

Welcome to TimeTracker!

-

Hello {user.display_name},

-

We're excited to have you on board!

- {org_html} -

You can now start tracking your time and managing your projects.

- Log In -

If you have any questions, feel free to reach out to our support team.

- -
- - -""" - - return self.send_email(user.email, subject, body_text, body_html) - - -# Global email service instance -email_service = EmailService() - diff --git a/app/utils/gdpr.py b/app/utils/gdpr.py deleted file mode 100644 index d759d8b..0000000 --- a/app/utils/gdpr.py +++ /dev/null @@ -1,486 +0,0 @@ -""" -GDPR Compliance Utilities - -This module provides data export and deletion functionality for GDPR compliance. -""" - -import json -import csv -import io -from datetime import datetime, timedelta -from flask import current_app -from app import db -from app.models import User, TimeEntry, Project, Task, Invoice, InvoiceItem, Client, Comment, Organization - - -class GDPRExporter: - """Handle GDPR data export requests""" - - @staticmethod - def export_organization_data(organization_id, format='json'): - """ - Export all data for an organization in GDPR-compliant format. - - Args: - organization_id: The organization ID to export data for - format: Export format ('json' or 'csv') - - Returns: - dict or str: Exported data - """ - from app.models import Membership - - organization = Organization.query.get(organization_id) - if not organization: - raise ValueError("Organization not found") - - # Collect all organization data - data = { - 'export_date': datetime.utcnow().isoformat(), - 'organization': { - 'id': organization.id, - 'name': organization.name, - 'slug': organization.slug, - 'contact_email': organization.contact_email, - 'created_at': organization.created_at.isoformat() if organization.created_at else None, - 'status': organization.status, - }, - 'members': [], - 'projects': [], - 'clients': [], - 'time_entries': [], - 'tasks': [], - 'invoices': [], - } - - # Export members - memberships = Membership.query.filter_by(organization_id=organization_id).all() - for membership in memberships: - user = membership.user - data['members'].append({ - 'username': user.username, - 'email': user.email, - 'full_name': user.full_name, - 'role': membership.role, - 'joined_at': membership.created_at.isoformat() if membership.created_at else None, - }) - - # Export clients - clients = Client.query.filter_by(organization_id=organization_id).all() - for client in clients: - data['clients'].append({ - 'name': client.name, - 'email': client.email, - 'phone': client.phone, - 'address': client.address, - 'contact_person': client.contact_person, - 'created_at': client.created_at.isoformat() if client.created_at else None, - }) - - # Export projects - projects = Project.query.filter_by(organization_id=organization_id).all() - for project in projects: - data['projects'].append({ - 'name': project.name, - 'description': project.description, - 'client': project.client_obj.name if project.client_obj else None, - 'billable': project.billable, - 'hourly_rate': float(project.hourly_rate) if project.hourly_rate else None, - 'status': project.status, - 'created_at': project.created_at.isoformat() if project.created_at else None, - }) - - # Export time entries - time_entries = TimeEntry.query.filter_by(organization_id=organization_id).all() - for entry in time_entries: - data['time_entries'].append({ - 'user': entry.user.username if entry.user else None, - 'project': entry.project.name if entry.project else None, - 'start_time': entry.start_time.isoformat() if entry.start_time else None, - 'end_time': entry.end_time.isoformat() if entry.end_time else None, - 'duration_seconds': entry.duration_seconds, - 'notes': entry.notes, - 'tags': entry.tags, - 'billable': entry.billable, - }) - - # Export tasks - tasks = Task.query.filter_by(organization_id=organization_id).all() - for task in tasks: - data['tasks'].append({ - 'name': task.name, - 'description': task.description, - 'project': task.project.name if task.project else None, - 'status': task.status, - 'priority': task.priority, - 'assigned_to': task.assigned_user.username if task.assigned_user else None, - 'created_by': task.creator.username if task.creator else None, - 'due_date': task.due_date.isoformat() if task.due_date else None, - 'created_at': task.created_at.isoformat() if task.created_at else None, - }) - - # Export invoices - invoices = Invoice.query.filter_by(organization_id=organization_id).all() - for invoice in invoices: - invoice_data = { - 'invoice_number': invoice.invoice_number, - 'client_name': invoice.client_name, - 'client_email': invoice.client_email, - 'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None, - 'due_date': invoice.due_date.isoformat() if invoice.due_date else None, - 'status': invoice.status, - 'subtotal': float(invoice.subtotal) if invoice.subtotal else None, - 'tax_rate': float(invoice.tax_rate) if invoice.tax_rate else None, - 'total_amount': float(invoice.total_amount) if invoice.total_amount else None, - 'items': [], - } - - # Add invoice items - for item in invoice.items: - invoice_data['items'].append({ - 'description': item.description, - 'quantity': float(item.quantity) if item.quantity else None, - 'unit_price': float(item.unit_price) if item.unit_price else None, - 'total_amount': float(item.total_amount) if item.total_amount else None, - }) - - data['invoices'].append(invoice_data) - - if format == 'csv': - return GDPRExporter._convert_to_csv(data) - - return data - - @staticmethod - def export_user_data(user_id, format='json'): - """ - Export all data for a specific user. - - Args: - user_id: The user ID to export data for - format: Export format ('json' or 'csv') - - Returns: - dict or str: Exported data - """ - user = User.query.get(user_id) - if not user: - raise ValueError("User not found") - - data = { - 'export_date': datetime.utcnow().isoformat(), - 'user': { - 'username': user.username, - 'email': user.email, - 'full_name': user.full_name, - 'created_at': user.created_at.isoformat() if user.created_at else None, - 'last_login': user.last_login.isoformat() if user.last_login else None, - 'role': user.role, - 'totp_enabled': user.totp_enabled, - }, - 'time_entries': [], - 'tasks_created': [], - 'tasks_assigned': [], - 'comments': [], - } - - # Export time entries - time_entries = TimeEntry.query.filter_by(user_id=user_id).all() - for entry in time_entries: - data['time_entries'].append({ - 'project': entry.project.name if entry.project else None, - 'start_time': entry.start_time.isoformat() if entry.start_time else None, - 'end_time': entry.end_time.isoformat() if entry.end_time else None, - 'duration_seconds': entry.duration_seconds, - 'notes': entry.notes, - 'tags': entry.tags, - }) - - # Export created tasks - created_tasks = Task.query.filter_by(created_by=user_id).all() - for task in created_tasks: - data['tasks_created'].append({ - 'name': task.name, - 'description': task.description, - 'project': task.project.name if task.project else None, - 'status': task.status, - 'created_at': task.created_at.isoformat() if task.created_at else None, - }) - - # Export assigned tasks - assigned_tasks = Task.query.filter_by(assigned_to=user_id).all() - for task in assigned_tasks: - data['tasks_assigned'].append({ - 'name': task.name, - 'description': task.description, - 'project': task.project.name if task.project else None, - 'status': task.status, - 'due_date': task.due_date.isoformat() if task.due_date else None, - }) - - # Export comments - try: - comments = Comment.query.filter_by(user_id=user_id).all() - for comment in comments: - data['comments'].append({ - 'content': comment.content, - 'created_at': comment.created_at.isoformat() if comment.created_at else None, - }) - except Exception: - # Comment model might not exist - pass - - if format == 'csv': - return GDPRExporter._convert_to_csv(data) - - return data - - @staticmethod - def _convert_to_csv(data): - """Convert JSON data to CSV format""" - # For simplicity, create a CSV with flattened data - # In production, you might want to create multiple CSV files - output = io.StringIO() - - # Write metadata - output.write(f"Export Date,{data['export_date']}\n\n") - - # Write each section - for section, items in data.items(): - if section == 'export_date': - continue - - if isinstance(items, list) and items: - output.write(f"\n{section.upper()}\n") - - # Get headers from first item - if isinstance(items[0], dict): - headers = list(items[0].keys()) - writer = csv.DictWriter(output, fieldnames=headers) - writer.writeheader() - writer.writerows(items) - - output.write('\n') - - return output.getvalue() - - -class GDPRDeleter: - """Handle GDPR data deletion requests""" - - @staticmethod - def request_organization_deletion(organization_id, requested_by_user_id): - """ - Request deletion of an organization and all its data. - Implements grace period before actual deletion. - - Args: - organization_id: The organization ID to delete - requested_by_user_id: User who requested the deletion - - Returns: - dict: Deletion request details - """ - organization = Organization.query.get(organization_id) - if not organization: - raise ValueError("Organization not found") - - # Check if user is an admin of the organization - if not organization.is_admin(requested_by_user_id): - raise PermissionError("Only organization admins can request deletion") - - # Calculate deletion date (grace period) - grace_days = current_app.config.get('GDPR_DELETION_DELAY_DAYS', 30) - deletion_date = datetime.utcnow() + timedelta(days=grace_days) - - # Mark organization for deletion - organization.deleted_at = deletion_date - organization.status = 'pending_deletion' - - db.session.commit() - - return { - 'organization_id': organization_id, - 'deletion_scheduled_for': deletion_date.isoformat(), - 'grace_period_days': grace_days, - 'can_cancel_until': deletion_date.isoformat(), - } - - @staticmethod - def cancel_organization_deletion(organization_id, user_id): - """ - Cancel a pending organization deletion. - - Args: - organization_id: The organization ID - user_id: User requesting cancellation - - Returns: - bool: Success status - """ - organization = Organization.query.get(organization_id) - if not organization: - raise ValueError("Organization not found") - - # Check if user is an admin - if not organization.is_admin(user_id): - raise PermissionError("Only organization admins can cancel deletion") - - # Check if deletion is pending - if organization.status != 'pending_deletion': - raise ValueError("No pending deletion to cancel") - - # Cancel deletion - organization.deleted_at = None - organization.status = 'active' - - db.session.commit() - - return True - - @staticmethod - def execute_organization_deletion(organization_id): - """ - Permanently delete an organization and all its data. - This should only be called after the grace period has expired. - - Args: - organization_id: The organization ID to delete - - Returns: - dict: Deletion summary - """ - organization = Organization.query.get(organization_id) - if not organization: - raise ValueError("Organization not found") - - # Verify grace period has expired - if organization.deleted_at and datetime.utcnow() < organization.deleted_at: - raise ValueError("Grace period has not expired yet") - - # Collect statistics before deletion - stats = { - 'organization_id': organization_id, - 'organization_name': organization.name, - 'members_deleted': organization.member_count, - 'projects_deleted': organization.project_count, - 'deletion_date': datetime.utcnow().isoformat(), - } - - # Delete all related data (CASCADE will handle most of this) - # But we'll be explicit for clarity - - # Delete time entries - TimeEntry.query.filter_by(organization_id=organization_id).delete() - - # Delete tasks - Task.query.filter_by(organization_id=organization_id).delete() - - # Delete invoice items (via invoices cascade) - Invoice.query.filter_by(organization_id=organization_id).delete() - - # Delete projects - Project.query.filter_by(organization_id=organization_id).delete() - - # Delete clients - Client.query.filter_by(organization_id=organization_id).delete() - - # Delete memberships - from app.models import Membership - Membership.query.filter_by(organization_id=organization_id).delete() - - # Finally, delete the organization - db.session.delete(organization) - db.session.commit() - - return stats - - @staticmethod - def delete_user_data(user_id): - """ - Delete a user and anonymize their data. - - Args: - user_id: The user ID to delete - - Returns: - dict: Deletion summary - """ - user = User.query.get(user_id) - if not user: - raise ValueError("User not found") - - stats = { - 'user_id': user_id, - 'username': user.username, - 'deletion_date': datetime.utcnow().isoformat(), - } - - # Anonymize time entries instead of deleting - # (needed for billing/audit purposes) - time_entries = TimeEntry.query.filter_by(user_id=user_id).all() - for entry in time_entries: - entry.notes = "[User data deleted]" - - # Reassign or delete tasks - created_tasks = Task.query.filter_by(created_by=user_id).all() - for task in created_tasks: - task.created_by = None - - assigned_tasks = Task.query.filter_by(assigned_to=user_id).all() - for task in assigned_tasks: - task.assigned_to = None - - # Delete user memberships - from app.models import Membership - Membership.query.filter_by(user_id=user_id).delete() - - # Finally, delete the user - db.session.delete(user) - db.session.commit() - - return stats - - @staticmethod - def cleanup_expired_data(): - """ - Clean up data that has exceeded retention period. - Should be run as a scheduled task. - - Returns: - dict: Cleanup summary - """ - retention_days = current_app.config.get('DATA_RETENTION_DAYS', 0) - - if retention_days == 0: - return {'message': 'Data retention not configured'} - - cutoff_date = datetime.utcnow() - timedelta(days=retention_days) - - # Delete old time entries - old_entries = TimeEntry.query.filter( - TimeEntry.created_at < cutoff_date - ).delete() - - # Execute pending organization deletions - pending_deletions = Organization.query.filter( - Organization.status == 'pending_deletion', - Organization.deleted_at <= datetime.utcnow() - ).all() - - deleted_orgs = 0 - for org in pending_deletions: - try: - GDPRDeleter.execute_organization_deletion(org.id) - deleted_orgs += 1 - except Exception as e: - current_app.logger.error(f"Failed to delete organization {org.id}: {e}") - - db.session.commit() - - return { - 'old_entries_deleted': old_entries, - 'organizations_deleted': deleted_orgs, - 'cutoff_date': cutoff_date.isoformat(), - } - diff --git a/app/utils/jwt_utils.py b/app/utils/jwt_utils.py deleted file mode 100644 index 7993e61..0000000 --- a/app/utils/jwt_utils.py +++ /dev/null @@ -1,244 +0,0 @@ -"""JWT utilities for authentication""" -import jwt -from datetime import datetime, timedelta -from flask import current_app -from functools import wraps -from flask import request, jsonify -from flask_login import current_user - -def generate_access_token(user_id, organization_id=None, expires_in_minutes=15): - """Generate a JWT access token. - - Args: - user_id: ID of the user - organization_id: Optional organization context - expires_in_minutes: Token validity period in minutes (default 15) - - Returns: - JWT token string - """ - payload = { - 'user_id': user_id, - 'type': 'access', - 'exp': datetime.utcnow() + timedelta(minutes=expires_in_minutes), - 'iat': datetime.utcnow(), - } - - if organization_id: - payload['organization_id'] = organization_id - - secret = current_app.config.get('SECRET_KEY') - return jwt.encode(payload, secret, algorithm='HS256') - - -def decode_access_token(token): - """Decode and validate a JWT access token. - - Args: - token: JWT token string - - Returns: - dict: Token payload if valid, None otherwise - """ - try: - secret = current_app.config.get('SECRET_KEY') - payload = jwt.decode(token, secret, algorithms=['HS256']) - - # Verify it's an access token - if payload.get('type') != 'access': - return None - - return payload - except jwt.ExpiredSignatureError: - current_app.logger.debug('JWT token expired') - return None - except jwt.InvalidTokenError as e: - current_app.logger.debug(f'Invalid JWT token: {e}') - return None - - -def jwt_required(f): - """Decorator to require a valid JWT token for API endpoints. - - Usage: - @app.route('/api/protected') - @jwt_required - def protected_endpoint(): - # Access user via g.current_user or request.jwt_user - return jsonify({'user_id': request.jwt_user['user_id']}) - """ - @wraps(f) - def decorated_function(*args, **kwargs): - from flask import g - - # Check for token in Authorization header - auth_header = request.headers.get('Authorization', '') - - if not auth_header.startswith('Bearer '): - return jsonify({'error': 'Missing or invalid authorization header'}), 401 - - token = auth_header.replace('Bearer ', '', 1) - payload = decode_access_token(token) - - if not payload: - return jsonify({'error': 'Invalid or expired token'}), 401 - - # Load user and verify they're still active - from app.models import User - user = User.query.get(payload['user_id']) - - if not user or not user.is_active: - return jsonify({'error': 'User not found or inactive'}), 401 - - # Store user info in request context - g.current_user = user - request.jwt_user = payload - - return f(*args, **kwargs) - - return decorated_function - - -def jwt_optional(f): - """Decorator that accepts but doesn't require a JWT token. - - If a valid token is provided, user info is available in request context. - If no token or invalid token, the endpoint still executes. - """ - @wraps(f) - def decorated_function(*args, **kwargs): - from flask import g - - auth_header = request.headers.get('Authorization', '') - - if auth_header.startswith('Bearer '): - token = auth_header.replace('Bearer ', '', 1) - payload = decode_access_token(token) - - if payload: - from app.models import User - user = User.query.get(payload['user_id']) - - if user and user.is_active: - g.current_user = user - request.jwt_user = payload - - return f(*args, **kwargs) - - return decorated_function - - -def get_jwt_identity(): - """Get the current JWT user ID from request context. - - Returns: - int: User ID if authenticated via JWT, None otherwise - """ - if hasattr(request, 'jwt_user'): - return request.jwt_user.get('user_id') - return None - - -def refresh_access_token(refresh_token_string): - """Generate a new access token using a refresh token. - - Args: - refresh_token_string: Refresh token string - - Returns: - tuple: (access_token, refresh_token) if successful, (None, None) otherwise - """ - from app.models import RefreshToken - - refresh_token = RefreshToken.get_valid_token(refresh_token_string) - - if not refresh_token: - return None, None - - # Update last used timestamp - refresh_token.update_last_used() - - # Generate new access token - access_token = generate_access_token(refresh_token.user_id) - - # Optionally rotate refresh token (more secure but requires client to update) - # For now, we'll keep the same refresh token - - return access_token, refresh_token.token - - -def create_token_pair(user_id, organization_id=None, device_id=None, device_name=None, - ip_address=None, user_agent=None): - """Create both access and refresh tokens for a user. - - Args: - user_id: ID of the user - organization_id: Optional organization context - device_id: Unique device identifier - device_name: User-friendly device name - ip_address: IP address of the client - user_agent: User agent string - - Returns: - dict: Contains 'access_token', 'refresh_token', and 'expires_in' - """ - from app.models import RefreshToken - - # Generate access token - access_token = generate_access_token(user_id, organization_id) - - # Create refresh token in database - refresh_token_obj = RefreshToken.create_token( - user_id=user_id, - device_id=device_id, - device_name=device_name, - ip_address=ip_address, - user_agent=user_agent - ) - - return { - 'access_token': access_token, - 'refresh_token': refresh_token_obj.token, - 'token_type': 'Bearer', - 'expires_in': 900, # 15 minutes in seconds - } - - -def revoke_refresh_token(refresh_token_string): - """Revoke a refresh token. - - Args: - refresh_token_string: Refresh token to revoke - - Returns: - bool: True if revoked, False if not found - """ - from app.models import RefreshToken - - token = RefreshToken.query.filter_by(token=refresh_token_string).first() - - if token: - token.revoke() - return True - - return False - - -def revoke_all_user_tokens(user_id): - """Revoke all refresh tokens for a user (e.g., on password change). - - Args: - user_id: ID of the user - - Returns: - int: Number of tokens revoked - """ - from app.models import RefreshToken - - tokens = RefreshToken.query.filter_by(user_id=user_id, revoked=False).all() - - for token in tokens: - token.revoke() - - return len(tokens) - diff --git a/app/utils/license_server.py b/app/utils/license_server.py new file mode 100644 index 0000000..38f7627 --- /dev/null +++ b/app/utils/license_server.py @@ -0,0 +1,563 @@ +import os +import json +import uuid +import time +import threading +import logging +import requests +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any +import platform +import socket + +logger = logging.getLogger(__name__) + +class LicenseServerClient: + """Client for communicating with the Metrics Server""" + + def __init__(self, app_identifier: str = "timetracker", app_version: str = "1.0.0"): + # Server configuration (env-overridable) + # Default targets the public metrics endpoint; override via environment if needed. + default_server_url = "http://metrics.drytrix.com:58082" + configured_server_url = os.getenv("METRICS_SERVER_URL", os.getenv("LICENSE_SERVER_BASE_URL", default_server_url)) + self.server_url = self._normalize_base_url(configured_server_url) + self.api_key = os.getenv("METRICS_SERVER_API_KEY", os.getenv("LICENSE_SERVER_API_KEY", "no-license-required")) + self.app_identifier = app_identifier + self.app_version = app_version + + # Instance management + self.instance_id = None + self.registration_token = None + self.is_registered = False + self.heartbeat_thread = None + # Timing configuration + self.heartbeat_interval = int(os.getenv("METRICS_HEARTBEAT_SECONDS", os.getenv("LICENSE_HEARTBEAT_SECONDS", "3600"))) # default: 1 hour + self.request_timeout = int(os.getenv("METRICS_SERVER_TIMEOUT_SECONDS", os.getenv("LICENSE_SERVER_TIMEOUT_SECONDS", "30"))) # default: 30s per docs + self.running = False + + # System information + self.system_info = self._collect_system_info() + + logger.info(f"Metrics server configured: base='{self.server_url}', app='{self.app_identifier}', version='{self.app_version}'") + if not self.api_key: + logger.warning("X-API-Key is empty; server may reject requests. Set LICENSE_SERVER_API_KEY.") + + # Registration synchronization and persistence + self._registration_lock = threading.Lock() + self._registration_in_progress = False + self._state_file_path = self._compute_state_file_path() + self._load_persisted_state() + + # Offline storage for failed requests + self.offline_data = [] + self.max_offline_data = 100 + + def _collect_system_info(self) -> Dict[str, Any]: + """Collect system information for registration""" + try: + # Check if analytics are allowed + from app.models import Settings + try: + settings = Settings.get_settings() + if not settings.allow_analytics: + # Return minimal info if analytics are disabled + return { + "os": "Unknown", + "version": "Unknown", + "architecture": "Unknown", + "machine": "Unknown", + "processor": "Unknown", + "hostname": "Unknown", + "local_ip": "Unknown", + "python_version": "Unknown", + "analytics_disabled": True + } + except Exception: + # If we can't get settings, assume analytics are allowed (fallback) + pass + + # Get local IP address + hostname = socket.gethostname() + local_ip = socket.gethostbyname(hostname) + + return { + "os": platform.system(), + "version": platform.version(), + "architecture": platform.architecture()[0], + "machine": platform.machine(), + "processor": platform.processor(), + "hostname": hostname, + "local_ip": local_ip, + "python_version": platform.python_version(), + "analytics_disabled": False + } + except Exception as e: + logger.warning(f"Could not collect complete system info: {e}") + return { + "os": platform.system(), + "version": "unknown", + "architecture": "unknown", + "analytics_disabled": False + } + + def _normalize_base_url(self, base_url: str) -> str: + """Normalize base URL to avoid duplicate '/api/v1' when building endpoints. + + Accepts values with or without trailing slash and with or without '/api/v1'. + Always returns a URL WITHOUT trailing slash and WITHOUT '/api/v1'. + """ + try: + if not base_url: + return "" + url = base_url.strip().rstrip("/") + # If caller provided a base that already includes '/api/v1', strip it. + if url.endswith("/api/v1"): + url = url[: -len("/api/v1")] + url = url.rstrip("/") + return url + except Exception: + # Fallback to provided value if normalization fails + return base_url + + def _compute_state_file_path(self) -> str: + """Compute a per-user path to persist license client state (instance id, token).""" + try: + if os.name == "nt": + base_dir = os.getenv("APPDATA") or os.path.expanduser("~") + app_dir = os.path.join(base_dir, "TimeTracker") + else: + app_dir = os.path.join(os.path.expanduser("~"), ".timetracker") + os.makedirs(app_dir, exist_ok=True) + return os.path.join(app_dir, "license_client_state.json") + except Exception: + # Fallback to current directory + return os.path.join(os.getcwd(), "license_client_state.json") + + def _load_persisted_state(self): + """Load previously persisted state if available (instance id, token).""" + try: + if self._state_file_path and os.path.exists(self._state_file_path): + with open(self._state_file_path, "r", encoding="utf-8") as f: + state = json.load(f) + loaded_instance_id = state.get("instance_id") + loaded_token = state.get("registration_token") + if loaded_instance_id and not self.instance_id: + self.instance_id = loaded_instance_id + if loaded_token: + self.registration_token = loaded_token + logger.info(f"Loaded persisted license client state from '{self._state_file_path}'") + except Exception as e: + logger.warning(f"Failed to load persisted license client state: {e}") + + def _persist_state(self): + """Persist current state (instance id, token) to disk.""" + try: + if not self._state_file_path: + return + state = { + "instance_id": self.instance_id, + "registration_token": self.registration_token, + "app_identifier": self.app_identifier, + "app_version": self.app_version, + "updated_at": datetime.now().isoformat() + } + with open(self._state_file_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + logger.debug(f"Persisted license client state to '{self._state_file_path}'") + except Exception as e: + logger.warning(f"Failed to persist license client state: {e}") + + def get_detailed_error_info(self, response) -> Dict[str, Any]: + """Extract detailed error information from a failed response""" + error_info = { + "status_code": response.status_code, + "reason": response.reason, + "headers": dict(response.headers), + "text": response.text, + "url": response.url, + "elapsed": str(response.elapsed) if hasattr(response, 'elapsed') else "unknown" + } + + # Try to parse JSON error response + try: + error_json = response.json() + error_info["json_error"] = error_json + if "error" in error_json: + error_info["error_message"] = error_json["error"] + if "details" in error_json: + error_info["error_details"] = error_json["details"] + if "traceback" in error_json: + error_info["server_traceback"] = error_json["traceback"] + except json.JSONDecodeError: + error_info["json_error"] = None + error_info["parse_error"] = "Response is not valid JSON" + + return error_info + + def _make_request(self, endpoint: str, method: str = "GET", data: Dict = None) -> Optional[Dict]: + """Make HTTP request to phone home endpoint with error handling""" + url = f"{self.server_url}{endpoint}" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json" + } + + # Log request details + logger.info(f"Making {method} request to phone home endpoint: {url}") + if data: + logger.debug(f"Request data: {json.dumps(data, indent=2)}") + logger.debug(f"Request headers: {headers}") + + try: + if method.upper() == "GET": + response = requests.get(url, headers=headers, timeout=self.request_timeout) + elif method.upper() == "POST": + response = requests.post(url, headers=headers, json=data, timeout=self.request_timeout) + else: + logger.error(f"Unsupported HTTP method: {method}") + return None + + # Log response details + logger.info(f"Phone home response: {response.status_code} - {response.reason}") + logger.debug(f"Response headers: {dict(response.headers)}") + + if response.status_code in [200, 201]: + try: + response_json = response.json() + logger.debug(f"Response body: {json.dumps(response_json, indent=2)}") + return response_json + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON response: {e}") + logger.error(f"Raw response text: {response.text}") + return None + else: + # Enhanced error logging with detailed error info + error_info = self.get_detailed_error_info(response) + logger.error(f"Phone home request failed with status {response.status_code}") + logger.error(f"Detailed error info: {json.dumps(error_info, indent=2)}") + + # Log specific error details + if "error_message" in error_info: + logger.error(f"Server error message: {error_info['error_message']}") + if "error_details" in error_info: + logger.error(f"Server error details: {error_info['error_details']}") + if "server_traceback" in error_info: + logger.error(f"Server traceback: {error_info['server_traceback']}") + + return None + + except requests.exceptions.Timeout as e: + logger.error(f"Phone home request timed out after {self.request_timeout} seconds: {e}") + return None + except requests.exceptions.ConnectionError as e: + logger.error(f"Phone home connection error: {e}") + logger.error(f"URL attempted: {url}") + return None + except requests.exceptions.RequestException as e: + logger.error(f"Phone home request exception: {e}") + logger.error(f"Request type: {type(e).__name__}") + return None + except Exception as e: + logger.error(f"Unexpected error in phone home request: {e}") + logger.error(f"Error type: {type(e).__name__}") + logger.error(f"Error details: {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + return None + + def register_instance(self) -> bool: + """Register this instance with the phone home service""" + with self._registration_lock: + if self.is_registered: + logger.info("Instance already registered") + return True + + # Generate instance ID if not exists (prefer persisted one) + if not self.instance_id: + self.instance_id = str(uuid.uuid4()) + + registration_data = { + "app_identifier": self.app_identifier, + "version": self.app_version, + "instance_id": self.instance_id, + "system_metadata": self.system_info, + "country": "Unknown", # Could be enhanced with IP geolocation + "city": "Unknown", + "license_id": "NO-LICENSE-REQUIRED" + } + + logger.info(f"Registering instance {self.instance_id} with phone home service at {self.server_url}") + logger.info(f"App identifier: {self.app_identifier}, Version: {self.app_version}") + logger.debug(f"System info: {json.dumps(self.system_info, indent=2)}") + logger.debug(f"Full registration data: {json.dumps(registration_data, indent=2)}") + + response = self._make_request("/api/v1/register", "POST", registration_data) + + if response and "instance_id" in response: + self.instance_id = response["instance_id"] + self.registration_token = response.get("token") + self.is_registered = True + self._persist_state() + logger.info(f"Successfully registered instance {self.instance_id}") + if self.registration_token: + logger.debug(f"Received registration token: {self.registration_token[:10]}...") + return True + else: + logger.error(f"Registration failed - no valid response from phone home service") + logger.error(f"Expected 'instance_id' in response, but got: {response}") + logger.info(f"Phone home service at {self.server_url} is not available - continuing without registration") + return False + + def validate_license(self) -> bool: + """Validate license (always returns True since no license required)""" + if not self.is_registered: + logger.warning("Cannot validate license: instance not registered") + return False + + validation_data = { + "app_identifier": self.app_identifier, + "license_id": "NO-LICENSE-REQUIRED", + "instance_id": self.instance_id + } + + # Log the complete license validation request (URL, method, headers, body) + validation_url = f"{self.server_url}/api/v1/validate" + validation_headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json" + } + try: + logger.info("Complete license validation request:") + logger.info(json.dumps({ + "url": validation_url, + "method": "POST", + "headers": validation_headers, + "body": validation_data + }, indent=2)) + except Exception: + # Fallback logging if JSON serialization fails for any reason + logger.info(f"License validation URL: {validation_url}") + logger.info(f"License validation headers: {validation_headers}") + logger.info(f"License validation body: {validation_data}") + + logger.info("Validating metrics token (no license required)") + response = self._make_request("/api/v1/validate", "POST", validation_data) + + if response and response.get("valid", False): + logger.info("Phone home token validation successful") + return True + else: + logger.warning("Phone home token validation failed") + return False + + def send_heartbeat(self) -> bool: + """Send heartbeat to phone home service""" + if not self.is_registered: + logger.warning("Cannot send heartbeat: instance not registered") + return False + + heartbeat_data = { + "instance_id": self.instance_id + } + + logger.debug("Sending heartbeat to phone home service") + response = self._make_request("/api/v1/heartbeat", "POST", heartbeat_data) + + if response: + logger.debug("Heartbeat successful") + return True + else: + logger.warning("Heartbeat failed") + return False + + def send_usage_data(self, data_points: List[Dict[str, Any]]) -> bool: + """Send usage data to phone home service""" + if not self.is_registered: + # Store data offline for later transmission + self._store_offline_data(data_points) + return False + + # Check if analytics are allowed + try: + from app.models import Settings + settings = Settings.get_settings() + if not settings.allow_analytics: + logger.info("Analytics disabled by user setting - skipping usage data transmission") + return True # Return True to indicate "success" (data was handled appropriately) + except Exception: + # If we can't get settings, assume analytics are allowed (fallback) + pass + + usage_data = { + "app_identifier": self.app_identifier, + "instance_id": self.instance_id, + "data": data_points + } + + logger.debug(f"Sending {len(data_points)} usage data points") + response = self._make_request("/api/v1/data", "POST", usage_data) + + if response: + logger.debug("Usage data sent successfully") + # Try to send any stored offline data + self._send_offline_data() + return True + else: + logger.warning("Failed to send usage data") + self._store_offline_data(data_points) + return False + + def _store_offline_data(self, data_points: List[Dict[str, Any]]): + """Store data points for offline transmission""" + for point in data_points: + # Use local time per project preference + point["timestamp"] = datetime.now().isoformat() + self.offline_data.append(point) + + # Keep only the most recent data points + if len(self.offline_data) > self.max_offline_data: + self.offline_data = self.offline_data[-self.max_offline_data:] + + logger.debug(f"Stored {len(data_points)} data points offline") + + def _send_offline_data(self): + """Attempt to send stored offline data""" + if not self.offline_data: + return + + data_to_send = self.offline_data.copy() + self.offline_data.clear() + + logger.info(f"Attempting to send {len(data_to_send)} offline data points to phone home service") + if self.send_usage_data(data_to_send): + logger.info("Successfully sent offline data") + else: + # Put the data back if sending failed + self.offline_data.extend(data_to_send) + logger.warning("Failed to send offline data, will retry later") + + def _heartbeat_worker(self): + """Background worker for sending heartbeats""" + while self.running: + try: + if self.is_registered: + self.send_heartbeat() + else: + # If not registered, try to register again every hour + logger.debug("Attempting to register with phone home service...") + self.register_instance() + time.sleep(self.heartbeat_interval) + except Exception as e: + logger.error(f"Error in heartbeat worker: {e}") + time.sleep(60) # Wait a minute before retrying + + def start(self) -> bool: + """Start the phone home client""" + if self.running: + logger.warning("Phone home client already running") + return True + + logger.info(f"Starting phone home client (instance: {id(self)})") + + # Try to register instance (but don't fail if it doesn't work) + registration_success = self.register_instance() + if not registration_success: + logger.info("Phone home service not available - client will run in offline mode") + + # Start heartbeat thread + self.running = True + self.heartbeat_thread = threading.Thread(target=self._heartbeat_worker, daemon=True) + self.heartbeat_thread.start() + + logger.info(f"Phone home client started successfully (instance: {id(self)})") + return True + + def stop(self): + """Stop the phone home client""" + logger.info("Stopping phone home client") + self.running = False + + if self.heartbeat_thread and self.heartbeat_thread.is_alive(): + self.heartbeat_thread.join(timeout=5) + + logger.info("Phone home client stopped") + + def check_server_health(self) -> bool: + """Check if the phone home service is healthy""" + response = self._make_request("/api/v1/status") + return response is not None + + def get_status(self) -> Dict[str, Any]: + """Get current status of the metrics client""" + return { + "is_registered": self.is_registered, + "instance_id": self.instance_id, + "is_running": self.running, + "server_healthy": self.check_server_health(), + "offline_data_count": len(self.offline_data), + "app_identifier": self.app_identifier, + "app_version": self.app_version, + "server_url": self.server_url, + "heartbeat_interval": self.heartbeat_interval, + "analytics_enabled": not bool(self.system_info.get("analytics_disabled", False)), + "system_info": self.system_info + } + +# Global instance +license_client = None +_initialization_lock = threading.Lock() + +def init_license_client(app_identifier: str = "timetracker", app_version: str = "1.0.0") -> LicenseServerClient: + """Initialize the global license client instance""" + global license_client + + with _initialization_lock: + if license_client is None: + logger.info(f"Creating new license client instance (app: {app_identifier}, version: {app_version})") + license_client = LicenseServerClient(app_identifier, app_version) + logger.info(f"License client initialized (instance: {id(license_client)})") + else: + logger.info(f"License client already initialized, reusing existing instance (instance: {id(license_client)})") + + return license_client + +def get_license_client() -> Optional[LicenseServerClient]: + """Get the global license client instance""" + return license_client + +def start_license_client() -> bool: + """Start the global license client""" + global license_client + + if license_client is None: + logger.error("License client not initialized") + return False + + # Check if already running + if license_client.running: + logger.info("License client already running") + return True + + return license_client.start() + +def stop_license_client(): + """Stop the global license client""" + global license_client + + if license_client: + license_client.stop() + +def send_usage_event(event_type: str, event_data: Dict[str, Any] = None): + """Send a usage event to the metrics server""" + if not license_client: + return False + + data_point = { + "key": "usage_event", + "value": event_type, + "type": "string", + "metadata": event_data or {} + } + + return license_client.send_usage_data([data_point]) + diff --git a/app/utils/password_policy.py b/app/utils/password_policy.py deleted file mode 100644 index dd80789..0000000 --- a/app/utils/password_policy.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Password Policy Enforcement - -This module provides password policy validation to ensure passwords meet -security requirements for length, complexity, and history. -""" - -import re -from datetime import datetime, timedelta -from flask import current_app -from werkzeug.security import check_password_hash - - -class PasswordPolicy: - """Password policy validator""" - - @staticmethod - def validate_password(password, user=None): - """ - Validate password against configured policy. - - Args: - password: The password to validate - user: Optional user object to check password history - - Returns: - tuple: (is_valid, error_message) - """ - if not password: - return False, "Password is required" - - # Get policy configuration - min_length = current_app.config.get('PASSWORD_MIN_LENGTH', 12) - require_uppercase = current_app.config.get('PASSWORD_REQUIRE_UPPERCASE', True) - require_lowercase = current_app.config.get('PASSWORD_REQUIRE_LOWERCASE', True) - require_digits = current_app.config.get('PASSWORD_REQUIRE_DIGITS', True) - require_special = current_app.config.get('PASSWORD_REQUIRE_SPECIAL', True) - - errors = [] - - # Check length - if len(password) < min_length: - errors.append(f"at least {min_length} characters") - - # Check for uppercase - if require_uppercase and not re.search(r'[A-Z]', password): - errors.append("at least one uppercase letter") - - # Check for lowercase - if require_lowercase and not re.search(r'[a-z]', password): - errors.append("at least one lowercase letter") - - # Check for digits - if require_digits and not re.search(r'\d', password): - errors.append("at least one number") - - # Check for special characters - if require_special and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): - errors.append("at least one special character (!@#$%^&*(),.?\":{}|<>)") - - # Check for common patterns - if re.search(r'(.)\1{2,}', password): - errors.append("no repeated characters (e.g., 'aaa', '111')") - - # Check against common weak passwords - weak_passwords = [ - 'password', 'password123', '12345678', 'qwerty', 'abc123', - 'letmein', 'welcome', 'monkey', 'dragon', 'master', - 'admin', 'administrator', 'user', 'test', 'guest' - ] - if password.lower() in weak_passwords: - errors.append("a stronger password (this password is too common)") - - # Check password history if user provided - if user and hasattr(user, 'check_password_history'): - if user.check_password_history(password): - errors.append("a password you haven't used recently") - - if errors: - return False, f"Password must contain {', '.join(errors)}" - - return True, None - - @staticmethod - def get_policy_description(): - """Get a human-readable description of the password policy""" - min_length = current_app.config.get('PASSWORD_MIN_LENGTH', 12) - require_uppercase = current_app.config.get('PASSWORD_REQUIRE_UPPERCASE', True) - require_lowercase = current_app.config.get('PASSWORD_REQUIRE_LOWERCASE', True) - require_digits = current_app.config.get('PASSWORD_REQUIRE_DIGITS', True) - require_special = current_app.config.get('PASSWORD_REQUIRE_SPECIAL', True) - - requirements = [f"at least {min_length} characters"] - - if require_uppercase: - requirements.append("uppercase letters") - if require_lowercase: - requirements.append("lowercase letters") - if require_digits: - requirements.append("numbers") - if require_special: - requirements.append("special characters") - - return f"Password must contain {', '.join(requirements)}" - - @staticmethod - def check_password_expiry(user): - """ - Check if user's password has expired. - - Args: - user: User object - - Returns: - tuple: (is_expired, days_until_expiry) - """ - expiry_days = current_app.config.get('PASSWORD_EXPIRY_DAYS', 0) - - if expiry_days == 0: - return False, None - - if not hasattr(user, 'password_changed_at') or not user.password_changed_at: - # If no password change date recorded, consider it expired - return True, 0 - - expiry_date = user.password_changed_at + timedelta(days=expiry_days) - now = datetime.utcnow() - - if now >= expiry_date: - return True, 0 - - days_remaining = (expiry_date - now).days - return False, days_remaining - - @staticmethod - def generate_strong_password(length=16): - """ - Generate a strong random password that meets the policy requirements. - - Args: - length: Password length (default: 16) - - Returns: - str: Generated password - """ - import secrets - import string - - # Ensure minimum length - min_length = current_app.config.get('PASSWORD_MIN_LENGTH', 12) - length = max(length, min_length) - - # Character sets - uppercase = string.ascii_uppercase - lowercase = string.ascii_lowercase - digits = string.digits - special = '!@#$%^&*(),.?":{}|<>' - - # Build password with required character types - password_chars = [] - - if current_app.config.get('PASSWORD_REQUIRE_UPPERCASE', True): - password_chars.append(secrets.choice(uppercase)) - - if current_app.config.get('PASSWORD_REQUIRE_LOWERCASE', True): - password_chars.append(secrets.choice(lowercase)) - - if current_app.config.get('PASSWORD_REQUIRE_DIGITS', True): - password_chars.append(secrets.choice(digits)) - - if current_app.config.get('PASSWORD_REQUIRE_SPECIAL', True): - password_chars.append(secrets.choice(special)) - - # Fill the rest with random characters from all sets - all_chars = uppercase + lowercase + digits + special - while len(password_chars) < length: - password_chars.append(secrets.choice(all_chars)) - - # Shuffle to avoid predictable patterns - import random - random.shuffle(password_chars) - - return ''.join(password_chars) - diff --git a/app/utils/permissions.py b/app/utils/permissions.py deleted file mode 100644 index 93236c8..0000000 --- a/app/utils/permissions.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Permission decorators and role enforcement utilities""" -from functools import wraps -from flask import abort, jsonify, request -from flask_login import current_user -from app.models import Membership, Organization - - -def login_required(f): - """Require user to be logged in (compatible with both session and JWT)""" - @wraps(f) - def decorated_function(*args, **kwargs): - from flask_login import current_user - from flask import g - - # Check session-based auth first - if current_user.is_authenticated: - return f(*args, **kwargs) - - # Check JWT auth - if hasattr(g, 'current_user') and g.current_user: - return f(*args, **kwargs) - - # No authentication found - if request.is_json or request.headers.get('Accept') == 'application/json': - return jsonify({'error': 'Authentication required'}), 401 - - abort(401) - - return decorated_function - - -def admin_required(f): - """Require user to be an admin""" - @wraps(f) - def decorated_function(*args, **kwargs): - from flask_login import current_user - from flask import g - - user = current_user if current_user.is_authenticated else getattr(g, 'current_user', None) - - if not user or not user.is_admin: - if request.is_json or request.headers.get('Accept') == 'application/json': - return jsonify({'error': 'Admin access required'}), 403 - abort(403) - - return f(*args, **kwargs) - - return decorated_function - - -def organization_member_required(f): - """Require user to be a member of the organization (from route parameter or query). - - Expects organization_id or org_slug in route or query parameters. - """ - @wraps(f) - def decorated_function(*args, **kwargs): - from flask_login import current_user - from flask import g - - user = current_user if current_user.is_authenticated else getattr(g, 'current_user', None) - - if not user: - if request.is_json or request.headers.get('Accept') == 'application/json': - return jsonify({'error': 'Authentication required'}), 401 - abort(401) - - # Get organization from route or query params - org_id = kwargs.get('organization_id') or request.args.get('organization_id') - org_slug = kwargs.get('org_slug') or request.args.get('org_slug') - - if not org_id and not org_slug: - if request.is_json: - return jsonify({'error': 'Organization not specified'}), 400 - abort(400) - - # Get organization - if org_slug: - org = Organization.get_by_slug(org_slug) - else: - org = Organization.query.get(org_id) - - if not org or not org.is_active: - if request.is_json: - return jsonify({'error': 'Organization not found'}), 404 - abort(404) - - # Check membership - if not Membership.user_is_member(user.id, org.id): - if request.is_json: - return jsonify({'error': 'You are not a member of this organization'}), 403 - abort(403) - - # Add organization to kwargs for convenience - kwargs['organization'] = org - - return f(*args, **kwargs) - - return decorated_function - - -def organization_admin_required(f): - """Require user to be an admin of the organization. - - Expects organization_id or org_slug in route or query parameters. - """ - @wraps(f) - def decorated_function(*args, **kwargs): - from flask_login import current_user - from flask import g - - user = current_user if current_user.is_authenticated else getattr(g, 'current_user', None) - - if not user: - if request.is_json or request.headers.get('Accept') == 'application/json': - return jsonify({'error': 'Authentication required'}), 401 - abort(401) - - # Global admins bypass organization checks - if user.is_admin: - return f(*args, **kwargs) - - # Get organization from route or query params - org_id = kwargs.get('organization_id') or request.args.get('organization_id') - org_slug = kwargs.get('org_slug') or request.args.get('org_slug') - - if not org_id and not org_slug: - if request.is_json: - return jsonify({'error': 'Organization not specified'}), 400 - abort(400) - - # Get organization - if org_slug: - org = Organization.get_by_slug(org_slug) - else: - org = Organization.query.get(org_id) - - if not org or not org.is_active: - if request.is_json: - return jsonify({'error': 'Organization not found'}), 404 - abort(404) - - # Check if user is org admin - if not Membership.user_is_admin(user.id, org.id): - if request.is_json: - return jsonify({'error': 'Organization admin access required'}), 403 - abort(403) - - # Add organization to kwargs for convenience - kwargs['organization'] = org - - return f(*args, **kwargs) - - return decorated_function - - -def can_edit_data(f): - """Require user to have edit permissions in the organization. - - Members and admins can edit, but not viewers. - """ - @wraps(f) - def decorated_function(*args, **kwargs): - from flask_login import current_user - from flask import g - - user = current_user if current_user.is_authenticated else getattr(g, 'current_user', None) - - if not user: - if request.is_json or request.headers.get('Accept') == 'application/json': - return jsonify({'error': 'Authentication required'}), 401 - abort(401) - - # Global admins bypass checks - if user.is_admin: - return f(*args, **kwargs) - - # Get organization from route or query params - org_id = kwargs.get('organization_id') or request.args.get('organization_id') - org_slug = kwargs.get('org_slug') or request.args.get('org_slug') - - if org_id or org_slug: - # Get organization - if org_slug: - org = Organization.get_by_slug(org_slug) - else: - org = Organization.query.get(org_id) - - if not org or not org.is_active: - if request.is_json: - return jsonify({'error': 'Organization not found'}), 404 - abort(404) - - # Check membership and role - membership = Membership.find_membership(user.id, org.id) - - if not membership or not membership.can_edit_data: - if request.is_json: - return jsonify({'error': 'You do not have permission to edit data'}), 403 - abort(403) - - return f(*args, **kwargs) - - return decorated_function - - -def check_user_permission(user, organization_id, required_permission): - """Check if user has a specific permission in an organization. - - Args: - user: User object - organization_id: Organization ID - required_permission: Permission to check ('view', 'edit', 'admin', 'manage_members', 'manage_projects') - - Returns: - bool: True if user has permission - """ - if not user: - return False - - # Global admins have all permissions - if user.is_admin: - return True - - # Get membership - membership = Membership.find_membership(user.id, organization_id) - - if not membership or not membership.is_active: - return False - - # Check permission based on role - if required_permission == 'view': - return True # All active members can view - elif required_permission == 'edit': - return membership.can_edit_data - elif required_permission in ['admin', 'manage_members']: - return membership.can_manage_members - elif required_permission == 'manage_projects': - return membership.can_manage_projects - - return False - - -def get_current_user(): - """Get current user from either Flask-Login session or JWT. - - Returns: - User: Current user object or None - """ - from flask_login import current_user - from flask import g - - if current_user.is_authenticated: - return current_user - - if hasattr(g, 'current_user'): - return g.current_user - - return None - - -def get_user_organizations(user): - """Get all organizations a user belongs to. - - Args: - user: User object - - Returns: - list: List of Organization objects - """ - if not user: - return [] - - memberships = Membership.get_user_active_memberships(user.id) - return [m.organization for m in memberships if m.organization and m.organization.is_active] - - -def get_user_role_in_organization(user, organization_id): - """Get user's role in an organization. - - Args: - user: User object - organization_id: Organization ID - - Returns: - str: Role name ('admin', 'member', 'viewer') or None if not a member - """ - if not user: - return None - - # Global admins are implicitly org admins - if user.is_admin: - return 'admin' - - membership = Membership.find_membership(user.id, organization_id) - return membership.role if membership else None - - -def require_permission(permission): - """Decorator factory for custom permission requirements. - - Args: - permission: Permission name ('view', 'edit', 'admin', 'manage_members', 'manage_projects') - - Returns: - Decorator function - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - user = get_current_user() - - if not user: - if request.is_json: - return jsonify({'error': 'Authentication required'}), 401 - abort(401) - - # Get organization from route or query params - org_id = kwargs.get('organization_id') or request.args.get('organization_id') - org_slug = kwargs.get('org_slug') or request.args.get('org_slug') - - if org_id or org_slug: - # Get organization - if org_slug: - org = Organization.get_by_slug(org_slug) - else: - org = Organization.query.get(org_id) - - if not org or not org.is_active: - if request.is_json: - return jsonify({'error': 'Organization not found'}), 404 - abort(404) - - # Check permission - if not check_user_permission(user, org.id, permission): - if request.is_json: - return jsonify({'error': f'{permission.capitalize()} permission required'}), 403 - abort(403) - - return f(*args, **kwargs) - - return decorated_function - - return decorator - diff --git a/app/utils/promo_code_service.py b/app/utils/promo_code_service.py deleted file mode 100644 index 258bf35..0000000 --- a/app/utils/promo_code_service.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Promo Code Service for handling discounts and promotions""" - -import stripe -from typing import Optional, Dict, Any, Tuple -from flask import current_app -from app import db -from app.models.promo_code import PromoCode, PromoCodeRedemption -from app.models.organization import Organization - - -class PromoCodeService: - """Service for managing promo codes and Stripe coupons""" - - def __init__(self): - """Initialize promo code service""" - self._initialized = False - - def _ensure_initialized(self): - """Ensure Stripe is initialized""" - if not self._initialized: - try: - api_key = current_app.config.get('STRIPE_SECRET_KEY') - if api_key: - stripe.api_key = api_key - self._initialized = True - except RuntimeError: - pass - - def validate_promo_code(self, code: str, organization: Organization = None) -> Tuple[bool, Optional[PromoCode], str]: - """Validate a promo code - - Args: - code: Promo code string - organization: Organization trying to use the code (optional) - - Returns: - Tuple of (is_valid, promo_code_object, message) - """ - # Find promo code - promo_code = PromoCode.query.filter_by(code=code.upper()).first() - - if not promo_code: - return False, None, "Promo code not found" - - if not promo_code.is_valid: - return False, promo_code, "Promo code is expired or has reached its usage limit" - - # Check organization-specific restrictions if provided - if organization: - can_use, message = promo_code.can_be_used_by(organization) - if not can_use: - return False, promo_code, message - - return True, promo_code, "Promo code is valid" - - def apply_promo_code(self, code: str, organization: Organization, user_id: int = None) -> Tuple[bool, Optional[str], str]: - """Apply a promo code to an organization - - Args: - code: Promo code string - organization: Organization to apply code to - user_id: User who is applying the code (optional) - - Returns: - Tuple of (success, stripe_coupon_id, message) - """ - # Validate code - is_valid, promo_code, message = self.validate_promo_code(code, organization) - - if not is_valid: - return False, None, message - - try: - # Sync with Stripe if not already synced - if not promo_code.stripe_coupon_id: - self._sync_to_stripe(promo_code) - - # Record redemption - redemption = promo_code.redeem( - organization_id=organization.id, - user_id=user_id - ) - - # Update organization with promo code info - organization.promo_code = promo_code.code - organization.promo_code_applied_at = redemption.redeemed_at - db.session.commit() - - return True, promo_code.stripe_coupon_id, f"Promo code applied! {promo_code.get_discount_description()}" - - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error applying promo code: {str(e)}") - return False, None, "An error occurred while applying the promo code" - - def _sync_to_stripe(self, promo_code: PromoCode) -> str: - """Sync promo code to Stripe as a coupon - - Args: - promo_code: PromoCode instance - - Returns: - Stripe coupon ID - """ - self._ensure_initialized() - - if promo_code.stripe_coupon_id: - return promo_code.stripe_coupon_id - - try: - # Create Stripe coupon - coupon_params = { - 'name': promo_code.description or promo_code.code, - 'metadata': { - 'promo_code_id': str(promo_code.id), - 'code': promo_code.code, - } - } - - # Set discount amount - if promo_code.discount_type == 'percent': - coupon_params['percent_off'] = float(promo_code.discount_value) - else: - # Fixed amount in cents - coupon_params['amount_off'] = int(promo_code.discount_value * 100) - coupon_params['currency'] = 'eur' - - # Set duration - if promo_code.duration == 'once': - coupon_params['duration'] = 'once' - elif promo_code.duration == 'forever': - coupon_params['duration'] = 'forever' - elif promo_code.duration == 'repeating': - coupon_params['duration'] = 'repeating' - coupon_params['duration_in_months'] = promo_code.duration_in_months - - # Set max redemptions - if promo_code.max_redemptions: - coupon_params['max_redemptions'] = promo_code.max_redemptions - - # Set expiry - if promo_code.valid_until: - import time - coupon_params['redeem_by'] = int(time.mktime(promo_code.valid_until.timetuple())) - - # Create coupon in Stripe - coupon = stripe.Coupon.create(**coupon_params) - - # Create promotion code in Stripe - promotion_code = stripe.PromotionCode.create( - coupon=coupon.id, - code=promo_code.code, - active=promo_code.is_active, - ) - - # Update promo code with Stripe IDs - promo_code.stripe_coupon_id = coupon.id - promo_code.stripe_promotion_code_id = promotion_code.id - db.session.commit() - - current_app.logger.info(f"Synced promo code {promo_code.code} to Stripe: {coupon.id}") - - return coupon.id - - except stripe.error.StripeError as e: - current_app.logger.error(f"Stripe error syncing promo code: {str(e)}") - raise - - def create_promo_code(self, code: str, discount_type: str, discount_value: float, - duration: str = 'once', duration_in_months: int = None, - description: str = None, max_redemptions: int = None, - valid_until = None, first_time_only: bool = False, - sync_to_stripe: bool = True) -> PromoCode: - """Create a new promo code - - Args: - code: Unique promo code string - discount_type: 'percent' or 'fixed' - discount_value: Discount value (percentage or fixed amount) - duration: 'once', 'repeating', or 'forever' - duration_in_months: Number of months for repeating discounts - description: Human-readable description - max_redemptions: Maximum number of times code can be used - valid_until: Expiration datetime - first_time_only: Only allow new customers to use - sync_to_stripe: Immediately sync to Stripe - - Returns: - Created PromoCode instance - """ - promo_code = PromoCode( - code=code.upper(), - description=description, - discount_type=discount_type, - discount_value=discount_value, - duration=duration, - duration_in_months=duration_in_months, - max_redemptions=max_redemptions, - valid_until=valid_until, - first_time_only=first_time_only, - is_active=True - ) - - db.session.add(promo_code) - db.session.commit() - - if sync_to_stripe: - try: - self._sync_to_stripe(promo_code) - except Exception as e: - current_app.logger.error(f"Failed to sync promo code to Stripe: {str(e)}") - # Don't fail the creation, just log the error - - return promo_code - - def get_promo_code_by_code(self, code: str) -> Optional[PromoCode]: - """Get promo code by code string""" - return PromoCode.query.filter_by(code=code.upper()).first() - - def deactivate_promo_code(self, code: str) -> bool: - """Deactivate a promo code""" - promo_code = self.get_promo_code_by_code(code) - - if not promo_code: - return False - - promo_code.is_active = False - db.session.commit() - - # Also deactivate in Stripe if synced - if promo_code.stripe_promotion_code_id: - try: - self._ensure_initialized() - stripe.PromotionCode.modify( - promo_code.stripe_promotion_code_id, - active=False - ) - except stripe.error.StripeError as e: - current_app.logger.error(f"Error deactivating Stripe promotion code: {str(e)}") - - return True - - def get_redemptions(self, promo_code: PromoCode) -> list: - """Get all redemptions for a promo code""" - return PromoCodeRedemption.query.filter_by(promo_code_id=promo_code.id).all() - - def get_organization_promo_codes(self, organization: Organization) -> list: - """Get all promo codes used by an organization""" - redemptions = PromoCodeRedemption.query.filter_by(organization_id=organization.id).all() - return [r.promo_code for r in redemptions] - - -# Global instance -promo_code_service = PromoCodeService() - diff --git a/app/utils/provisioning_service.py b/app/utils/provisioning_service.py deleted file mode 100644 index 7a71542..0000000 --- a/app/utils/provisioning_service.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -Provisioning Service - -Handles automated tenant provisioning after successful payment or trial signup. -This includes creating default resources, setting up admin accounts, and triggering -welcome communications. -""" - -from datetime import datetime -from typing import Dict, Any, Optional -from flask import current_app, url_for -from app import db -from app.models.organization import Organization -from app.models.user import User -from app.models.membership import Membership -from app.models.project import Project -from app.utils.email_service import email_service - - -class ProvisioningService: - """Service for automated tenant provisioning and onboarding.""" - - def provision_organization(self, organization: Organization, - admin_user: Optional[User] = None, - trigger: str = 'payment') -> Dict[str, Any]: - """Provision a new organization with default resources. - - This is the main entry point for provisioning. It: - 1. Creates a default project - 2. Sets up admin account if needed - 3. Initializes onboarding checklist - 4. Sends welcome email - - Args: - organization: Organization to provision - admin_user: Optional admin user (if already exists) - trigger: Provisioning trigger ('payment', 'trial', 'manual') - - Returns: - Dictionary with provisioning results - """ - current_app.logger.info(f"Starting provisioning for organization {organization.name} (trigger: {trigger})") - - results = { - 'organization_id': organization.id, - 'organization_name': organization.name, - 'trigger': trigger, - 'provisioned_at': datetime.utcnow(), - 'resources_created': [], - 'errors': [] - } - - try: - # 1. Create default project - project = self._create_default_project(organization) - if project: - results['resources_created'].append(f"Default project: {project.name}") - current_app.logger.info(f"Created default project '{project.name}' for {organization.name}") - - # 2. Ensure admin membership exists - if admin_user: - membership = self._ensure_admin_membership(organization, admin_user) - if membership: - results['admin_user_id'] = admin_user.id - results['resources_created'].append(f"Admin membership for {admin_user.username}") - - # 3. Initialize onboarding checklist - checklist = self._initialize_onboarding_checklist(organization) - if checklist: - results['resources_created'].append("Onboarding checklist") - results['onboarding_checklist_id'] = checklist.id - - # 4. Mark organization as provisioned - organization.status = 'active' - db.session.commit() - - # 5. Send welcome email - if admin_user and admin_user.email: - email_sent = self._send_welcome_email(organization, admin_user, trigger) - if email_sent: - results['resources_created'].append("Welcome email sent") - else: - results['errors'].append("Failed to send welcome email") - - results['success'] = True - current_app.logger.info(f"Successfully provisioned organization {organization.name}") - - except Exception as e: - current_app.logger.error(f"Error provisioning organization {organization.name}: {e}", exc_info=True) - results['success'] = False - results['errors'].append(str(e)) - # Rollback any partial changes - db.session.rollback() - - return results - - def provision_trial_organization(self, organization: Organization, - admin_user: User) -> Dict[str, Any]: - """Provision a trial organization immediately upon signup. - - Args: - organization: New organization - admin_user: User who created the organization - - Returns: - Provisioning results - """ - current_app.logger.info(f"Provisioning trial organization: {organization.name}") - - # Mark as trial - trial_days = current_app.config.get('STRIPE_TRIAL_DAYS', 14) - from datetime import timedelta - organization.trial_ends_at = datetime.utcnow() + timedelta(days=trial_days) - organization.stripe_subscription_status = 'trialing' - organization.subscription_plan = 'team' # Default trial plan - db.session.commit() - - # Provision the organization - return self.provision_organization(organization, admin_user, trigger='trial') - - def _create_default_project(self, organization: Organization) -> Optional[Project]: - """Create a default project for the organization. - - Args: - organization: Organization instance - - Returns: - Created Project or None if it fails - """ - try: - # Check if organization already has projects - if organization.projects.count() > 0: - current_app.logger.info(f"Organization {organization.name} already has projects, skipping default project creation") - return None - - # Create default project - project = Project( - name="Getting Started", - description="Your first project - feel free to rename or create new projects!", - organization_id=organization.id, - status='active', - hourly_rate=0.00, - currency_code=organization.currency - ) - - db.session.add(project) - db.session.commit() - - return project - - except Exception as e: - current_app.logger.error(f"Failed to create default project: {e}") - db.session.rollback() - return None - - def _ensure_admin_membership(self, organization: Organization, - user: User) -> Optional[Membership]: - """Ensure user has admin membership in organization. - - Args: - organization: Organization instance - user: User to make admin - - Returns: - Membership instance or None - """ - try: - # Check if membership already exists - membership = Membership.find_membership(user.id, organization.id) - - if membership: - # Ensure they're an admin - if membership.role != 'admin': - membership.role = 'admin' - membership.status = 'active' - db.session.commit() - return membership - else: - # Create new admin membership - membership = Membership( - user_id=user.id, - organization_id=organization.id, - role='admin', - status='active' - ) - db.session.add(membership) - db.session.commit() - return membership - - except Exception as e: - current_app.logger.error(f"Failed to ensure admin membership: {e}") - db.session.rollback() - return None - - def _initialize_onboarding_checklist(self, organization: Organization) -> Optional['OnboardingChecklist']: - """Initialize onboarding checklist for organization. - - Args: - organization: Organization instance - - Returns: - OnboardingChecklist instance or None - """ - try: - from app.models.onboarding_checklist import OnboardingChecklist - - # Check if checklist already exists - existing = OnboardingChecklist.query.filter_by( - organization_id=organization.id - ).first() - - if existing: - return existing - - # Create new checklist with default tasks - checklist = OnboardingChecklist( - organization_id=organization.id, - created_at=datetime.utcnow() - ) - - db.session.add(checklist) - db.session.commit() - - return checklist - - except Exception as e: - current_app.logger.error(f"Failed to create onboarding checklist: {e}") - db.session.rollback() - return None - - def _send_welcome_email(self, organization: Organization, - user: User, trigger: str) -> bool: - """Send welcome email to new organization admin. - - Args: - organization: Organization instance - user: Admin user - trigger: Provisioning trigger - - Returns: - True if email sent successfully - """ - try: - # Build email content - subject = f"Welcome to TimeTracker - {organization.name}" - - # Determine if trial or paid - is_trial = organization.is_on_trial - - # Generate body - body_text = self._generate_welcome_text(organization, user, is_trial) - body_html = self._generate_welcome_html(organization, user, is_trial) - - # Send email - return email_service.send_email( - to_email=user.email, - subject=subject, - body_text=body_text, - body_html=body_html - ) - - except Exception as e: - current_app.logger.error(f"Failed to send welcome email: {e}") - return False - - def _generate_welcome_text(self, organization: Organization, - user: User, is_trial: bool) -> str: - """Generate plain text welcome email.""" - - trial_text = "" - if is_trial: - trial_text = f""" -You're on a {organization.trial_days_remaining}-day free trial. Explore all features with no credit card required! -Your trial ends on {organization.trial_ends_at.strftime('%B %d, %Y')}. -""" - - return f""" -Hello {user.display_name}, - -Welcome to TimeTracker! Your organization "{organization.name}" is now ready. -{trial_text} - -🚀 GETTING STARTED - -We've set up your account with: -- ✅ Your organization: {organization.name} -- ✅ A default project to get started -- ✅ Admin access for full control - -📋 NEXT STEPS - -1. Invite your team members -2. Create your first project (or customize the default one) -3. Set your working hours and preferences -4. Start tracking time! - -Visit your dashboard: {url_for('main.dashboard', _external=True)} -Complete your onboarding: {url_for('onboarding.checklist', _external=True)} - -💡 TIPS - -- Use the command palette (Ctrl+K or ?) for quick navigation -- Set up billing information to avoid interruption after trial -- Explore our keyboard shortcuts for power users - -Need help? Check out our documentation or contact support. - -Best regards, -The TimeTracker Team -""" - - def _generate_welcome_html(self, organization: Organization, - user: User, is_trial: bool) -> str: - """Generate HTML welcome email.""" - - trial_section = "" - if is_trial: - trial_section = f""" -
-

- 🎉 Free Trial Active
- You have {organization.trial_days_remaining} days left in your trial. - Explore all features with no credit card required!
- Trial ends: {organization.trial_ends_at.strftime('%B %d, %Y')} -

-
- """ - - return f""" - - - - - - -
-
-

🎉 Welcome to TimeTracker!

-

Your organization is ready

-
- -
-

Hello {user.display_name},

- -

Congratulations! Your organization "{organization.name}" has been successfully set up and is ready to use.

- - {trial_section} - -
-

✨ We've set up your account with:

-
✅ Your organization: {organization.name}
-
✅ A default project to get started
-
✅ Admin access for full control
-
- -

📋 Next Steps

-
    -
  1. Invite team members - Add your colleagues to collaborate
  2. -
  3. Create projects - Organize your work effectively
  4. -
  5. Set preferences - Configure working hours and settings
  6. -
  7. Start tracking - Begin logging your time
  8. -
- - - -
-

💡 Pro Tips

-
    -
  • Press Ctrl+K or ? for quick navigation
  • -
  • Use keyboard shortcuts to work faster
  • -
  • Set up billing early to avoid trial expiration
  • -
-
- -

If you have any questions, our support team is here to help!

- - -
-
- - - """ - - -# Create singleton instance -provisioning_service = ProvisioningService() - diff --git a/app/utils/rate_limiting.py b/app/utils/rate_limiting.py deleted file mode 100644 index 6468180..0000000 --- a/app/utils/rate_limiting.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Rate Limiting Configuration - -This module provides comprehensive rate limiting rules for different -endpoint types to prevent abuse and DoS attacks. -""" - -from functools import wraps -from flask import request, jsonify -from app import limiter - - -# Authentication endpoints - strict limits -AUTH_RATE_LIMITS = { - 'login': '5 per minute', - 'register': '3 per hour', - 'password_reset': '3 per hour', - 'password_change': '10 per hour', - '2fa_setup': '5 per hour', - '2fa_verify': '10 per 5 minutes', -} - -# API endpoints - moderate limits -API_RATE_LIMITS = { - 'read': '100 per minute', - 'write': '60 per minute', - 'delete': '30 per minute', - 'bulk': '10 per minute', -} - -# Administrative endpoints - moderate limits with higher ceiling -ADMIN_RATE_LIMITS = { - 'general': '200 per minute', - 'write': '100 per minute', -} - -# Public endpoints - lenient limits -PUBLIC_RATE_LIMITS = { - 'general': '1000 per hour', -} - -# GDPR endpoints - very strict limits -GDPR_RATE_LIMITS = { - 'export': '5 per hour', - 'delete': '2 per hour', -} - - -def apply_rate_limit(limit_type='general', limit_string=None): - """ - Decorator to apply rate limiting to routes. - - Args: - limit_type: Type of limit ('auth', 'api', 'admin', 'public', 'gdpr') - limit_string: Custom limit string (e.g., '100 per minute') - - Example: - @apply_rate_limit('auth', 'login') - def login(): - pass - """ - def decorator(f): - @wraps(f) - def wrapper(*args, **kwargs): - return f(*args, **kwargs) - - # Apply the limit - if limit_string: - limiter.limit(limit_string)(wrapper) - elif limit_type == 'auth': - limiter.limit(AUTH_RATE_LIMITS.get('general', '10 per minute'))(wrapper) - elif limit_type == 'api': - limiter.limit(API_RATE_LIMITS.get('read', '100 per minute'))(wrapper) - elif limit_type == 'admin': - limiter.limit(ADMIN_RATE_LIMITS.get('general', '200 per minute'))(wrapper) - elif limit_type == 'gdpr': - limiter.limit(GDPR_RATE_LIMITS.get('export', '5 per hour'))(wrapper) - else: - limiter.limit(PUBLIC_RATE_LIMITS.get('general', '1000 per hour'))(wrapper) - - return wrapper - - return decorator - - -def get_client_ip(): - """Get the real client IP address, accounting for proxies""" - if request.headers.get('X-Forwarded-For'): - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - return request.remote_addr - - -def rate_limit_error_handler(e): - """Custom error handler for rate limit exceeded""" - return jsonify({ - 'error': 'rate_limit_exceeded', - 'message': 'Too many requests. Please try again later.', - 'retry_after': e.description - }), 429 - - -# Rate limit exemptions (e.g., for health checks, webhooks) -RATE_LIMIT_EXEMPTIONS = [ - '/_health', - '/health', - '/metrics', - '/webhooks/stripe', # Stripe webhooks should not be rate limited -] - - -def is_exempt_from_rate_limit(): - """Check if the current request should be exempt from rate limiting""" - return request.path in RATE_LIMIT_EXEMPTIONS - diff --git a/app/utils/rls.py b/app/utils/rls.py deleted file mode 100644 index ab06ec8..0000000 --- a/app/utils/rls.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Row Level Security (RLS) integration for PostgreSQL multi-tenancy. - -This module provides utilities to set and manage PostgreSQL RLS context -for tenant isolation at the database level. -""" - -from functools import wraps -from flask import g -from sqlalchemy import text -from app import db -import logging - -logger = logging.getLogger(__name__) - - -def set_rls_context(organization_id, is_super_admin=False): - """Set the PostgreSQL RLS context for the current request. - - This function sets session variables that are used by Row Level Security - policies to filter data at the database level. - - Args: - organization_id: ID of the organization to set context for - is_super_admin: Whether the current user is a super admin (can access all orgs) - - Returns: - bool: True if successful, False otherwise - """ - if not organization_id and not is_super_admin: - logger.warning("Attempted to set RLS context without organization_id and not super admin") - return False - - try: - # Check if we're using PostgreSQL - if 'postgresql' not in str(db.engine.url): - logger.debug("RLS context setting skipped - not using PostgreSQL") - return True - - # Set the organization context in PostgreSQL - org_id_str = str(organization_id) if organization_id else '' - - db.session.execute(text( - "SELECT set_config('app.current_organization_id', :org_id, false)" - ), {"org_id": org_id_str}) - - db.session.execute(text( - "SELECT set_config('app.is_super_admin', :is_admin, false)" - ), {"is_admin": str(is_super_admin).lower()}) - - logger.debug(f"RLS context set: org_id={organization_id}, super_admin={is_super_admin}") - return True - - except Exception as e: - logger.error(f"Failed to set RLS context: {e}") - return False - - -def clear_rls_context(): - """Clear the PostgreSQL RLS context. - - This should be called at the end of each request to clean up. - - Returns: - bool: True if successful, False otherwise - """ - try: - # Check if we're using PostgreSQL - if 'postgresql' not in str(db.engine.url): - return True - - db.session.execute(text( - "SELECT set_config('app.current_organization_id', '', false)" - )) - - db.session.execute(text( - "SELECT set_config('app.is_super_admin', 'false', false)" - )) - - logger.debug("RLS context cleared") - return True - - except Exception as e: - logger.error(f"Failed to clear RLS context: {e}") - return False - - -def init_rls_for_request(): - """Initialize RLS context for the current request. - - This should be called in a before_request handler. It: - 1. Gets the current organization from the request context - 2. Determines if the user is a super admin - 3. Sets the RLS context in PostgreSQL - """ - from app.utils.tenancy import get_current_organization_id - from flask_login import current_user - - try: - # Get current organization from tenancy context - org_id = get_current_organization_id() - - # Check if user is a super admin (global admin, not org admin) - is_super_admin = False - if current_user and current_user.is_authenticated: - # Super admins are users with 'admin' role at the application level - # (not just organization-level admins) - is_super_admin = getattr(current_user, 'is_admin', False) - - # Set RLS context if we have an organization - if org_id or is_super_admin: - set_rls_context(org_id, is_super_admin) - - except Exception as e: - logger.error(f"Error initializing RLS for request: {e}") - - -def cleanup_rls_for_request(): - """Cleanup RLS context after the request. - - This should be called in an after_request or teardown_request handler. - """ - try: - clear_rls_context() - except Exception as e: - logger.error(f"Error cleaning up RLS after request: {e}") - - -def with_rls_context(organization_id, is_super_admin=False): - """Decorator to temporarily set RLS context for a function. - - Useful for background tasks or CLI commands that need to operate - within a specific organization context. - - Args: - organization_id: ID of the organization - is_super_admin: Whether to run as super admin - - Example: - @with_rls_context(organization_id=1) - def process_org_data(): - # This function runs within organization 1's context - projects = Project.query.all() # Only sees org 1 projects - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - # Set context - set_rls_context(organization_id, is_super_admin) - - try: - # Execute function - result = f(*args, **kwargs) - return result - finally: - # Always clear context - clear_rls_context() - - return decorated_function - return decorator - - -def verify_rls_is_active(): - """Verify that RLS is active and working correctly. - - This is useful for testing and deployment verification. - - Returns: - dict: Status information about RLS - """ - try: - # Check if we're using PostgreSQL - if 'postgresql' not in str(db.engine.url): - return { - 'enabled': False, - 'reason': 'Not using PostgreSQL', - 'database_type': str(db.engine.url).split(':')[0] - } - - # Check if RLS policies exist - result = db.session.execute(text(""" - SELECT - schemaname, - tablename, - policyname, - permissive, - roles, - cmd, - qual, - with_check - FROM pg_policies - WHERE schemaname = 'public' - AND policyname LIKE '%tenant_isolation%' - """)) - - policies = list(result) - - # Check if helper functions exist - functions_result = db.session.execute(text(""" - SELECT - proname - FROM pg_proc - WHERE proname IN ( - 'current_organization_id', - 'is_super_admin', - 'set_organization_context', - 'clear_organization_context' - ) - """)) - - functions = [row[0] for row in functions_result] - - return { - 'enabled': len(policies) > 0, - 'policies_count': len(policies), - 'policies': [ - { - 'table': p.tablename, - 'policy': p.policyname, - 'command': p.cmd - } for p in policies - ], - 'functions': functions, - 'all_functions_present': len(functions) == 4 - } - - except Exception as e: - logger.error(f"Error verifying RLS status: {e}") - return { - 'enabled': False, - 'error': str(e) - } - - -def test_rls_isolation(org1_id, org2_id): - """Test that RLS properly isolates data between organizations. - - This creates test data in two organizations and verifies that - setting the context for one org only shows that org's data. - - Args: - org1_id: ID of first test organization - org2_id: ID of second test organization - - Returns: - dict: Test results - """ - from app.models import Project, Organization - - results = { - 'success': True, - 'tests': [] - } - - try: - # Test 1: Set context to org1 and verify we only see org1 data - set_rls_context(org1_id) - - org1_projects = Project.query.filter_by(organization_id=org1_id).count() - org2_projects_visible = Project.query.filter_by(organization_id=org2_id).count() - - results['tests'].append({ - 'name': 'Org 1 Context - Isolation Test', - 'passed': org2_projects_visible == 0, - 'org1_visible': org1_projects, - 'org2_visible': org2_projects_visible, - 'expected_org2': 0 - }) - - # Test 2: Set context to org2 and verify we only see org2 data - set_rls_context(org2_id) - - org2_projects = Project.query.filter_by(organization_id=org2_id).count() - org1_projects_visible = Project.query.filter_by(organization_id=org1_id).count() - - results['tests'].append({ - 'name': 'Org 2 Context - Isolation Test', - 'passed': org1_projects_visible == 0, - 'org2_visible': org2_projects, - 'org1_visible': org1_projects_visible, - 'expected_org1': 0 - }) - - # Test 3: Super admin can see all data - set_rls_context(None, is_super_admin=True) - - all_projects = Project.query.count() - - results['tests'].append({ - 'name': 'Super Admin - Can See All Data', - 'passed': all_projects >= (org1_projects + org2_projects), - 'visible_count': all_projects, - 'expected_minimum': org1_projects + org2_projects - }) - - # Determine overall success - results['success'] = all(test['passed'] for test in results['tests']) - - except Exception as e: - results['success'] = False - results['error'] = str(e) - - finally: - clear_rls_context() - - return results - diff --git a/app/utils/seat_sync.py b/app/utils/seat_sync.py deleted file mode 100644 index 0c34800..0000000 --- a/app/utils/seat_sync.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -Seat Synchronization Service - -This module handles automatic synchronization of subscription quantities -with Stripe when users are added or removed from an organization. -""" - -from flask import current_app -from app import db -from app.models.organization import Organization -from app.models.membership import Membership -from app.utils.stripe_service import stripe_service - - -class SeatSyncService: - """Service for synchronizing subscription seats with Stripe.""" - - @staticmethod - def calculate_required_seats(organization): - """Calculate the number of seats required for an organization. - - Args: - organization: Organization instance - - Returns: - int: Number of active members in the organization - """ - return organization.member_count - - @staticmethod - def should_sync_seats(organization): - """Check if an organization should sync seats with Stripe. - - Args: - organization: Organization instance - - Returns: - bool: True if seats should be synced - """ - # Only sync for team plans with active subscriptions - return ( - organization.subscription_plan == 'team' and - organization.has_active_subscription and - organization.stripe_subscription_id is not None - ) - - @staticmethod - def sync_seats(organization, new_quantity=None, prorate=None): - """Synchronize subscription seats with Stripe. - - Args: - organization: Organization instance - new_quantity: Optional explicit seat count (defaults to member_count) - prorate: Whether to prorate charges (uses config default if not provided) - - Returns: - dict: Result of sync operation with 'success' and 'message' keys - """ - if not stripe_service.is_configured(): - return { - 'success': False, - 'message': 'Stripe is not configured' - } - - if not SeatSyncService.should_sync_seats(organization): - return { - 'success': False, - 'message': 'Seat sync not applicable for this organization' - } - - # Calculate required seats - if new_quantity is None: - new_quantity = SeatSyncService.calculate_required_seats(organization) - - # Ensure minimum of 1 seat - new_quantity = max(1, new_quantity) - - # Check if quantity changed - if new_quantity == organization.subscription_quantity: - return { - 'success': True, - 'message': 'Seat count is already up to date', - 'quantity': new_quantity - } - - try: - # Update subscription in Stripe - result = stripe_service.update_subscription_quantity( - organization=organization, - new_quantity=new_quantity, - prorate=prorate - ) - - return { - 'success': True, - 'message': f"Seats updated from {result['old_quantity']} to {result['new_quantity']}", - 'old_quantity': result['old_quantity'], - 'new_quantity': result['new_quantity'], - 'prorated': prorate if prorate is not None else current_app.config.get('STRIPE_ENABLE_PRORATION', True) - } - except Exception as e: - current_app.logger.error(f"Failed to sync seats for organization {organization.id}: {e}") - return { - 'success': False, - 'message': f"Failed to update seats: {str(e)}" - } - - @staticmethod - def on_member_added(organization, user): - """Handle member addition - sync seats if needed. - - Args: - organization: Organization instance - user: User that was added - - Returns: - dict: Result of sync operation - """ - current_app.logger.info( - f"Member {user.username} added to organization {organization.name} ({organization.id})" - ) - - # Sync seats - result = SeatSyncService.sync_seats(organization) - - if result['success']: - current_app.logger.info( - f"Seats synced for organization {organization.id}: {result['message']}" - ) - else: - current_app.logger.warning( - f"Seat sync failed for organization {organization.id}: {result['message']}" - ) - - return result - - @staticmethod - def on_member_removed(organization, user): - """Handle member removal - sync seats if needed. - - Args: - organization: Organization instance - user: User that was removed - - Returns: - dict: Result of sync operation - """ - current_app.logger.info( - f"Member {user.username} removed from organization {organization.name} ({organization.id})" - ) - - # Sync seats - result = SeatSyncService.sync_seats(organization) - - if result['success']: - current_app.logger.info( - f"Seats synced for organization {organization.id}: {result['message']}" - ) - else: - current_app.logger.warning( - f"Seat sync failed for organization {organization.id}: {result['message']}" - ) - - return result - - @staticmethod - def on_invitation_accepted(organization, user): - """Handle invitation acceptance - sync seats if needed. - - Args: - organization: Organization instance - user: User that accepted invitation - - Returns: - dict: Result of sync operation - """ - return SeatSyncService.on_member_added(organization, user) - - @staticmethod - def check_seat_limit(organization): - """Check if organization has reached its seat limit. - - Args: - organization: Organization instance - - Returns: - dict: Result with 'can_add', 'current_count', 'limit', and 'message' - """ - current_count = organization.member_count - - # For team plans, check against subscription quantity - if organization.subscription_plan == 'team': - limit = organization.subscription_quantity - - if current_count >= limit: - return { - 'can_add': False, - 'current_count': current_count, - 'limit': limit, - 'message': f'Organization has reached its seat limit ({limit} seats). Please upgrade to add more users.' - } - - return { - 'can_add': True, - 'current_count': current_count, - 'limit': limit, - 'remaining': limit - current_count, - 'message': f'{limit - current_count} seat(s) remaining' - } - - # For other plans, check against max_users - if organization.max_users is not None: - if current_count >= organization.max_users: - return { - 'can_add': False, - 'current_count': current_count, - 'limit': organization.max_users, - 'message': f'Organization has reached its user limit ({organization.max_users} users)' - } - - # No limit or under limit - return { - 'can_add': True, - 'current_count': current_count, - 'limit': organization.max_users, - 'message': 'Can add users' - } - - -# Create singleton instance -seat_sync_service = SeatSyncService() - - -# Convenience functions for use in routes - -def sync_seats_on_member_change(organization_id, action='sync', user_id=None): - """Convenience function to sync seats when membership changes. - - Args: - organization_id: Organization ID - action: Type of action ('add', 'remove', 'sync') - user_id: User ID involved in the change (optional) - - Returns: - dict: Result of sync operation - """ - organization = Organization.query.get(organization_id) - if not organization: - return {'success': False, 'message': 'Organization not found'} - - from app.models.user import User - user = User.query.get(user_id) if user_id else None - - if action == 'add' and user: - return seat_sync_service.on_member_added(organization, user) - elif action == 'remove' and user: - return seat_sync_service.on_member_removed(organization, user) - else: - return seat_sync_service.sync_seats(organization) - - -def check_can_add_member(organization_id): - """Check if a new member can be added to an organization. - - Args: - organization_id: Organization ID - - Returns: - dict: Result with 'can_add' and other details - """ - organization = Organization.query.get(organization_id) - if not organization: - return {'can_add': False, 'message': 'Organization not found'} - - return seat_sync_service.check_seat_limit(organization) - diff --git a/app/utils/stripe_service.py b/app/utils/stripe_service.py deleted file mode 100644 index f5e0968..0000000 --- a/app/utils/stripe_service.py +++ /dev/null @@ -1,872 +0,0 @@ -""" -Stripe Service Module - -This module provides a clean interface for all Stripe API interactions. -It handles customer creation, subscription management, seat updates, and more. -""" - -import stripe -from datetime import datetime, timedelta -from typing import Optional, Dict, Any, List -from flask import current_app -from app import db -from app.models.organization import Organization -from app.models.subscription_event import SubscriptionEvent - - -class StripeService: - """Service class for Stripe API interactions.""" - - def __init__(self): - """Initialize Stripe service (configuration loaded lazily).""" - self._api_key = None - self._initialized = False - - def _ensure_initialized(self): - """Ensure Stripe is initialized with API key (lazy loading).""" - if not self._initialized: - try: - self._api_key = current_app.config.get('STRIPE_SECRET_KEY') - if self._api_key: - stripe.api_key = self._api_key - self._initialized = True - except RuntimeError: - # No application context available - pass - - def is_configured(self) -> bool: - """Check if Stripe is properly configured.""" - self._ensure_initialized() - return bool(self._api_key) - - # ======================================== - # Customer Management - # ======================================== - - def create_customer(self, organization: Organization, email: str = None, - name: str = None, metadata: Dict[str, Any] = None) -> str: - """Create a Stripe customer for an organization. - - Args: - organization: Organization instance - email: Customer email (defaults to organization billing_email) - name: Customer name (defaults to organization name) - metadata: Additional metadata to attach to customer - - Returns: - Stripe customer ID - - Raises: - stripe.error.StripeError: If customer creation fails - """ - self._ensure_initialized() - self._ensure_initialized() - if not self.is_configured(): - raise ValueError("Stripe is not configured") - - # Use organization data as defaults - email = email or organization.billing_email or organization.contact_email - name = name or organization.name - - # Prepare metadata - customer_metadata = { - 'organization_id': str(organization.id), - 'organization_slug': organization.slug, - } - if metadata: - customer_metadata.update(metadata) - - # Create customer - customer = stripe.Customer.create( - email=email, - name=name, - metadata=customer_metadata, - description=f"Organization: {organization.name}" - ) - - # Update organization with customer ID - organization.stripe_customer_id = customer.id - db.session.commit() - - # Log event - self._log_event( - organization=organization, - event_type='customer.created', - stripe_customer_id=customer.id, - notes=f"Created Stripe customer for organization {organization.name}" - ) - - return customer.id - - def get_or_create_customer(self, organization: Organization) -> str: - """Get existing customer ID or create a new customer. - - Args: - organization: Organization instance - - Returns: - Stripe customer ID - """ - self._ensure_initialized() - if organization.stripe_customer_id: - return organization.stripe_customer_id - - return self.create_customer(organization) - - def update_customer(self, organization: Organization, **kwargs) -> None: - """Update a Stripe customer. - - Args: - organization: Organization instance - **kwargs: Fields to update (email, name, metadata, etc.) - """ - self._ensure_initialized() - if not organization.stripe_customer_id: - raise ValueError("Organization does not have a Stripe customer") - - stripe.Customer.modify( - organization.stripe_customer_id, - **kwargs - ) - - # ======================================== - # Subscription Management - # ======================================== - - def create_subscription(self, organization: Organization, price_id: str, - quantity: int = 1, trial_days: Optional[int] = None) -> Dict[str, Any]: - """Create a subscription for an organization. - - Args: - organization: Organization instance - price_id: Stripe price ID (e.g., STRIPE_SINGLE_USER_PRICE_ID or STRIPE_TEAM_PRICE_ID) - quantity: Number of seats/units - trial_days: Number of trial days (uses config default if not provided) - - Returns: - Subscription data dictionary - - Raises: - stripe.error.StripeError: If subscription creation fails - """ - self._ensure_initialized() - if not self.is_configured(): - raise ValueError("Stripe is not configured") - - # Get or create customer - customer_id = self.get_or_create_customer(organization) - - # Prepare subscription parameters - subscription_params = { - 'customer': customer_id, - 'items': [{ - 'price': price_id, - 'quantity': quantity, - }], - 'metadata': { - 'organization_id': str(organization.id), - 'organization_slug': organization.slug, - }, - 'payment_behavior': 'default_incomplete', # Requires payment method - 'expand': ['latest_invoice.payment_intent'], - } - - # Add trial if enabled - if current_app.config.get('STRIPE_ENABLE_TRIALS', True): - trial_days = trial_days or current_app.config.get('STRIPE_TRIAL_DAYS', 14) - if trial_days > 0: - subscription_params['trial_period_days'] = trial_days - - # Enable proration if configured - if current_app.config.get('STRIPE_ENABLE_PRORATION', True): - subscription_params['proration_behavior'] = 'create_prorations' - - # Create subscription - subscription = stripe.Subscription.create(**subscription_params) - - # Update organization - self._update_organization_from_subscription(organization, subscription) - - # Log event - self._log_event( - organization=organization, - event_type='subscription.created', - stripe_customer_id=customer_id, - stripe_subscription_id=subscription.id, - status=subscription.status, - quantity=quantity, - notes=f"Created subscription with {quantity} seat(s)" - ) - - return { - 'subscription_id': subscription.id, - 'client_secret': subscription.latest_invoice.payment_intent.client_secret if subscription.latest_invoice else None, - 'status': subscription.status, - } - - def update_subscription_quantity(self, organization: Organization, new_quantity: int, - prorate: Optional[bool] = None) -> Dict[str, Any]: - """Update the quantity (number of seats) for a subscription. - - Args: - organization: Organization instance - new_quantity: New seat count - prorate: Whether to prorate charges (uses config default if not provided) - - Returns: - Updated subscription data - - Raises: - ValueError: If organization doesn't have a subscription - stripe.error.StripeError: If update fails - """ - if not organization.stripe_subscription_id: - raise ValueError("Organization does not have a Stripe subscription") - - if new_quantity < 1: - raise ValueError("Quantity must be at least 1") - - # Get current subscription - subscription = stripe.Subscription.retrieve(organization.stripe_subscription_id) - - # Get the subscription item ID (should be the first item) - if not subscription.items.data: - raise ValueError("Subscription has no items") - - subscription_item_id = subscription.items.data[0].id - old_quantity = subscription.items.data[0].quantity - - # Determine proration behavior - if prorate is None: - prorate = current_app.config.get('STRIPE_ENABLE_PRORATION', True) - - proration_behavior = 'create_prorations' if prorate else 'none' - - # Update subscription - updated_subscription = stripe.Subscription.modify( - organization.stripe_subscription_id, - items=[{ - 'id': subscription_item_id, - 'quantity': new_quantity, - }], - proration_behavior=proration_behavior, - ) - - # Update organization - organization.subscription_quantity = new_quantity - db.session.commit() - - # Log event - self._log_event( - organization=organization, - event_type='subscription.quantity_updated', - stripe_customer_id=organization.stripe_customer_id, - stripe_subscription_id=organization.stripe_subscription_id, - quantity=new_quantity, - previous_quantity=old_quantity, - notes=f"Updated seats from {old_quantity} to {new_quantity}" - ) - - return { - 'subscription_id': updated_subscription.id, - 'old_quantity': old_quantity, - 'new_quantity': new_quantity, - 'status': updated_subscription.status, - } - - def cancel_subscription(self, organization: Organization, at_period_end: bool = True) -> Dict[str, Any]: - """Cancel a subscription. - - Args: - organization: Organization instance - at_period_end: If True, cancel at end of billing period; if False, cancel immediately - - Returns: - Cancellation data - - Raises: - ValueError: If organization doesn't have a subscription - stripe.error.StripeError: If cancellation fails - """ - if not organization.stripe_subscription_id: - raise ValueError("Organization does not have a Stripe subscription") - - if at_period_end: - # Cancel at period end - subscription = stripe.Subscription.modify( - organization.stripe_subscription_id, - cancel_at_period_end=True - ) - organization.subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end) - else: - # Cancel immediately - subscription = stripe.Subscription.cancel(organization.stripe_subscription_id) - organization.stripe_subscription_status = 'canceled' - organization.subscription_ends_at = datetime.utcnow() - - db.session.commit() - - # Log event - self._log_event( - organization=organization, - event_type='subscription.canceled', - stripe_customer_id=organization.stripe_customer_id, - stripe_subscription_id=organization.stripe_subscription_id, - notes=f"Canceled {'at period end' if at_period_end else 'immediately'}" - ) - - return { - 'subscription_id': subscription.id, - 'status': subscription.status, - 'cancel_at_period_end': at_period_end, - 'ends_at': organization.subscription_ends_at, - } - - def reactivate_subscription(self, organization: Organization) -> Dict[str, Any]: - """Reactivate a subscription that was set to cancel at period end. - - Args: - organization: Organization instance - - Returns: - Reactivation data - """ - if not organization.stripe_subscription_id: - raise ValueError("Organization does not have a Stripe subscription") - - subscription = stripe.Subscription.modify( - organization.stripe_subscription_id, - cancel_at_period_end=False - ) - - organization.subscription_ends_at = None - db.session.commit() - - # Log event - self._log_event( - organization=organization, - event_type='subscription.reactivated', - stripe_customer_id=organization.stripe_customer_id, - stripe_subscription_id=organization.stripe_subscription_id, - notes="Reactivated subscription" - ) - - return { - 'subscription_id': subscription.id, - 'status': subscription.status, - } - - # ======================================== - # Checkout & Portal - # ======================================== - - def create_checkout_session(self, organization: Organization, price_id: str, - quantity: int = 1, success_url: str = None, - cancel_url: str = None) -> Dict[str, Any]: - """Create a Stripe Checkout session for subscription. - - Args: - organization: Organization instance - price_id: Stripe price ID - quantity: Number of seats - success_url: URL to redirect after successful payment - cancel_url: URL to redirect if checkout is cancelled - - Returns: - Checkout session data with URL - """ - self._ensure_initialized() - if not self.is_configured(): - raise ValueError("Stripe is not configured") - - # Get or create customer - customer_id = self.get_or_create_customer(organization) - - # Prepare checkout session parameters - checkout_params = { - 'customer': customer_id, - 'mode': 'subscription', - 'line_items': [{ - 'price': price_id, - 'quantity': quantity, - }], - 'success_url': success_url or f"{current_app.config.get('BASE_URL', '')}/billing/success?session_id={{CHECKOUT_SESSION_ID}}", - 'cancel_url': cancel_url or f"{current_app.config.get('BASE_URL', '')}/billing/cancelled", - 'metadata': { - 'organization_id': str(organization.id), - 'organization_slug': organization.slug, - }, - } - - # Add trial if enabled - if current_app.config.get('STRIPE_ENABLE_TRIALS', True): - trial_days = current_app.config.get('STRIPE_TRIAL_DAYS', 14) - if trial_days > 0: - checkout_params['subscription_data'] = { - 'trial_period_days': trial_days, - 'metadata': checkout_params['metadata'], - } - - # Create checkout session - session = stripe.checkout.Session.create(**checkout_params) - - return { - 'session_id': session.id, - 'url': session.url, - } - - def create_billing_portal_session(self, organization: Organization, - return_url: str = None) -> Dict[str, Any]: - """Create a Stripe Customer Portal session for managing subscription. - - Args: - organization: Organization instance - return_url: URL to return to after portal session - - Returns: - Portal session data with URL - """ - if not organization.stripe_customer_id: - raise ValueError("Organization does not have a Stripe customer") - - session = stripe.billing_portal.Session.create( - customer=organization.stripe_customer_id, - return_url=return_url or f"{current_app.config.get('BASE_URL', '')}/billing", - ) - - return { - 'session_id': session.id, - 'url': session.url, - } - - # ======================================== - # Invoice & Payment Info - # ======================================== - - def get_upcoming_invoice(self, organization: Organization) -> Optional[Dict[str, Any]]: - """Get the upcoming invoice for an organization. - - Args: - organization: Organization instance - - Returns: - Invoice data or None if no upcoming invoice - """ - self._ensure_initialized() - if not organization.stripe_customer_id: - return None - - try: - invoice = stripe.Invoice.upcoming( - customer=organization.stripe_customer_id - ) - - return { - 'id': invoice.id, - 'amount_due': invoice.amount_due / 100, # Convert from cents - 'currency': invoice.currency.upper(), - 'period_start': datetime.fromtimestamp(invoice.period_start), - 'period_end': datetime.fromtimestamp(invoice.period_end), - 'lines': [ - { - 'description': line.description, - 'amount': line.amount / 100, - 'quantity': line.quantity, - } - for line in invoice.lines.data - ], - } - except stripe.error.InvalidRequestError: - return None - - def get_invoices(self, organization: Organization, limit: int = 10) -> List[Dict[str, Any]]: - """Get past invoices for an organization. - - Args: - organization: Organization instance - limit: Maximum number of invoices to return - - Returns: - List of invoice data dictionaries - """ - self._ensure_initialized() - if not organization.stripe_customer_id: - return [] - - invoices = stripe.Invoice.list( - customer=organization.stripe_customer_id, - limit=limit - ) - - return [ - { - 'id': invoice.id, - 'number': invoice.number, - 'amount_paid': invoice.amount_paid / 100, - 'amount_due': invoice.amount_due / 100, - 'currency': invoice.currency.upper(), - 'status': invoice.status, - 'paid': invoice.paid, - 'created': datetime.fromtimestamp(invoice.created), - 'due_date': datetime.fromtimestamp(invoice.due_date) if invoice.due_date else None, - 'invoice_pdf': invoice.invoice_pdf, - 'hosted_invoice_url': invoice.hosted_invoice_url, - } - for invoice in invoices.data - ] - - def get_payment_methods(self, organization: Organization) -> List[Dict[str, Any]]: - """Get payment methods for an organization. - - Args: - organization: Organization instance - - Returns: - List of payment method data - """ - self._ensure_initialized() - if not organization.stripe_customer_id: - return [] - - payment_methods = stripe.PaymentMethod.list( - customer=organization.stripe_customer_id, - type='card' - ) - - return [ - { - 'id': pm.id, - 'type': pm.type, - 'card': { - 'brand': pm.card.brand, - 'last4': pm.card.last4, - 'exp_month': pm.card.exp_month, - 'exp_year': pm.card.exp_year, - } - } - for pm in payment_methods.data - ] - - # ======================================== - # Webhook Processing - # ======================================== - - def construct_webhook_event(self, payload: bytes, sig_header: str) -> Any: - """Construct and verify a Stripe webhook event. - - Args: - payload: Raw request body - sig_header: Stripe signature header - - Returns: - Stripe Event object - - Raises: - stripe.error.SignatureVerificationError: If signature is invalid - """ - self._ensure_initialized() - webhook_secret = current_app.config.get('STRIPE_WEBHOOK_SECRET') - if not webhook_secret: - raise ValueError("STRIPE_WEBHOOK_SECRET is not configured") - - return stripe.Webhook.construct_event( - payload, sig_header, webhook_secret - ) - - # ======================================== - # Helper Methods - # ======================================== - - def _update_organization_from_subscription(self, organization: Organization, - subscription: Any) -> None: - """Update organization fields from Stripe subscription data. - - Args: - organization: Organization instance - subscription: Stripe Subscription object - """ - organization.stripe_subscription_id = subscription.id - organization.stripe_subscription_status = subscription.status - - # Update quantity - if subscription.items.data: - organization.subscription_quantity = subscription.items.data[0].quantity - organization.stripe_price_id = subscription.items.data[0].price.id - - # Update trial info - if subscription.trial_end: - organization.trial_ends_at = datetime.fromtimestamp(subscription.trial_end) - - # Update billing dates - if subscription.current_period_end: - organization.next_billing_date = datetime.fromtimestamp(subscription.current_period_end) - - # Update subscription plan based on price ID - single_user_price = current_app.config.get('STRIPE_SINGLE_USER_PRICE_ID') - team_price = current_app.config.get('STRIPE_TEAM_PRICE_ID') - - if organization.stripe_price_id == single_user_price: - organization.subscription_plan = 'single_user' - elif organization.stripe_price_id == team_price: - organization.subscription_plan = 'team' - - db.session.commit() - - # ======================================== - # Refund Management - # ======================================== - - def create_refund(self, organization: Organization, charge_id: str = None, - invoice_id: str = None, amount: Optional[int] = None, - reason: str = None) -> Dict[str, Any]: - """Create a refund for a charge or invoice. - - Args: - organization: Organization instance - charge_id: Stripe charge ID to refund (optional if invoice_id provided) - invoice_id: Stripe invoice ID to refund (optional if charge_id provided) - amount: Amount to refund in cents (None = full refund) - reason: Reason for refund ('duplicate', 'fraudulent', 'requested_by_customer') - - Returns: - Refund data - - Raises: - ValueError: If neither charge_id nor invoice_id provided - stripe.error.StripeError: If refund creation fails - """ - self._ensure_initialized() - if not self.is_configured(): - raise ValueError("Stripe is not configured") - - # Get charge_id from invoice if needed - if not charge_id and invoice_id: - invoice = stripe.Invoice.retrieve(invoice_id) - if invoice.charge: - charge_id = invoice.charge - else: - raise ValueError("Invoice has no associated charge") - - if not charge_id: - raise ValueError("Either charge_id or invoice_id must be provided") - - # Prepare refund parameters - refund_params = { - 'charge': charge_id, - } - - if amount: - refund_params['amount'] = amount - - if reason: - refund_params['reason'] = reason - - # Create refund - refund = stripe.Refund.create(**refund_params) - - # Log event - self._log_event( - organization=organization, - event_type='refund.created', - stripe_customer_id=organization.stripe_customer_id, - stripe_charge_id=charge_id, - stripe_refund_id=refund.id, - amount=refund.amount / 100, - currency=refund.currency.upper(), - status=refund.status, - notes=f"Refund created: {reason or 'No reason specified'}" - ) - - return { - 'refund_id': refund.id, - 'amount': refund.amount / 100, - 'currency': refund.currency.upper(), - 'status': refund.status, - 'reason': refund.reason, - } - - def get_refunds(self, organization: Organization, limit: int = 10) -> List[Dict[str, Any]]: - """Get refunds for an organization. - - Args: - organization: Organization instance - limit: Maximum number of refunds to return - - Returns: - List of refund data dictionaries - """ - self._ensure_initialized() - if not organization.stripe_customer_id: - return [] - - # Get all charges for the customer first - charges = stripe.Charge.list( - customer=organization.stripe_customer_id, - limit=100 - ) - - # Collect refunds from all charges - all_refunds = [] - for charge in charges.data: - if charge.refunds and charge.refunds.data: - for refund in charge.refunds.data: - all_refunds.append({ - 'id': refund.id, - 'amount': refund.amount / 100, - 'currency': refund.currency.upper(), - 'status': refund.status, - 'reason': refund.reason, - 'created': datetime.fromtimestamp(refund.created), - 'charge_id': charge.id, - }) - - # Sort by creation date and limit - all_refunds.sort(key=lambda x: x['created'], reverse=True) - return all_refunds[:limit] - - # ======================================== - # Sync & Reconciliation - # ======================================== - - def sync_organization_with_stripe(self, organization: Organization) -> Dict[str, Any]: - """Sync organization data with Stripe to ensure consistency. - - Args: - organization: Organization instance - - Returns: - Dictionary with sync results and any discrepancies found - """ - self._ensure_initialized() - if not organization.stripe_customer_id: - return {'synced': False, 'error': 'No Stripe customer ID'} - - discrepancies = [] - - try: - # Get customer from Stripe - customer = stripe.Customer.retrieve(organization.stripe_customer_id) - - # Get subscription if exists - if organization.stripe_subscription_id: - try: - subscription = stripe.Subscription.retrieve(organization.stripe_subscription_id) - - # Check for discrepancies - if subscription.status != organization.stripe_subscription_status: - discrepancies.append({ - 'field': 'subscription_status', - 'local': organization.stripe_subscription_status, - 'stripe': subscription.status - }) - # Update local status - organization.stripe_subscription_status = subscription.status - - # Check quantity - if subscription.items.data: - stripe_quantity = subscription.items.data[0].quantity - if stripe_quantity != organization.subscription_quantity: - discrepancies.append({ - 'field': 'subscription_quantity', - 'local': organization.subscription_quantity, - 'stripe': stripe_quantity - }) - # Update local quantity - organization.subscription_quantity = stripe_quantity - - # Update other fields - self._update_organization_from_subscription(organization, subscription) - - except stripe.error.InvalidRequestError: - discrepancies.append({ - 'field': 'subscription', - 'error': 'Subscription not found in Stripe but exists locally' - }) - - db.session.commit() - - return { - 'synced': True, - 'discrepancies': discrepancies, - 'discrepancy_count': len(discrepancies), - } - - except stripe.error.StripeError as e: - return { - 'synced': False, - 'error': str(e) - } - - def check_all_organizations_sync(self) -> Dict[str, Any]: - """Check sync status for all organizations with Stripe customers. - - Returns: - Summary of sync status across all organizations - """ - self._ensure_initialized() - if not self.is_configured(): - return {'error': 'Stripe not configured'} - - organizations = Organization.query.filter( - Organization.stripe_customer_id.isnot(None) - ).all() - - results = { - 'total': len(organizations), - 'synced': 0, - 'with_discrepancies': 0, - 'errors': 0, - 'organizations': [] - } - - for org in organizations: - sync_result = self.sync_organization_with_stripe(org) - - org_result = { - 'id': org.id, - 'name': org.name, - 'slug': org.slug, - 'synced': sync_result.get('synced', False), - 'discrepancy_count': sync_result.get('discrepancy_count', 0), - 'discrepancies': sync_result.get('discrepancies', []), - 'error': sync_result.get('error') - } - - results['organizations'].append(org_result) - - if sync_result.get('synced'): - results['synced'] += 1 - if sync_result.get('discrepancy_count', 0) > 0: - results['with_discrepancies'] += 1 - else: - results['errors'] += 1 - - return results - - # ======================================== - # Helper Methods - # ======================================== - - def _log_event(self, organization: Organization, event_type: str, **kwargs) -> None: - """Log a subscription event. - - Args: - organization: Organization instance - event_type: Type of event - **kwargs: Additional event data - """ - event = SubscriptionEvent( - event_type=event_type, - organization_id=organization.id, - **kwargs - ) - event.processed = True - event.processed_at = datetime.utcnow() - - db.session.add(event) - db.session.commit() - - -# Create a singleton instance -stripe_service = StripeService() - diff --git a/app/utils/tenancy.py b/app/utils/tenancy.py deleted file mode 100644 index 0d72be7..0000000 --- a/app/utils/tenancy.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -Tenancy utilities for multi-tenant data isolation. - -This module provides: -1. Context management for current organization -2. Scoped query helpers -3. Middleware for enforcing tenant boundaries -""" - -from functools import wraps -from flask import g, request, abort, session -from flask_login import current_user -from werkzeug.local import LocalProxy - - -def get_current_organization_id(): - """Get the current organization ID from the request context. - - Returns: - int: Organization ID if set, None otherwise - """ - return getattr(g, 'current_organization_id', None) - - -def get_current_organization(): - """Get the current organization object from the request context. - - Returns: - Organization: Organization object if set, None otherwise - """ - return getattr(g, 'current_organization', None) - - -def set_current_organization(organization_id, organization=None): - """Set the current organization in the request context. - - Args: - organization_id: ID of the organization - organization: Optional Organization object (will be loaded if not provided) - """ - g.current_organization_id = organization_id - - if organization: - g.current_organization = organization - elif organization_id: - from app.models import Organization - g.current_organization = Organization.query.get(organization_id) - - -# Convenient proxy for accessing current organization -current_organization_id = LocalProxy(get_current_organization_id) -current_organization = LocalProxy(get_current_organization) - - -def get_user_organizations(user_id): - """Get all organizations a user belongs to. - - Args: - user_id: ID of the user - - Returns: - list: List of Organization objects - """ - from app.models import Organization, Membership - - memberships = Membership.get_user_active_memberships(user_id) - return [m.organization for m in memberships if m.organization] - - -def get_user_default_organization(user_id): - """Get the default organization for a user (first active membership). - - Args: - user_id: ID of the user - - Returns: - Organization: Default organization or None - """ - from app.models import Membership - - membership = Membership.query.filter_by( - user_id=user_id, - status='active' - ).order_by(Membership.created_at.asc()).first() - - return membership.organization if membership else None - - -def user_has_access_to_organization(user_id, organization_id): - """Check if a user has access to an organization. - - Args: - user_id: ID of the user - organization_id: ID of the organization - - Returns: - bool: True if user has access, False otherwise - """ - from app.models import Membership - - return Membership.user_is_member(user_id, organization_id) - - -def user_is_organization_admin(user_id, organization_id): - """Check if a user is an admin of an organization. - - Args: - user_id: ID of the user - organization_id: ID of the organization - - Returns: - bool: True if user is admin, False otherwise - """ - from app.models import Membership - - return Membership.user_is_admin(user_id, organization_id) - - -def require_organization_access(admin_only=False): - """Decorator to ensure user has access to the current organization. - - Args: - admin_only: If True, require admin role in the organization - - Raises: - 403: If user doesn't have access or isn't an admin (when required) - 401: If user is not authenticated - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not current_user or not current_user.is_authenticated: - abort(401, description="Authentication required") - - org_id = get_current_organization_id() - if not org_id: - abort(403, description="No organization context set") - - if not user_has_access_to_organization(current_user.id, org_id): - abort(403, description="Access denied to this organization") - - if admin_only and not user_is_organization_admin(current_user.id, org_id): - abort(403, description="Admin access required") - - return f(*args, **kwargs) - - return decorated_function - return decorator - - -def scoped_query(model_class): - """Create a query scoped to the current organization. - - Args: - model_class: SQLAlchemy model class to query - - Returns: - Query: Scoped query object - - Raises: - ValueError: If no organization context is set or model doesn't have organization_id - """ - from app import db - - org_id = get_current_organization_id() - if not org_id: - raise ValueError("No organization context set for scoped query") - - if not hasattr(model_class, 'organization_id'): - raise ValueError(f"Model {model_class.__name__} does not have organization_id column") - - return db.session.query(model_class).filter(model_class.organization_id == org_id) - - -def ensure_organization_access(obj): - """Verify that an object belongs to the current organization. - - Args: - obj: Object with organization_id attribute - - Raises: - ValueError: If no organization context is set - PermissionError: If object doesn't belong to current organization - """ - org_id = get_current_organization_id() - if not org_id: - raise ValueError("No organization context set") - - if not hasattr(obj, 'organization_id'): - raise ValueError(f"Object {type(obj).__name__} does not have organization_id") - - if obj.organization_id != org_id: - raise PermissionError( - f"Object {type(obj).__name__} #{obj.id} does not belong to organization #{org_id}" - ) - - -def switch_organization(organization_id): - """Switch the current organization context (stores in session). - - Args: - organization_id: ID of the organization to switch to - - Returns: - Organization: The new current organization - - Raises: - PermissionError: If user doesn't have access to the organization - """ - if not current_user or not current_user.is_authenticated: - raise PermissionError("Must be authenticated to switch organizations") - - if not user_has_access_to_organization(current_user.id, organization_id): - raise PermissionError(f"No access to organization #{organization_id}") - - from app.models import Organization - org = Organization.query.get(organization_id) - if not org or not org.is_active: - raise ValueError(f"Organization #{organization_id} not found or inactive") - - # Store in session for persistence across requests - session['current_organization_id'] = organization_id - - # Also set in request context - set_current_organization(organization_id, org) - - return org - - -def init_tenancy_for_request(): - """Initialize tenancy context for the current request. - - This should be called in a before_request handler. It: - 1. Loads organization from session or URL parameter - 2. Verifies user has access - 3. Sets up request context - - Returns: - Organization: Current organization or None - """ - if not current_user or not current_user.is_authenticated: - return None - - # Try to get organization from various sources (in priority order) - org_id = None - - # 1. URL parameter (for API calls or explicit switching) - org_id = request.args.get('organization_id', type=int) - - # 2. Session (for web UI persistence) - if not org_id: - org_id = session.get('current_organization_id') - - # 3. User's default organization - if not org_id: - default_org = get_user_default_organization(current_user.id) - if default_org: - org_id = default_org.id - - # Set the organization if found - if org_id: - if user_has_access_to_organization(current_user.id, org_id): - from app.models import Organization - org = Organization.query.get(org_id) - if org and org.is_active: - set_current_organization(org_id, org) - # Store in session for next request - session['current_organization_id'] = org_id - return org - - return None - - -def auto_set_organization_id(obj): - """Automatically set organization_id on an object from current context. - - Args: - obj: Object to set organization_id on - - Raises: - ValueError: If no organization context is set - """ - org_id = get_current_organization_id() - if not org_id: - raise ValueError("Cannot auto-set organization_id: No organization context") - - if hasattr(obj, 'organization_id'): - obj.organization_id = org_id - else: - raise AttributeError(f"Object {type(obj).__name__} does not have organization_id attribute") - diff --git a/app/utils/totp.py b/app/utils/totp.py deleted file mode 100644 index edf2c60..0000000 --- a/app/utils/totp.py +++ /dev/null @@ -1,85 +0,0 @@ -"""TOTP (Time-based One-Time Password) utilities for 2FA""" -import pyotp -import qrcode -from io import BytesIO -import base64 - - -def generate_totp_secret(): - """Generate a new TOTP secret for a user. - - Returns: - str: Base32-encoded secret - """ - return pyotp.random_base32() - - -def get_totp_uri(secret, username, issuer='TimeTracker'): - """Generate a TOTP URI for QR code generation. - - Args: - secret: TOTP secret - username: User's username or email - issuer: Application name - - Returns: - str: TOTP URI - """ - totp = pyotp.TOTP(secret) - return totp.provisioning_uri(name=username, issuer_name=issuer) - - -def generate_qr_code(totp_uri): - """Generate QR code image for TOTP setup. - - Args: - totp_uri: TOTP URI string - - Returns: - str: Base64-encoded PNG image - """ - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - - # Convert to base64 for embedding in HTML - buffer = BytesIO() - img.save(buffer, format='PNG') - buffer.seek(0) - img_base64 = base64.b64encode(buffer.getvalue()).decode() - - return f"data:image/png;base64,{img_base64}" - - -def verify_totp_token(secret, token, valid_window=1): - """Verify a TOTP token. - - Args: - secret: TOTP secret - token: Token to verify (6 digits) - valid_window: Number of time windows to check (before and after current) - - Returns: - bool: True if token is valid - """ - if not secret or not token: - return False - - totp = pyotp.TOTP(secret) - return totp.verify(token, valid_window=valid_window) - - -def get_current_totp_token(secret): - """Get the current TOTP token (for testing purposes). - - Args: - secret: TOTP secret - - Returns: - str: Current 6-digit token - """ - totp = pyotp.TOTP(secret) - return totp.now() - diff --git a/docker-compose.local-test.yml b/docker-compose.local-test.yml index ce34821..c1305df 100644 --- a/docker-compose.local-test.yml +++ b/docker-compose.local-test.yml @@ -20,6 +20,8 @@ services: # Set Flask environment for development - FLASK_ENV=development - FLASK_DEBUG=true + # Disable license server for local testing + - LICENSE_SERVER_ENABLED=false ports: - "8080:8080" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 8758e96..85ac042 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,11 +13,6 @@ services: - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this} - DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker - LOG_FILE=/app/logs/timetracker.log - # Flask environment - set to development for local testing - - FLASK_ENV=${FLASK_ENV:-development} - - FLASK_DEBUG=${FLASK_DEBUG:-false} - # HTTPS enforcement - DISABLE for local development - - FORCE_HTTPS=${FORCE_HTTPS:-false} # Ensure cookies work over HTTP (disable Secure for local/dev or non-TLS proxies) - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false} - REMEMBER_COOKIE_SECURE=${REMEMBER_COOKIE_SECURE:-false} diff --git a/docker/init-database-enhanced.py b/docker/init-database-enhanced.py index f051ebd..2a8b378 100644 --- a/docker/init-database-enhanced.py +++ b/docker/init-database-enhanced.py @@ -349,48 +349,21 @@ def insert_initial_data(engine): ); """)) - # Create default organization (idempotent via unique slug) + # Ensure default client exists (idempotent via unique name) conn.execute(text(""" - INSERT INTO organizations (name, slug, contact_email, subscription_plan, status, timezone, currency, date_format) - SELECT 'Default Organization', 'default', 'admin@timetracker.local', 'free', 'active', 'UTC', 'EUR', 'YYYY-MM-DD' + INSERT INTO clients (name, status) + SELECT 'Default Client', 'active' WHERE NOT EXISTS ( - SELECT 1 FROM organizations WHERE slug = 'default' - ); - """)) - - # Add admin user to default organization - conn.execute(text(f""" - INSERT INTO memberships (user_id, organization_id, role, status) - SELECT u.id, o.id, 'admin', 'active' - FROM users u - CROSS JOIN organizations o - WHERE u.username = '{admin_username}' - AND o.slug = 'default' - AND NOT EXISTS ( - SELECT 1 FROM memberships m - WHERE m.user_id = u.id AND m.organization_id = o.id - ); - """)) - - # Ensure default client exists (idempotent via unique name, linked to default org) - conn.execute(text(""" - INSERT INTO clients (name, organization_id, status) - SELECT 'Default Client', o.id, 'active' - FROM organizations o - WHERE o.slug = 'default' - AND NOT EXISTS ( SELECT 1 FROM clients WHERE name = 'Default Client' ); """)) - # Insert default project linked to default client and org if not present + # Insert default project linked to default client if not present conn.execute(text(""" - INSERT INTO projects (name, organization_id, client_id, description, billable, status) - SELECT 'General', o.id, c.id, 'Default project for general tasks', true, 'active' - FROM organizations o - CROSS JOIN clients c - WHERE o.slug = 'default' - AND c.name = 'Default Client' + INSERT INTO projects (name, client_id, description, billable, status) + SELECT 'General', c.id, 'Default project for general tasks', true, 'active' + FROM clients c + WHERE c.name = 'Default Client' AND NOT EXISTS ( SELECT 1 FROM projects p WHERE p.name = 'General' ); diff --git a/docker/init-database-sql.py b/docker/init-database-sql.py index 2ae2a9e..3562f3a 100644 --- a/docker/init-database-sql.py +++ b/docker/init-database-sql.py @@ -270,42 +270,17 @@ def insert_initial_data(engine): SELECT 1 FROM users WHERE username = '{admin_username}' ); - -- Create default organization - INSERT INTO organizations (name, slug, contact_email, subscription_plan, status, timezone, currency, date_format) - SELECT 'Default Organization', 'default', 'admin@timetracker.local', 'free', 'active', 'UTC', 'EUR', 'YYYY-MM-DD' + -- Ensure default client exists + INSERT INTO clients (name, status) + SELECT 'Default Client', 'active' WHERE NOT EXISTS ( - SELECT 1 FROM organizations WHERE slug = 'default' - ); - - -- Add admin user to default organization - INSERT INTO memberships (user_id, organization_id, role, status) - SELECT u.id, o.id, 'admin', 'active' - FROM users u - CROSS JOIN organizations o - WHERE u.username = '{admin_username}' - AND o.slug = 'default' - AND NOT EXISTS ( - SELECT 1 FROM memberships m - WHERE m.user_id = u.id AND m.organization_id = o.id - ); - - -- Ensure default client exists (linked to default org) - INSERT INTO clients (name, organization_id, status) - SELECT 'Default Client', o.id, 'active' - FROM organizations o - WHERE o.slug = 'default' - AND NOT EXISTS ( SELECT 1 FROM clients WHERE name = 'Default Client' ); - -- Insert default project linked to default client and org - INSERT INTO projects (name, organization_id, client_id, description, billable, status) - SELECT 'General', o.id, c.id, 'Default project for general tasks', true, 'active' - FROM organizations o - CROSS JOIN clients c - WHERE o.slug = 'default' - AND c.name = 'Default Client' - AND NOT EXISTS ( + -- Insert default project idempotently and link to default client + INSERT INTO projects (name, client, description, billable, status) + SELECT 'General', 'Default Client', 'Default project for general tasks', true, 'active' + WHERE NOT EXISTS ( SELECT 1 FROM projects WHERE name = 'General' ); diff --git a/docs/HTTPS_SETUP_GUIDE.md b/docs/HTTPS_SETUP_GUIDE.md deleted file mode 100644 index 95da4bc..0000000 --- a/docs/HTTPS_SETUP_GUIDE.md +++ /dev/null @@ -1,599 +0,0 @@ -# HTTPS Setup Guide for TimeTracker - -## Overview - -This guide provides step-by-step instructions for setting up HTTPS/TLS for TimeTracker in production. - -**⚠️ IMPORTANT: Always use HTTPS in production!** - ---- - -## Table of Contents - -1. [Quick Start](#quick-start) -2. [Option 1: Nginx Reverse Proxy with Let's Encrypt](#option-1-nginx-reverse-proxy-with-lets-encrypt) -3. [Option 2: Apache Reverse Proxy](#option-2-apache-reverse-proxy) -4. [Option 3: Cloud Load Balancer](#option-3-cloud-load-balancer) -5. [Application Configuration](#application-configuration) -6. [Verification](#verification) -7. [Troubleshooting](#troubleshooting) - ---- - -## Quick Start - -TimeTracker includes automatic HTTPS enforcement: - -1. **Set environment variable:** - ```bash - FORCE_HTTPS=true - ``` - -2. **Enable secure cookies:** - ```bash - SESSION_COOKIE_SECURE=true - REMEMBER_COOKIE_SECURE=true - ``` - -3. **Configure reverse proxy** (nginx, Apache, or cloud load balancer) for TLS termination - -4. **Restart application** - ---- - -## Option 1: Nginx Reverse Proxy with Let's Encrypt - -### 1.1 Install Nginx and Certbot - -```bash -# Ubuntu/Debian -sudo apt update -sudo apt install nginx certbot python3-certbot-nginx - -# CentOS/RHEL -sudo yum install epel-release -sudo yum install nginx certbot python3-certbot-nginx -``` - -### 1.2 Configure Nginx - -Create `/etc/nginx/sites-available/timetracker`: - -```nginx -# HTTP Server (redirects to HTTPS) -server { - listen 80; - listen [::]:80; - server_name timetracker.yourdomain.com; - - # Let's Encrypt challenge - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - # Redirect all other traffic to HTTPS - location / { - return 301 https://$server_name$request_uri; - } -} - -# HTTPS Server -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name timetracker.yourdomain.com; - - # SSL Configuration (Let's Encrypt will manage these) - ssl_certificate /etc/letsencrypt/live/timetracker.yourdomain.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/timetracker.yourdomain.com/privkey.pem; - - # Modern SSL configuration - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; - ssl_prefer_server_ciphers off; - - # SSL session cache - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 10m; - - # OCSP stapling - ssl_stapling on; - ssl_stapling_verify on; - ssl_trusted_certificate /etc/letsencrypt/live/timetracker.yourdomain.com/chain.pem; - - # Security headers (HSTS added by application) - add_header X-Frame-Options "DENY" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - - # Proxy settings - location / { - proxy_pass http://localhost:8080; - proxy_http_version 1.1; - - # Headers for proxy - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - - # WebSocket support (for SocketIO) - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - # Timeouts - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - - # Increase max body size for file uploads - client_max_body_size 20M; -} -``` - -### 1.3 Enable Site - -```bash -# Create symlink -sudo ln -s /etc/nginx/sites-available/timetracker /etc/nginx/sites-enabled/ - -# Test configuration -sudo nginx -t - -# Restart nginx -sudo systemctl restart nginx -``` - -### 1.4 Obtain Let's Encrypt Certificate - -```bash -# Obtain certificate (automatic nginx configuration) -sudo certbot --nginx -d timetracker.yourdomain.com - -# Or manually -sudo certbot certonly --nginx -d timetracker.yourdomain.com -``` - -### 1.5 Auto-Renewal Setup - -```bash -# Test renewal -sudo certbot renew --dry-run - -# Certbot creates a systemd timer automatically -sudo systemctl status certbot.timer - -# Or add to crontab -sudo crontab -e -# Add: 0 3 * * * certbot renew --quiet --post-hook "systemctl reload nginx" -``` - ---- - -## Option 2: Apache Reverse Proxy - -### 2.1 Install Apache and Certbot - -```bash -# Ubuntu/Debian -sudo apt install apache2 certbot python3-certbot-apache - -# Enable required modules -sudo a2enmod ssl proxy proxy_http headers rewrite -``` - -### 2.2 Configure Apache - -Create `/etc/apache2/sites-available/timetracker.conf`: - -```apache -# HTTP Virtual Host (redirect to HTTPS) - - ServerName timetracker.yourdomain.com - - # Redirect to HTTPS - RewriteEngine On - RewriteCond %{HTTPS} off - RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L] - - -# HTTPS Virtual Host - - ServerName timetracker.yourdomain.com - - # SSL Configuration - SSLEngine on - SSLCertificateFile /etc/letsencrypt/live/timetracker.yourdomain.com/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/timetracker.yourdomain.com/privkey.pem - - # Modern SSL settings - SSLProtocol -all +TLSv1.2 +TLSv1.3 - SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 - SSLHonorCipherOrder off - SSLSessionTickets off - - # Security headers - Header always set X-Frame-Options "DENY" - Header always set X-Content-Type-Options "nosniff" - Header always set X-XSS-Protection "1; mode=block" - - # Proxy settings - ProxyPreserveHost On - ProxyPass / http://localhost:8080/ - ProxyPassReverse / http://localhost:8080/ - - # Headers for proxy - RequestHeader set X-Forwarded-Proto "https" - RequestHeader set X-Forwarded-Port "443" - - # WebSocket support - RewriteEngine On - RewriteCond %{HTTP:Upgrade} =websocket [NC] - RewriteRule /(.*) ws://localhost:8080/$1 [P,L] - - # Logging - ErrorLog ${APACHE_LOG_DIR}/timetracker-error.log - CustomLog ${APACHE_LOG_DIR}/timetracker-access.log combined - -``` - -### 2.3 Enable Site - -```bash -# Enable site -sudo a2ensite timetracker - -# Test configuration -sudo apache2ctl configtest - -# Restart Apache -sudo systemctl restart apache2 -``` - -### 2.4 Obtain Certificate - -```bash -sudo certbot --apache -d timetracker.yourdomain.com -``` - ---- - -## Option 3: Cloud Load Balancer - -### AWS Application Load Balancer (ALB) - -1. **Create Target Group:** - - Target type: Instances - - Protocol: HTTP - - Port: 8080 - - Health check path: `/_health` - -2. **Create Load Balancer:** - - Type: Application Load Balancer - - Scheme: Internet-facing - - IP address type: IPv4 - -3. **Add Listeners:** - - HTTP (80) → Redirect to HTTPS - - HTTPS (443) → Forward to target group - -4. **Configure SSL Certificate:** - - Use ACM (AWS Certificate Manager) - - Request certificate for your domain - - Validate via DNS or email - -5. **Security Group:** - - Allow inbound: 80 (HTTP), 443 (HTTPS) - - Allow outbound: 8080 to application instances - -### Google Cloud Load Balancer - -1. **Create Backend Service:** - - Protocol: HTTP - - Port: 8080 - - Health check: `/_health` - -2. **Create Load Balancer:** - - Type: HTTP(S) Load Balancer - - Frontend configuration: - - Protocol: HTTPS - - Port: 443 - - IP: Reserve static IP - -3. **SSL Certificate:** - - Google-managed certificate (automatic renewal) - - Or upload your own certificate - -4. **HTTP to HTTPS Redirect:** - - Create HTTP frontend (port 80) - - Add URL redirect to HTTPS - -### Azure Application Gateway - -1. **Create Application Gateway:** - - Tier: Standard_v2 or WAF_v2 - - SKU: Standard_v2 - -2. **Backend Pool:** - - Add your application instances - - Port: 8080 - -3. **HTTP Settings:** - - Protocol: HTTP - - Port: 8080 - - Custom health probe: `/_health` - -4. **Listener:** - - Protocol: HTTPS - - Port: 443 - - SSL certificate: Upload or use Key Vault - -5. **HTTP to HTTPS Redirect:** - - Create HTTP listener (port 80) - - Add redirect rule to HTTPS - ---- - -## Application Configuration - -### Environment Variables - -Update your `.env` file: - -```bash -# Force HTTPS (enabled by default in production) -FORCE_HTTPS=true - -# Secure cookies (required for HTTPS) -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true - -# Flask environment -FLASK_ENV=production -``` - -### Docker Compose - -If using Docker Compose, ensure proper configuration: - -```yaml -services: - app: - environment: - - FORCE_HTTPS=true - - SESSION_COOKIE_SECURE=true - - REMEMBER_COOKIE_SECURE=true - - FLASK_ENV=production - # Don't expose port 8080 directly if using reverse proxy - # expose: - # - "8080" -``` - -### For Development - -Disable HTTPS enforcement locally: - -```bash -FORCE_HTTPS=false -SESSION_COOKIE_SECURE=false -REMEMBER_COOKIE_SECURE=false -FLASK_ENV=development -``` - ---- - -## Verification - -### 1. Check HTTPS is Working - -```bash -curl -I https://timetracker.yourdomain.com -``` - -Should return `200 OK` with security headers. - -### 2. Verify HTTP Redirects to HTTPS - -```bash -curl -I http://timetracker.yourdomain.com -``` - -Should return `301` or `302` redirect to HTTPS. - -### 3. Test SSL Configuration - -Use online tools: -- **SSL Labs**: https://www.ssllabs.com/ssltest/ -- **Security Headers**: https://securityheaders.com -- **Mozilla Observatory**: https://observatory.mozilla.org - -Target grades: -- SSL Labs: A or A+ -- Security Headers: A or A+ -- Mozilla Observatory: A or A+ - -### 4. Verify Security Headers - -```bash -curl -I https://timetracker.yourdomain.com | grep -i "strict-transport-security\|x-frame-options\|x-content-type" -``` - -Should see: -- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` -- `X-Frame-Options: DENY` -- `X-Content-Type-Options: nosniff` - -### 5. Test Application - -1. Open https://timetracker.yourdomain.com -2. Verify padlock icon in browser -3. Test login (should work over HTTPS) -4. Test WebSocket connections (if using timer features) - ---- - -## Troubleshooting - -### Issue: "Too many redirects" error - -**Cause:** Application and reverse proxy both redirecting - -**Solution:** -```bash -# Disable application-level HTTPS redirect -FORCE_HTTPS=false -``` - -Let the reverse proxy handle HTTP→HTTPS redirect. - -### Issue: Cookies not working after enabling HTTPS - -**Cause:** Secure cookies require HTTPS - -**Solution:** -```bash -# Ensure both are true in production -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true -``` - -### Issue: Mixed content warnings - -**Cause:** Loading HTTP resources on HTTPS page - -**Solution:** -- Update all URLs to use HTTPS -- Use protocol-relative URLs: `//example.com/resource.js` -- Check Content-Security-Policy settings - -### Issue: Certificate errors - -**Cause:** Certificate not trusted or expired - -**Solution:** -```bash -# Check certificate validity -openssl s_client -connect timetracker.yourdomain.com:443 -servername timetracker.yourdomain.com - -# Renew Let's Encrypt certificate -sudo certbot renew -``` - -### Issue: WebSocket connections fail - -**Cause:** Proxy not configured for WebSocket - -**Solution:** Ensure proxy configuration includes: - -**Nginx:** -```nginx -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection "upgrade"; -``` - -**Apache:** -```apache -RewriteEngine On -RewriteCond %{HTTP:Upgrade} =websocket [NC] -RewriteRule /(.*) ws://localhost:8080/$1 [P,L] -``` - -### Issue: "X-Forwarded-Proto" not set correctly - -**Cause:** Reverse proxy not sending correct headers - -**Solution:** - -**Nginx:** -```nginx -proxy_set_header X-Forwarded-Proto $scheme; -``` - -**Apache:** -```apache -RequestHeader set X-Forwarded-Proto "https" -``` - ---- - -## Best Practices - -### 1. Use Strong TLS Configuration - -- **Protocols:** TLSv1.2 and TLSv1.3 only -- **Ciphers:** Modern, secure ciphers -- **HSTS:** Enable with preload -- **OCSP Stapling:** Enable if supported - -### 2. Certificate Management - -- **Let's Encrypt:** Free, automatic renewal -- **Expiry monitoring:** Set up alerts 30 days before expiry -- **Backup certificates:** Keep secure backups - -### 3. Regular Testing - -- **Weekly:** Check certificate expiry -- **Monthly:** Run SSL Labs test -- **Quarterly:** Review TLS configuration - -### 4. HSTS Preload - -Add your domain to HSTS preload list: -1. Meet requirements: https://hstspreload.org -2. Submit domain -3. Wait for inclusion in browsers - -### 5. Certificate Transparency - -Monitor certificate issuance: -- **crt.sh**: https://crt.sh -- **Facebook CT Monitor**: https://developers.facebook.com/tools/ct/ - ---- - -## Security Checklist - -Pre-deployment: - -- [ ] TLS certificate installed and valid -- [ ] HTTP redirects to HTTPS -- [ ] Secure cookies enabled (`SESSION_COOKIE_SECURE=true`) -- [ ] HSTS header present (max-age=31536000) -- [ ] Strong TLS protocols only (TLSv1.2+) -- [ ] Strong cipher suites configured -- [ ] Certificate auto-renewal configured -- [ ] Security headers present (X-Frame-Options, CSP, etc.) -- [ ] SSL Labs grade: A or A+ -- [ ] No mixed content warnings -- [ ] WebSocket connections work over WSS - -Post-deployment: - -- [ ] Monitor certificate expiry -- [ ] Monitor security headers -- [ ] Regular SSL Labs scans -- [ ] Update TLS configuration as needed - ---- - -## Additional Resources - -- **Let's Encrypt Documentation**: https://letsencrypt.org/docs/ -- **Mozilla SSL Configuration Generator**: https://ssl-config.mozilla.org/ -- **OWASP TLS Cheat Sheet**: https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html -- **Security Headers Guide**: https://securityheaders.com/ -- **HSTS Preload**: https://hstspreload.org/ - ---- - -## Support - -For HTTPS setup assistance: -- 📖 Read this guide thoroughly -- 🔧 Check troubleshooting section -- 📧 Contact: support@your-domain.com - diff --git a/docs/LICENSE_SERVER_DOCKER_SETUP.md b/docs/LICENSE_SERVER_DOCKER_SETUP.md new file mode 100644 index 0000000..f03a6a5 --- /dev/null +++ b/docs/LICENSE_SERVER_DOCKER_SETUP.md @@ -0,0 +1,147 @@ +# License Server Docker Setup Guide + +This guide explains how to configure the license server integration when running TimeTracker in Docker containers. + +## Overview + +The TimeTracker application includes a license server client that communicates with a DryLicenseServer for monitoring and analytics purposes. **No license is required** to use the application - this integration is purely for usage tracking and system monitoring. + +## Important: Hardcoded License Server IP + +**The license server IP address is hardcoded in the application code and cannot be changed by clients.** + +- **IP Address**: `192.168.1.100:8081` +- **Location**: Hardcoded in `app/utils/license_server.py` +- **Client Control**: None - clients cannot modify this +- **Purpose**: Ensures consistent monitoring across all deployments + +## Docker Environment Behavior + +### Expected Behavior (Server Unavailable) + +When the license server at `192.168.1.100:8081` is not available: + +``` +[INFO] Starting license server client +[INFO] Registering instance [uuid] with license server at http://192.168.1.100:8081 +[INFO] License server at http://192.168.1.100:8081 is not available - continuing without registration +[INFO] License server not available - client will run in offline mode +[INFO] License server client started successfully +``` + +This is **normal and expected** when no license server is running. + +## Configuration Options + +### 1. Disable License Server Integration + +**Note**: License server integration is now always enabled by default. To disable it, you would need to modify the source code in `app/config.py`. + +**Result**: No license server integration, clean logs, no warnings. + +### 2. License Server Integration (Default) + +By default, the license server integration is enabled and will: + +- Attempt to connect to `192.168.1.100:8081` +- Run in offline mode if server is unavailable +- Store data locally for later transmission +- Continue all application functionality normally + +**Result**: Application works completely offline, no blocking operations. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| **N/A** | **N/A** | **All license server settings are hardcoded in the application code** | + +**Note**: The license server IP address and all configuration settings are hardcoded in `app/utils/license_server.py` and `app/config.py`. Clients cannot modify these settings through environment variables. + +## Common Scenarios + +### Scenario 1: No License Server Needed + +**Note**: To disable license server integration, you must modify the source code in `app/config.py` and rebuild the Docker image. + +**Result**: No license server integration, clean logs, no warnings. + +### Scenario 2: License Server Available at 192.168.1.100:8081 + +**Default configuration** - no changes needed. The application automatically connects to the hardcoded license server IP. + +**Result**: Connects to hardcoded license server IP. + +### Scenario 3: License Server in Different Network + +**Note**: If your license server is not at `192.168.1.100:8081`, you will need to: + +1. **Modify the source code** in `app/utils/license_server.py` +2. **Rebuild the Docker image** +3. **Or disable the integration** by modifying `app/config.py` + +## Troubleshooting + +### Connection Refused Errors + +**Symptoms**: Info messages about license server not being available + +**Cause**: License server not running at `192.168.1.100:8081` + +**Solutions**: +1. **Ignore** - This is normal if no license server is needed +2. **Disable** - Modify `app/config.py` and rebuild the Docker image +3. **Verify** - Check if license server is running at the hardcoded IP + +### Application Won't Start + +**Symptoms**: Application crashes during startup + +**Cause**: Usually not related to license server (check other logs) + +**Solutions**: +1. Check database connectivity +2. Verify environment variables +3. Check application logs for other errors + +## Best Practices + +### 1. Development Environment + +**Default configuration** - License server integration is enabled by default and will work in offline mode if no server is available. + +### 2. Production Environment + +**Default configuration** - License server integration is enabled by default and will connect to the hardcoded IP address. + +### 3. Monitoring + +Check license server status in admin panel: +- Navigate to Admin Dashboard +- Click "License Status" +- Monitor connection health + +## CLI Commands + +Test license server integration: + +```bash +# Check status +docker-compose exec web flask license-status + +# Test connection +docker-compose exec web flask license-test + +# Restart client +docker-compose exec web flask license-restart +``` + +## Summary + +- **No license required** - Application functions normally without license server +- **IP hardcoded** - License server IP is `192.168.1.100:8081` and cannot be changed by clients +- **Works offline** - Application functions completely without license server +- **Non-intrusive** - Won't prevent application from starting or functioning +- **Monitoring available** - Admin interface shows status and health + +The license server integration is designed to be completely optional and won't interfere with normal application operation. The hardcoded IP ensures consistent monitoring across all deployments. diff --git a/docs/LICENSE_SERVER_INTEGRATION.md b/docs/LICENSE_SERVER_INTEGRATION.md new file mode 100644 index 0000000..3a810b0 --- /dev/null +++ b/docs/LICENSE_SERVER_INTEGRATION.md @@ -0,0 +1,272 @@ +# Metrics Server Integration + +This document describes the implementation of the metrics server communication in the TimeTracker application. + +## Overview + +The TimeTracker application includes a metrics client that communicates with a metrics server for monitoring and analytics purposes. **No license is required** to use the application — this integration is purely for usage tracking and system monitoring. + +## Implementation Details + +### Core Components + +1. **LicenseServerClient** (`app/utils/license_server.py`) + - Main client class for communicating with the metrics server + - Handles instance registration, heartbeats, and usage data transmission + - Implements offline data storage for failed requests + - Runs background heartbeat thread + +2. **Configuration** (`app/utils/license_server.py` and environment) + - Metrics server settings are environment-configurable with sane defaults + - Default values are provided for development + +3. **Integration** (`app/__init__.py`) + - Automatic initialization during application startup + - Graceful error handling if metrics server is unavailable + +4. **Admin Interface** (`app/routes/admin.py`) + - Metrics server status monitoring + - Testing and restart capabilities + - Integration with admin dashboard + +### Features + +- ✅ **Automatic Instance Registration**: Registers each application instance with unique ID +- ✅ **Periodic Heartbeats**: Sends status updates every hour (configurable) +- ✅ **Usage Data Collection**: Tracks application usage and features +- ✅ **Offline Storage**: Stores data locally when server is unavailable +- ✅ **Graceful Error Handling**: Continues operation even if metrics server fails +- ✅ **System Information Collection**: Gathers OS, hardware, and environment details +- ✅ **Admin Monitoring**: Web interface for status and management + +## Configuration + +### Environment Variables + +Metrics server configuration supports environment overrides (legacy variable names are also accepted for compatibility): + +- `METRICS_SERVER_URL` (legacy: `LICENSE_SERVER_BASE_URL`) +- `METRICS_SERVER_API_KEY` (legacy: `LICENSE_SERVER_API_KEY`) +- `METRICS_HEARTBEAT_SECONDS` (legacy: `LICENSE_HEARTBEAT_SECONDS`) — default 3600 +- `METRICS_SERVER_TIMEOUT_SECONDS` (legacy: `LICENSE_SERVER_TIMEOUT_SECONDS`) — default 30 + +## API Endpoints Used + +| Endpoint | Purpose | Status | +|----------|---------|---------| +| `/api/v1/register` | Instance registration | ✅ Implemented | +| `/api/v1/validate` | Token validation | ✅ Implemented (no license required) | +| `/api/v1/heartbeat` | Status updates | ✅ Implemented | +| `/api/v1/data` | Usage data transmission | ✅ Implemented | +| `/api/v1/status` | Server health check | ✅ Implemented | + +## Usage + +### Automatic Operation + +The metrics client starts automatically when the application starts: + +1. **Application Startup**: Client initializes and registers instance +2. **Background Heartbeats**: Sends status updates every hour +3. **Usage Tracking**: Collects and transmits usage data +4. **Graceful Shutdown**: Stops cleanly when application exits + +### Manual Control + +#### CLI Commands + +```bash +# Check metrics status +flask license-status + +# Test metrics communication +flask license-test + +# Restart metrics client +flask license-restart +``` + +#### Admin Interface + +- **Dashboard**: Quick access to metrics status +- **Metrics Status Page**: Detailed status information +- **Test Connection**: Verify server communication +- **Restart Client**: Restart the metrics client + +### Programmatic Usage + +```python +from app.utils.license_server import send_usage_event, get_license_client + +# Send a usage event +send_usage_event("feature_used", {"feature": "dashboard", "user": "admin"}) + +# Get client status +client = get_license_client() +if client: + status = client.get_status() + print(f"Instance ID: {status['instance_id']}") +``` + +## Data Collection + +### System Information (minimal) + +- Operating system and version +- Hardware architecture +- Python version +- Hostname and local IP +- Processor information + +### Usage Events (aggregate) + +- Feature usage tracking +- User actions +- Session information +- Performance metrics + +### Data Format + +```json +{ + "app_identifier": "timetracker", + "instance_id": "uuid-here", + "data": [ + { + "key": "usage_event", + "value": "feature_used", + "type": "string", + "metadata": {"feature": "dashboard"} + } + ] +} +``` + +## Error Handling + +### Network Failures + +- Automatic retry with exponential backoff +- Offline data storage for failed requests +- Graceful degradation when server is unavailable + +### Invalid Responses + +- Logs warnings for failed requests +- Continues operation without interruption +- Reports errors to admin interface + +### Startup Failures + +- Application continues to function +- Metrics client marked as unavailable +- Admin interface shows error status + +## Monitoring and Debugging + +### Logs + +Metrics client operations are logged with appropriate levels: + +- **INFO**: Successful operations and status changes +- **WARNING**: Failed requests and connection issues +- **ERROR**: Unexpected errors and exceptions +- **DEBUG**: Detailed operation information + +### Admin Interface + +The admin panel provides: + +- Real-time status information +- Connection health monitoring +- Offline data queue status +- Manual testing capabilities + +### Health Checks + +- Server availability monitoring +- Client status verification +- Automatic health reporting + +## Security Considerations + +- **No License Required**: Application functions without license validation +- **Local Data Storage**: Sensitive data not transmitted +- **Minimal Information**: Only system and usage metrics collected +- **Configurable**: Can be disabled via environment variables + +## Testing + +### Test Script + +Run the included test script: + +```bash +python test_license_server.py +``` + +### Manual Testing + +1. Start the application +2. Check admin dashboard for metrics status +3. Use CLI commands to test functionality +4. Monitor logs for operation details + +### Integration Testing + +1. Start a metrics server on localhost:8081 +2. Verify registration and heartbeats +3. Test usage data transmission +4. Check offline data handling + +## Troubleshooting + +### Common Issues + +1. **Server Not Responding** + - Check if metrics server is running on port 8081 + - Verify network connectivity + - Check firewall settings + +2. **Client Not Starting** + - Review application logs + - Check configuration values + - Verify dependencies (requests library) + +3. **Data Not Transmitting** + - Check offline data queue + - Verify server health + - Review network configuration + +### Debug Commands + +```bash +# Check detailed status +flask license-status + +# Test communication +flask license-test + +# View application logs +tail -f logs/timetracker.log +``` + +## Future Enhancements + +- **Geolocation**: Add IP-based location detection +- **Metrics Dashboard**: Enhanced usage analytics +- **Performance Monitoring**: System performance metrics +- **Alert System**: Notifications for server issues +- **Data Export**: Export collected usage data + +## Support + +For issues with the metrics server integration: + +1. Check the admin interface for status +2. Review application logs +3. Use CLI commands for testing +4. Verify metrics server availability +5. Check configuration values + +The integration is designed to be non-intrusive and will not prevent the application from functioning normally. diff --git a/docs/LICENSE_SERVER_SETUP.md b/docs/LICENSE_SERVER_SETUP.md new file mode 100644 index 0000000..ffd2aba --- /dev/null +++ b/docs/LICENSE_SERVER_SETUP.md @@ -0,0 +1,215 @@ +# License Server Setup Guide + +This guide explains how to set up and run TimeTracker with the integrated license server. + +## Problem + +The original setup used `host.docker.internal:8082` which doesn't work properly for container-to-container communication. This setup provides a proper Docker-based solution. + +## Solution + +We've created a custom license server that runs in its own Docker container and can be reached by the main TimeTracker application. + +## Files Created + +- `docker/license-server/Dockerfile` - License server container definition +- `docker/license-server/license_server.py` - Simple Flask-based license server +- `docker-compose.license-server.yml` - Docker Compose override for license server +- `scripts/start-with-license-server.sh` - Linux/Mac startup script +- `scripts/start-with-license-server.bat` - Windows startup script + +## Network Debugging Tools + +### Enhanced Dockerfile +The main Dockerfile has been enhanced with: +- Network tools (`iproute2`, `net-tools`, `iputils-ping`, `dnsutils`) +- Comprehensive network information display on startup +- Connectivity testing to `host.docker.internal` + +### Network Test Scripts +Use these scripts to debug Docker network connectivity: + +**Linux/Mac:** +```bash +chmod +x scripts/test-docker-network.sh +./scripts/test-docker-network.sh +``` + +**Windows:** +```cmd +scripts\test-docker-network.bat +``` + +### What the Enhanced Logging Shows +When the container starts, you'll see: +- Container hostname and IP addresses +- Docker host IP (gateway) +- Connectivity test to `host.docker.internal` +- DNS configuration +- Network interfaces +- Default gateway +- Routing information + +## Quick Start + +### Option 1: Use the Startup Scripts (Recommended) + +**Linux/Mac:** +```bash +chmod +x scripts/start-with-license-server.sh +./scripts/start-with-license-server.sh +``` + +**Windows:** +```cmd +scripts\start-with-license-server.bat +``` + +### Option 2: Manual Docker Compose + +```bash +# Start both services +docker-compose -f docker-compose.yml -f docker-compose.license-server.yml up --build -d + +# View logs +docker-compose -f docker-compose.yml -f docker-compose.license-server.yml logs -f + +# Stop services +docker-compose -f docker-compose.yml -f docker-compose.license-server.yml down +``` + +## What This Setup Provides + +### 1. License Server Container +- Runs on port 8082 +- Implements all required API endpoints: + - `/api/v1/status` - Health check + - `/api/v1/register` - Instance registration + - `/api/v1/validate` - License validation + - `/api/v1/heartbeat` - Heartbeat processing + - `/api/v1/data` - Usage data collection + +### 2. Proper Container Networking +- Uses Docker service names (`license-server:8082`) instead of `host.docker.internal` +- Allows proper container-to-container communication +- Both services are on the same Docker network + +### 3. Enhanced Logging +- The TimeTracker application now has comprehensive logging for license server communication +- You'll see detailed information about requests, responses, and any errors + +### 4. Network Debugging +- Container displays full network information on startup +- Network test scripts for troubleshooting connectivity issues +- Enhanced error logging with detailed network diagnostics + +## API Endpoints + +### Health Check +```bash +curl http://localhost:8082/api/v1/status +``` + +### Instance Registration +```bash +curl -X POST http://localhost:8082/api/v1/register \ + -H "Content-Type: application/json" \ + -d '{ + "app_identifier": "timetracker", + "version": "1.0.0", + "instance_id": "test-123", + "system_metadata": {"os": "Linux"} + }' +``` + +### View Registered Instances +```bash +curl http://localhost:8082/api/v1/instances +``` + +### View Usage Data +```bash +curl http://localhost:8082/api/v1/usage +``` + +## Troubleshooting + +### 1. Port Already in Use +If port 8082 is already in use, you can change it in both: +- `docker-compose.license-server.yml` (ports section) +- `app/utils/license_server.py` (server_url) + +### 2. Container Communication Issues +Check if both containers are running: +```bash +docker-compose -f docker-compose.yml -f docker-compose.license-server.yml ps +``` + +### 3. View Detailed Logs +```bash +# License server logs +docker-compose -f docker-compose.yml -f docker-compose.license-server.yml logs license-server + +# Main application logs +docker-compose -f docker-compose.yml -f docker-compose.license-server.yml logs app +``` + +### 4. Test License Server Directly +```bash +# Test from host +curl http://localhost:8082/api/v1/status + +# Test from within the app container +docker exec timetracker-app curl http://license-server:8082/api/v1/status +``` + +### 5. Network Debugging +```bash +# Run network test script +./scripts/test-docker-network.sh + +# Check container network info +docker exec timetracker-app ip addr show +docker exec timetracker-app ip route show +docker exec timetracker-app cat /etc/resolv.conf +``` + +## Customization + +### Change License Server Port +1. Update `docker-compose.license-server.yml`: + ```yaml + ports: + - "8083:8082" # Change 8083 to your desired port + ``` + +2. Update `app/utils/license_server.py`: + ```python + self.server_url = "http://license-server:8082" # Keep internal port as 8082 + ``` + +### Add Authentication +Modify `docker/license-server/license_server.py` to add authentication logic to the endpoints. + +### Persistent Storage +The current implementation uses in-memory storage. For production, consider: +- Adding a database service +- Using Redis for caching +- Implementing proper data persistence + +## Security Notes + +- This is a development/demo setup +- The license server accepts all requests without authentication +- For production use, implement proper security measures +- Consider using HTTPS for production deployments + +## Next Steps + +1. Start the services using one of the methods above +2. Check the logs to ensure both services are running +3. Run the network test script to verify connectivity +4. Test the license server endpoints +5. Monitor the TimeTracker application logs for successful license server communication + +The enhanced logging and network debugging tools will now provide detailed information about all license server interactions and network configuration, making it much easier to diagnose any issues. diff --git a/docs/LOCAL_TESTING_WITH_SQLITE.md b/docs/LOCAL_TESTING_WITH_SQLITE.md index 0a27956..8d6555e 100644 --- a/docs/LOCAL_TESTING_WITH_SQLITE.md +++ b/docs/LOCAL_TESTING_WITH_SQLITE.md @@ -181,6 +181,22 @@ If you encounter issues with the entrypoint script (like `su-exec: not found`), The simplified entrypoint runs everything as root, which avoids user switching issues but is less secure (fine for local testing). +### License Server Errors + +If you see license server 404 errors, they should be automatically disabled in the local test environment. If you still see them: + +1. **Verify environment variable is set**: + ```bash + docker exec timetracker-app-local-test env | grep LICENSE_SERVER_ENABLED + ``` + +2. **Should show**: `LICENSE_SERVER_ENABLED=false` + +3. **If not set correctly, restart the container**: + ```bash + docker-compose -f docker-compose.local-test.yml restart + ``` + ## Differences from Production | Feature | Local Test | Production | diff --git a/docs/MULTI_TENANT_IMPLEMENTATION.md b/docs/MULTI_TENANT_IMPLEMENTATION.md deleted file mode 100644 index b8b59d1..0000000 --- a/docs/MULTI_TENANT_IMPLEMENTATION.md +++ /dev/null @@ -1,581 +0,0 @@ -# Multi-Tenant Implementation Guide - -## Overview - -TimeTracker now supports **multi-tenancy**, allowing multiple organizations to use the same application instance while keeping their data completely isolated. This implementation uses a **shared database with Row Level Security (RLS)** approach for maximum efficiency and security. - -## Architecture - -### Data Model - -The multi-tenant architecture consists of two core tables: - -1. **Organizations** - Represents each tenant/customer organization -2. **Memberships** - Links users to organizations with roles - -All data tables now include an `organization_id` foreign key that scopes data to a specific organization. - -### Key Components - -#### 1. Organization Model (`app/models/organization.py`) - -```python -class Organization(db.Model): - """Represents a tenant organization""" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(200), nullable=False) - slug = db.Column(db.String(100), unique=True, nullable=False) - status = db.Column(db.String(20), default='active') - subscription_plan = db.Column(db.String(50), default='free') - # ... more fields -``` - -#### 2. Membership Model (`app/models/membership.py`) - -```python -class Membership(db.Model): - """Links users to organizations with roles""" - user_id = db.Column(db.Integer, db.ForeignKey('users.id')) - organization_id = db.Column(db.Integer, db.ForeignKey('organizations.id')) - role = db.Column(db.String(20)) # 'admin', 'member', 'viewer' - status = db.Column(db.String(20)) # 'active', 'invited', 'suspended' -``` - -#### 3. Tenancy Middleware (`app/utils/tenancy.py`) - -Provides helpers for managing organization context: - -- `get_current_organization_id()` - Get current org from context -- `set_current_organization(org_id)` - Set org in context -- `scoped_query(Model)` - Create org-scoped queries -- `require_organization_access()` - Decorator to enforce access - -#### 4. Row Level Security (`migrations/enable_row_level_security.sql`) - -PostgreSQL RLS policies that enforce data isolation at the database level. - -## Database Schema Changes - -### New Tables - -#### `organizations` -```sql -CREATE TABLE organizations ( - id SERIAL PRIMARY KEY, - name VARCHAR(200) NOT NULL, - slug VARCHAR(100) UNIQUE NOT NULL, - contact_email VARCHAR(200), - status VARCHAR(20) DEFAULT 'active', - subscription_plan VARCHAR(50) DEFAULT 'free', - max_users INTEGER, - max_projects INTEGER, - timezone VARCHAR(50) DEFAULT 'UTC', - currency VARCHAR(3) DEFAULT 'EUR', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - deleted_at TIMESTAMP -); -``` - -#### `memberships` -```sql -CREATE TABLE memberships ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id), - organization_id INTEGER NOT NULL REFERENCES organizations(id), - role VARCHAR(20) DEFAULT 'member', - status VARCHAR(20) DEFAULT 'active', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - UNIQUE (user_id, organization_id, status) -); -``` - -### Modified Tables - -All core data tables now have: -- `organization_id` column (NOT NULL, FOREIGN KEY to organizations.id) -- Composite indexes on (`organization_id`, common_filter_column) -- Updated unique constraints to be per-organization - -Tables updated: -- `projects` -- `clients` -- `time_entries` -- `tasks` -- `invoices` -- `comments` -- `focus_sessions` -- `saved_filters` -- `task_activity` - -## Migration Guide - -### Step 1: Run Database Migration - -```bash -# Run the Alembic migration -flask db upgrade head - -# Or manually run the migration -alembic upgrade 018 -``` - -This migration will: -1. Create `organizations` and `memberships` tables -2. Create a default organization named "Default Organization" -3. Add `organization_id` to all existing tables -4. Migrate all existing data to the default organization -5. Create memberships for all existing users - -### Step 2: Enable Row Level Security (PostgreSQL only) - -For PostgreSQL databases, optionally enable RLS for additional security: - -```bash -psql -U timetracker -d timetracker -f migrations/enable_row_level_security.sql -``` - -This creates: -- RLS policies on all tenant-scoped tables -- Helper functions for context management -- Verification queries - -### Step 3: Update Application Code - -The tenancy middleware is automatically enabled. For existing routes that create/modify data, ensure they use the current organization context: - -```python -from app.utils.tenancy import get_current_organization_id - -@app.route('/projects/new', methods=['POST']) -@login_required -def create_project(): - org_id = get_current_organization_id() - - project = Project( - name=request.form['name'], - organization_id=org_id, # Always set organization_id - client_id=request.form['client_id'] - ) - - db.session.add(project) - db.session.commit() -``` - -## Usage Examples - -### Creating an Organization - -```python -from app.models import Organization -from app import db - -org = Organization( - name="Acme Corp", - slug="acme-corp", # Auto-generated if not provided - contact_email="admin@acme.com", - subscription_plan="professional" -) - -db.session.add(org) -db.session.commit() -``` - -### Adding Users to Organizations - -```python -from app.models import Membership - -# Add user as admin -membership = Membership( - user_id=user.id, - organization_id=org.id, - role='admin', - status='active' -) - -db.session.add(membership) -db.session.commit() -``` - -### Inviting Users - -```python -# Create pending membership with invitation token -membership = Membership( - user_id=new_user.id, - organization_id=org.id, - role='member', - status='invited', - invited_by=current_user.id -) - -db.session.add(membership) -db.session.commit() - -# Send invitation email with token -invitation_url = url_for('auth.accept_invitation', - token=membership.invitation_token) -``` - -### Scoped Queries - -```python -from app.utils.tenancy import scoped_query - -# Get projects for current organization only -projects = scoped_query(Project).filter_by(status='active').all() - -# Get time entries for current organization -entries = scoped_query(TimeEntry).filter( - TimeEntry.start_time >= start_date -).all() -``` - -### Switching Organizations - -```python -from app.utils.tenancy import switch_organization - -# Switch to a different organization (if user has access) -try: - org = switch_organization(new_org_id) - # Now all queries will be scoped to new_org_id -except PermissionError: - flash("You don't have access to that organization") -``` - -### Protecting Routes - -```python -from app.utils.tenancy import require_organization_access - -@app.route('/admin/settings') -@login_required -@require_organization_access(admin_only=True) -def organization_settings(): - """Only accessible to organization admins""" - org = get_current_organization() - return render_template('admin/org_settings.html', org=org) -``` - -## Row Level Security (RLS) - -### How It Works - -RLS provides defense-in-depth by enforcing tenant isolation at the PostgreSQL level: - -```sql --- Example policy on projects table -CREATE POLICY projects_tenant_isolation ON projects - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); -``` - -### Setting Context - -The application automatically sets the RLS context for each request: - -```python -# In app/__init__.py before_request handler -from app.utils.rls import init_rls_for_request - -@app.before_request -def setup_request(): - init_rls_for_request() # Sets PostgreSQL session variables -``` - -### Testing RLS - -```python -from app.utils.rls import test_rls_isolation - -# Test that org1 data is isolated from org2 -results = test_rls_isolation(org1.id, org2.id) - -if results['success']: - print("✅ RLS is working correctly!") -else: - print("❌ RLS isolation failed!") - print(results) -``` - -## API Endpoints - -### Organization Management - -#### List Organizations -```http -GET /api/organizations -Authorization: Bearer - -Response: -{ - "organizations": [ - { - "id": 1, - "name": "Acme Corp", - "slug": "acme-corp", - "member_count": 5, - "subscription_plan": "professional" - } - ] -} -``` - -#### Create Organization -```http -POST /api/organizations -Authorization: Bearer -Content-Type: application/json - -{ - "name": "New Corp", - "contact_email": "admin@newcorp.com", - "subscription_plan": "starter" -} -``` - -#### Switch Organization -```http -POST /api/organizations/:id/switch -Authorization: Bearer - -Response: -{ - "success": true, - "organization": { - "id": 1, - "name": "Acme Corp" - } -} -``` - -### Membership Management - -#### List Members -```http -GET /api/organizations/:id/members -Authorization: Bearer - -Response: -{ - "members": [ - { - "user_id": 1, - "username": "john", - "role": "admin", - "status": "active", - "created_at": "2025-01-01T00:00:00Z" - } - ] -} -``` - -#### Invite User -```http -POST /api/organizations/:id/members/invite -Authorization: Bearer -Content-Type: application/json - -{ - "email": "newuser@example.com", - "role": "member" -} -``` - -## Testing - -### Running Tests - -```bash -# Run all multi-tenant tests -pytest tests/test_multi_tenant.py -v - -# Run specific test class -pytest tests/test_multi_tenant.py::TestTenantDataIsolation -v - -# Run with coverage -pytest tests/test_multi_tenant.py --cov=app --cov-report=html -``` - -### Manual Testing - -```bash -# Start Python shell in app context -flask shell - -# Create test organizations -from app.models import Organization -org1 = Organization(name="Test Org 1") -org2 = Organization(name="Test Org 2") -db.session.add_all([org1, org2]) -db.session.commit() - -# Test scoped queries -from app.utils.tenancy import set_current_organization, scoped_query -from app.models import Project - -set_current_organization(org1.id, org1) -projects = scoped_query(Project).all() -print(f"Org 1 has {len(projects)} projects") -``` - -## Security Considerations - -### 1. Application-Level Security -- ✅ Tenancy middleware enforces organization scoping on all requests -- ✅ Scoped queries automatically filter by organization_id -- ✅ Access decorators prevent cross-organization access - -### 2. Database-Level Security -- ✅ Row Level Security (RLS) policies enforce isolation in PostgreSQL -- ✅ Foreign key constraints ensure referential integrity -- ✅ Unique constraints are scoped per-organization - -### 3. Best Practices -- Always use `scoped_query()` instead of raw queries -- Never bypass `organization_id` filters -- Use `require_organization_access()` decorator on sensitive routes -- Enable RLS in production for defense-in-depth -- Regular audit logs for cross-organization access attempts - -## Troubleshooting - -### Problem: Users can see data from other organizations - -**Check:** -1. Is tenancy middleware enabled? (`init_tenancy_for_request` called?) -2. Are you using `scoped_query()` or raw queries? -3. Is `organization_id` set on new records? -4. Is RLS enabled and working? (PostgreSQL only) - -**Debug:** -```python -from app.utils.tenancy import get_current_organization_id -print(f"Current org: {get_current_organization_id()}") - -from app.utils.rls import verify_rls_is_active -print(verify_rls_is_active()) -``` - -### Problem: Migration fails - -**Common issues:** -- Existing data without organization -- Unique constraint violations -- Foreign key constraint violations - -**Solution:** -Check migration logs and manually fix data if needed: -```sql --- Find records without organization_id -SELECT * FROM projects WHERE organization_id IS NULL; - --- Fix manually -UPDATE projects SET organization_id = 1 WHERE organization_id IS NULL; -``` - -### Problem: RLS is too restrictive - -If RLS blocks legitimate access: -1. Check that `set_organization_context()` is being called -2. Verify user has active membership -3. For admin tasks, use `is_super_admin=True` - -```python -from app.utils.rls import set_rls_context - -# Allow super admin access for migrations/admin tasks -set_rls_context(None, is_super_admin=True) -``` - -## Performance Optimization - -### Indexes - -The migration creates these composite indexes: -- `(organization_id, status)` on most tables -- `(organization_id, user_id)` on user-scoped tables -- `(organization_id, start_time)` on time-based tables - -### Query Patterns - -```python -# ✅ Good - Uses composite index -projects = Project.query.filter_by( - organization_id=org_id, - status='active' -).all() - -# ❌ Bad - Doesn't use index efficiently -projects = Project.query.filter_by(status='active').filter_by( - organization_id=org_id -).all() -``` - -### Connection Pooling - -With RLS, ensure connection pool is configured properly: - -```python -# config.py -SQLALCHEMY_ENGINE_OPTIONS = { - 'pool_size': 10, - 'max_overflow': 20, - 'pool_pre_ping': True, - 'pool_recycle': 300, # Recycle connections every 5 minutes -} -``` - -## Future Enhancements - -### Planned Features -- [ ] Organization-level settings inheritance -- [ ] Billing and subscription management -- [ ] Organization transfer (change ownership) -- [ ] Bulk user import/export -- [ ] Organization templates -- [ ] Usage analytics per organization -- [ ] Schema-per-tenant option for extreme isolation - -### Migration Path to Schema-per-Tenant - -If stronger isolation is needed: - -1. Export organization data -2. Create dedicated schema per organization -3. Update connection string to use schema -4. Migrate data to new schema - -```python -# Example schema-per-tenant approach -def get_schema_for_org(org_id): - return f"org_{org_id}" - -# Set schema for queries -db.session.execute(f"SET search_path TO {schema_name}") -``` - -## Support - -For issues or questions: -- Check logs: `logs/timetracker.log` -- Run tests: `pytest tests/test_multi_tenant.py -v` -- Enable debug logging: Set `LOG_LEVEL=DEBUG` - -## Changelog - -### Version 2.0.0 (2025-10-07) -- ✅ Initial multi-tenant implementation -- ✅ Organizations and memberships models -- ✅ Tenancy middleware -- ✅ Row Level Security policies -- ✅ Database migration (018) -- ✅ Comprehensive test suite -- ✅ API endpoints for org management - diff --git a/docs/ROUTE_MIGRATION_GUIDE.md b/docs/ROUTE_MIGRATION_GUIDE.md deleted file mode 100644 index b49c2ff..0000000 --- a/docs/ROUTE_MIGRATION_GUIDE.md +++ /dev/null @@ -1,559 +0,0 @@ -# Route Migration Guide for Multi-Tenancy - -This guide explains how to update existing routes and queries to work with the new multi-tenant architecture. - -## Overview - -With multi-tenancy enabled, all data queries must be scoped to the current organization. This guide provides patterns for updating your existing routes. - -## Quick Migration Checklist - -For each route that handles organization-scoped data: - -- [ ] Add `organization_id` when creating new records -- [ ] Use `scoped_query()` instead of `Model.query` -- [ ] Add `@require_organization_access()` decorator where appropriate -- [ ] Update form submissions to include organization context -- [ ] Update API endpoints to return organization-scoped data - -## Common Patterns - -### Pattern 1: Creating New Records - -**Before:** -```python -@projects_bp.route('/new', methods=['POST']) -@login_required -def create_project(): - project = Project( - name=request.form['name'], - client_id=request.form['client_id'] - ) - db.session.add(project) - db.session.commit() - return redirect(url_for('projects.index')) -``` - -**After:** -```python -from app.utils.tenancy import get_current_organization_id, require_organization_access - -@projects_bp.route('/new', methods=['POST']) -@login_required -@require_organization_access() # Ensure user has org access -def create_project(): - org_id = get_current_organization_id() - - project = Project( - name=request.form['name'], - organization_id=org_id, # ✅ Add organization_id - client_id=request.form['client_id'] - ) - db.session.add(project) - db.session.commit() - return redirect(url_for('projects.index')) -``` - -### Pattern 2: Listing Records - -**Before:** -```python -@projects_bp.route('/') -@login_required -def index(): - projects = Project.query.filter_by(status='active').all() - return render_template('projects/index.html', projects=projects) -``` - -**After:** -```python -from app.utils.tenancy import scoped_query, require_organization_access - -@projects_bp.route('/') -@login_required -@require_organization_access() -def index(): - # Use scoped_query to automatically filter by organization - projects = scoped_query(Project).filter_by(status='active').all() - return render_template('projects/index.html', projects=projects) -``` - -### Pattern 3: Viewing a Specific Record - -**Before:** -```python -@projects_bp.route('/') -@login_required -def detail(project_id): - project = Project.query.get_or_404(project_id) - return render_template('projects/detail.html', project=project) -``` - -**After:** -```python -from app.utils.tenancy import scoped_query, ensure_organization_access, require_organization_access - -@projects_bp.route('/') -@login_required -@require_organization_access() -def detail(project_id): - # Option 1: Use scoped_query with get - project = scoped_query(Project).filter_by(id=project_id).first_or_404() - - # Option 2: Get normally then verify access - # project = Project.query.get_or_404(project_id) - # ensure_organization_access(project) # Raises PermissionError if wrong org - - return render_template('projects/detail.html', project=project) -``` - -### Pattern 4: Updating Records - -**Before:** -```python -@projects_bp.route('//edit', methods=['POST']) -@login_required -def edit(project_id): - project = Project.query.get_or_404(project_id) - project.name = request.form['name'] - db.session.commit() - return redirect(url_for('projects.detail', project_id=project_id)) -``` - -**After:** -```python -from app.utils.tenancy import scoped_query, require_organization_access - -@projects_bp.route('//edit', methods=['POST']) -@login_required -@require_organization_access() -def edit(project_id): - # Scoped query ensures we can only edit projects in our org - project = scoped_query(Project).filter_by(id=project_id).first_or_404() - - project.name = request.form['name'] - # organization_id stays the same, no need to update - - db.session.commit() - return redirect(url_for('projects.detail', project_id=project_id)) -``` - -### Pattern 5: Deleting Records - -**Before:** -```python -@projects_bp.route('//delete', methods=['POST']) -@login_required -def delete(project_id): - project = Project.query.get_or_404(project_id) - db.session.delete(project) - db.session.commit() - return redirect(url_for('projects.index')) -``` - -**After:** -```python -from app.utils.tenancy import scoped_query, require_organization_access - -@projects_bp.route('//delete', methods=['POST']) -@login_required -@require_organization_access(admin_only=True) # Only org admins can delete -def delete(project_id): - # Scoped query ensures we can only delete from our org - project = scoped_query(Project).filter_by(id=project_id).first_or_404() - - db.session.delete(project) - db.session.commit() - return redirect(url_for('projects.index')) -``` - -### Pattern 6: Complex Queries with Joins - -**Before:** -```python -@reports_bp.route('/project-summary') -@login_required -def project_summary(): - results = db.session.query( - Project.name, - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join(TimeEntry).group_by(Project.id).all() - - return render_template('reports/summary.html', results=results) -``` - -**After:** -```python -from app.utils.tenancy import get_current_organization_id, require_organization_access - -@reports_bp.route('/project-summary') -@login_required -@require_organization_access() -def project_summary(): - org_id = get_current_organization_id() - - results = db.session.query( - Project.name, - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join(TimeEntry).filter( - Project.organization_id == org_id, # ✅ Filter by org - TimeEntry.organization_id == org_id # ✅ Filter join too - ).group_by(Project.id).all() - - return render_template('reports/summary.html', results=results) -``` - -### Pattern 7: API Endpoints - -**Before:** -```python -@api_bp.route('/projects', methods=['GET']) -@login_required -def api_projects(): - projects = Project.query.all() - return jsonify({ - 'projects': [p.to_dict() for p in projects] - }) -``` - -**After:** -```python -from app.utils.tenancy import scoped_query, require_organization_access - -@api_bp.route('/projects', methods=['GET']) -@login_required -@require_organization_access() -def api_projects(): - projects = scoped_query(Project).all() - return jsonify({ - 'projects': [p.to_dict() for p in projects] - }) -``` - -### Pattern 8: Background Tasks - -**Before:** -```python -def send_weekly_report(): - users = User.query.filter_by(is_active=True).all() - for user in users: - entries = TimeEntry.query.filter_by(user_id=user.id).all() - # Send report... -``` - -**After:** -```python -from app.utils.tenancy import set_current_organization -from app.utils.rls import with_rls_context - -def send_weekly_report(): - # Get all organizations - orgs = Organization.query.filter_by(status='active').all() - - for org in orgs: - # Set org context for each organization - set_current_organization(org.id, org) - - # Now all queries are scoped to this org - memberships = Membership.query.filter_by( - organization_id=org.id, - status='active' - ).all() - - for membership in memberships: - entries = scoped_query(TimeEntry).filter_by( - user_id=membership.user_id - ).all() - # Send report... - -# Or use decorator for simpler code: -@with_rls_context(organization_id=1) # Set org context -def process_org_data(): - # All queries here are automatically scoped to org 1 - projects = scoped_query(Project).all() -``` - -## Migration Strategy - -### Phase 1: Audit Routes (Day 1) - -1. List all routes that handle data operations -2. Identify which models are affected -3. Prioritize critical routes first - -```bash -# Find all route files -find app/routes -name "*.py" -type f - -# Search for model queries -grep -r "Model.query" app/routes/ -grep -r "db.session.query" app/routes/ -``` - -### Phase 2: Update Models (Day 1-2) - -Already completed! All models now have `organization_id`. - -### Phase 3: Update Routes (Day 2-5) - -Update routes in this order: -1. **Authentication routes** - Add org selection after login -2. **Project routes** - Core functionality -3. **Time entry routes** - Core functionality -4. **Client routes** - Related to projects -5. **Task routes** - Related to projects -6. **Invoice routes** - Related to projects/clients -7. **Report routes** - Cross-cutting queries -8. **Admin routes** - System-wide operations - -### Phase 4: Testing (Day 5-7) - -1. Run test suite: `pytest tests/test_multi_tenant.py -v` -2. Manual testing with multiple organizations -3. Verify data isolation -4. Check RLS policies - -### Phase 5: Deployment (Day 7) - -1. Backup database -2. Run migrations -3. Enable RLS (if using PostgreSQL) -4. Monitor logs for errors -5. Verify tenant isolation in production - -## Example: Complete Route File Migration - -### Before: `app/routes/projects.py` - -```python -from flask import Blueprint, render_template, request, redirect, url_for -from flask_login import login_required -from app import db -from app.models import Project - -projects_bp = Blueprint('projects', __name__, url_prefix='/projects') - -@projects_bp.route('/') -@login_required -def index(): - projects = Project.query.all() - return render_template('projects/index.html', projects=projects) - -@projects_bp.route('/new', methods=['POST']) -@login_required -def create(): - project = Project(name=request.form['name'], client_id=request.form['client_id']) - db.session.add(project) - db.session.commit() - return redirect(url_for('projects.index')) -``` - -### After: `app/routes/projects.py` - -```python -from flask import Blueprint, render_template, request, redirect, url_for, flash -from flask_login import login_required -from app import db -from app.models import Project, Client -from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access -) - -projects_bp = Blueprint('projects', __name__, url_prefix='/projects') - -@projects_bp.route('/') -@login_required -@require_organization_access() -def index(): - # ✅ Use scoped_query for automatic org filtering - projects = scoped_query(Project).all() - return render_template('projects/index.html', projects=projects) - -@projects_bp.route('/new', methods=['POST']) -@login_required -@require_organization_access() -def create(): - org_id = get_current_organization_id() - - # ✅ Verify client belongs to same org - client = scoped_query(Client).filter_by( - id=request.form['client_id'] - ).first_or_404() - - # ✅ Include organization_id - project = Project( - name=request.form['name'], - organization_id=org_id, - client_id=client.id - ) - - db.session.add(project) - db.session.commit() - - flash('Project created successfully!', 'success') - return redirect(url_for('projects.index')) -``` - -## Common Pitfalls - -### ❌ Pitfall 1: Forgetting organization_id - -```python -# BAD - Missing organization_id -project = Project(name='Test', client_id=1) -db.session.add(project) -db.session.commit() # Will fail - organization_id is required -``` - -### ✅ Solution - -```python -# GOOD - Include organization_id -org_id = get_current_organization_id() -project = Project(name='Test', organization_id=org_id, client_id=1) -``` - -### ❌ Pitfall 2: Using unscoped queries - -```python -# BAD - Shows all projects from all orgs -projects = Project.query.all() -``` - -### ✅ Solution - -```python -# GOOD - Only shows current org's projects -projects = scoped_query(Project).all() -``` - -### ❌ Pitfall 3: Cross-org references - -```python -# BAD - Client might be from different org -org_id = get_current_organization_id() -project = Project( - name='Test', - organization_id=org_id, - client_id=request.form['client_id'] # Not verified! -) -``` - -### ✅ Solution - -```python -# GOOD - Verify client is in same org -org_id = get_current_organization_id() -client = scoped_query(Client).filter_by( - id=request.form['client_id'] -).first_or_404() # Will 404 if wrong org - -project = Project( - name='Test', - organization_id=org_id, - client_id=client.id -) -``` - -## Testing Your Changes - -### Unit Test Example - -```python -def test_project_isolation(app, organizations, users): - with app.app_context(): - org1, org2 = organizations - - # Create projects in each org - set_current_organization(org1.id, org1) - project1 = Project(name='Org1 Project', organization_id=org1.id, client_id=...) - db.session.add(project1) - - set_current_organization(org2.id, org2) - project2 = Project(name='Org2 Project', organization_id=org2.id, client_id=...) - db.session.add(project2) - db.session.commit() - - # Verify isolation - set_current_organization(org1.id, org1) - org1_projects = scoped_query(Project).all() - assert len(org1_projects) == 1 - assert org1_projects[0].id == project1.id -``` - -### Manual Testing Checklist - -- [ ] Create test organizations -- [ ] Create test users with different org memberships -- [ ] Login as each user and verify: - - [ ] Can only see their org's data - - [ ] Cannot access other org's data by URL manipulation - - [ ] Can create new records in their org - - [ ] Cannot edit other org's records - - [ ] Can switch between orgs (if multi-org user) - -## Monitoring and Debugging - -### Enable Debug Logging - -```python -# config.py -LOG_LEVEL = 'DEBUG' -``` - -### Check Current Organization - -```python -from app.utils.tenancy import get_current_organization_id - -# In any route -org_id = get_current_organization_id() -print(f"Current org: {org_id}") -``` - -### Verify RLS is Active - -```python -from app.utils.rls import verify_rls_is_active - -# In Flask shell -status = verify_rls_is_active() -print(status) -``` - -### Monitor Query Performance - -```sql --- Check slow queries -SELECT query, calls, total_time, mean_time -FROM pg_stat_statements -WHERE query LIKE '%organization_id%' -ORDER BY total_time DESC -LIMIT 10; -``` - -## Need Help? - -- Check logs: `logs/timetracker.log` -- Run tests: `pytest tests/test_multi_tenant.py -v` -- Review documentation: `docs/MULTI_TENANT_IMPLEMENTATION.md` -- Search for existing patterns in `app/routes/organizations.py` - -## Summary - -✅ **Always**: -- Use `scoped_query()` for queries -- Include `organization_id` when creating records -- Add `@require_organization_access()` decorator -- Verify cross-model references are in same org - -❌ **Never**: -- Use `Model.query` directly for org-scoped data -- Forget to add `organization_id` to new records -- Trust user input for cross-org references -- Bypass org checks for "convenience" - -The multi-tenant architecture ensures data isolation and security, but it requires discipline in following these patterns throughout your codebase. - diff --git a/docs/ROUTE_UPDATE_PLAN.md b/docs/ROUTE_UPDATE_PLAN.md deleted file mode 100644 index 1e4e8ee..0000000 --- a/docs/ROUTE_UPDATE_PLAN.md +++ /dev/null @@ -1,386 +0,0 @@ -# Route Update Plan for Multi-Tenant Implementation - -## Overview - -This document provides a prioritized plan for updating existing routes to work with the multi-tenant architecture. The updates are designed to be done **gradually** and **incrementally** to minimize risk and allow for thorough testing. - -## Why Gradual Updates? - -The route updates are intentionally left for incremental implementation because: - -1. **Testing Required:** Each route should be tested after updating to ensure correct behavior -2. **Business Logic:** Route handlers contain application-specific logic that needs careful review -3. **Low Risk:** The infrastructure is complete and safe; routes can be updated at your own pace -4. **Backward Compatible:** Existing routes will continue working (though without org scoping) until updated - -## Priority Matrix - -### 🔴 Critical Priority (Update First - Day 1-2) - -These routes handle core functionality and should be updated immediately: - -| Route File | Reason | Estimated Time | -|------------|--------|----------------| -| `app/routes/projects.py` | Core project CRUD operations | 2-3 hours | -| `app/routes/timer.py` | Time entry creation and tracking | 2-3 hours | -| `app/routes/clients.py` | Client management | 1-2 hours | - -**Why Critical:** -- Most frequently used features -- Data creation happens here -- High risk of cross-org data visibility - -**Update Pattern:** -```python -# Add imports -from app.utils.tenancy import get_current_organization_id, scoped_query, require_organization_access - -# Add decorator to all routes -@require_organization_access() - -# Use scoped queries -projects = scoped_query(Project).filter_by(status='active').all() - -# Include org_id when creating -org_id = get_current_organization_id() -project = Project(name='...', organization_id=org_id, client_id=...) -``` - -### 🟡 High Priority (Update Next - Day 3-4) - -Important features that should be updated soon: - -| Route File | Reason | Estimated Time | -|------------|--------|----------------| -| `app/routes/tasks.py` | Task management and assignment | 2 hours | -| `app/routes/invoices.py` | Invoice generation and management | 2-3 hours | -| `app/routes/comments.py` | Project/task discussions | 1 hour | - -**Why High Priority:** -- Frequently used features -- Involve cross-table relationships -- Important for data integrity - -### 🟢 Medium Priority (Update When Convenient - Day 5-7) - -Features that can be updated with less urgency: - -| Route File | Reason | Estimated Time | -|------------|--------|----------------| -| `app/routes/reports.py` | Report generation | 2-3 hours | -| `app/routes/analytics.py` | Analytics views | 1-2 hours | -| `app/routes/api.py` | API endpoints (if not already covered) | 1-2 hours | - -**Why Medium Priority:** -- Less frequently used -- Read-only operations (lower risk) -- Can be updated after core features - -### 🔵 Low Priority (Update Eventually) - -Features that can be updated last: - -| Route File | Reason | Estimated Time | -|------------|--------|----------------| -| `app/routes/admin.py` | System-wide admin operations | 1-2 hours | -| `app/routes/auth.py` | Authentication (may need minor updates) | 1 hour | -| `app/routes/main.py` | Dashboard/homepage | 1 hour | - -**Why Low Priority:** -- Minimal org-scoping needed -- System-wide operations -- Can work without immediate updates - -## Detailed Update Checklist - -### For Each Route File: - -- [ ] **Step 1: Add Imports** - ```python - from app.utils.tenancy import ( - get_current_organization_id, - scoped_query, - require_organization_access, - ensure_organization_access - ) - ``` - -- [ ] **Step 2: Add Route Decorators** - ```python - @some_bp.route('/endpoint') - @login_required - @require_organization_access() # Add this - def my_route(): - ... - ``` - -- [ ] **Step 3: Update CREATE Operations** - ```python - org_id = get_current_organization_id() - new_object = Model( - name='...', - organization_id=org_id, # Add this - ... - ) - ``` - -- [ ] **Step 4: Update READ Operations** - ```python - # Before: - objects = Model.query.filter_by(status='active').all() - - # After: - objects = scoped_query(Model).filter_by(status='active').all() - ``` - -- [ ] **Step 5: Update GET by ID** - ```python - # Before: - obj = Model.query.get_or_404(id) - - # After - Option 1: - obj = scoped_query(Model).filter_by(id=id).first_or_404() - - # After - Option 2: - obj = Model.query.get_or_404(id) - ensure_organization_access(obj) # Raises PermissionError if wrong org - ``` - -- [ ] **Step 6: Update Complex Queries** - ```python - org_id = get_current_organization_id() - - results = db.session.query(...).join(...).filter( - Model.organization_id == org_id, # Add to each table - OtherModel.organization_id == org_id - ).all() - ``` - -- [ ] **Step 7: Test the Route** - - Create test organization - - Test CRUD operations - - Verify data isolation - - Check error handling - -## Route-Specific Guidance - -### app/routes/projects.py - -**Key Updates:** -```python -# List projects -@projects_bp.route('/') -@login_required -@require_organization_access() -def index(): - projects = scoped_query(Project).filter_by(status='active').all() - return render_template('projects/index.html', projects=projects) - -# Create project -@projects_bp.route('/new', methods=['POST']) -@login_required -@require_organization_access() -def create(): - org_id = get_current_organization_id() - - # Verify client is in same org - client = scoped_query(Client).filter_by(id=request.form['client_id']).first_or_404() - - project = Project( - name=request.form['name'], - organization_id=org_id, - client_id=client.id, - ... - ) - db.session.add(project) - db.session.commit() - return redirect(url_for('projects.detail', project_id=project.id)) - -# View project -@projects_bp.route('/') -@login_required -@require_organization_access() -def detail(project_id): - project = scoped_query(Project).filter_by(id=project_id).first_or_404() - return render_template('projects/detail.html', project=project) -``` - -### app/routes/timer.py - -**Key Updates:** -```python -# Start timer -@timer_bp.route('/start', methods=['POST']) -@login_required -@require_organization_access() -def start(): - org_id = get_current_organization_id() - - # Verify project is in same org - project = scoped_query(Project).filter_by(id=request.form['project_id']).first_or_404() - - entry = TimeEntry( - user_id=current_user.id, - project_id=project.id, - organization_id=org_id, - start_time=datetime.utcnow(), - ... - ) - db.session.add(entry) - db.session.commit() - return jsonify({'success': True}) - -# List entries -@timer_bp.route('/entries') -@login_required -@require_organization_access() -def entries(): - entries = scoped_query(TimeEntry).filter_by( - user_id=current_user.id - ).order_by(TimeEntry.start_time.desc()).all() - return render_template('timer/entries.html', entries=entries) -``` - -### app/routes/reports.py - -**Key Updates:** -```python -# Project summary -@reports_bp.route('/project-summary') -@login_required -@require_organization_access() -def project_summary(): - org_id = get_current_organization_id() - - results = db.session.query( - Project.name, - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join(TimeEntry).filter( - Project.organization_id == org_id, - TimeEntry.organization_id == org_id - ).group_by(Project.id, Project.name).all() - - return render_template('reports/summary.html', results=results) -``` - -## Testing Checklist - -For each updated route, test: - -### Functional Testing -- [ ] Route loads without errors -- [ ] Can create new records -- [ ] Can view existing records -- [ ] Can edit records -- [ ] Can delete records -- [ ] Error handling works correctly - -### Security Testing -- [ ] Cannot view other org's data via URL manipulation -- [ ] Cannot edit other org's data -- [ ] Cannot create records in other org -- [ ] Proper 403/404 errors for invalid access - -### Multi-Org Testing (if applicable) -- [ ] User with multiple orgs can switch -- [ ] Data shows correctly after switch -- [ ] Breadcrumbs/navigation work correctly - -## Automation Helpers - -### Find All Routes to Update - -```bash -# Find all route functions -grep -r "@.*_bp.route" app/routes/ --include="*.py" - -# Find all Model.query usage (potential issues) -grep -r "\.query\." app/routes/ --include="*.py" -n - -# Find all db.session.query usage -grep -r "db.session.query" app/routes/ --include="*.py" -n -``` - -### Create Update Checklist - -```bash -# Generate checklist of files to update -for file in app/routes/*.py; do - echo "- [ ] $(basename $file)" -done -``` - -## Progress Tracking - -| Route File | Status | Updated By | Date | Notes | -|-----------|--------|------------|------|-------| -| projects.py | ⬜ Not Started | - | - | | -| timer.py | ⬜ Not Started | - | - | | -| clients.py | ⬜ Not Started | - | - | | -| tasks.py | ⬜ Not Started | - | - | | -| invoices.py | ⬜ Not Started | - | - | | -| comments.py | ⬜ Not Started | - | - | | -| reports.py | ⬜ Not Started | - | - | | -| analytics.py | ⬜ Not Started | - | - | | -| api.py | ⬜ Not Started | - | - | | -| admin.py | ⬜ Not Started | - | - | | -| auth.py | ⬜ Not Started | - | - | | -| main.py | ⬜ Not Started | - | - | | - -**Legend:** -- ⬜ Not Started -- 🟡 In Progress -- ✅ Complete & Tested -- ⏸️ On Hold - -## Quick Reference - -### Common Imports -```python -from app.utils.tenancy import ( - get_current_organization_id, - get_current_organization, - scoped_query, - require_organization_access, - ensure_organization_access, - switch_organization, -) -``` - -### Common Patterns -```python -# Get current org -org_id = get_current_organization_id() - -# Scoped list query -items = scoped_query(Model).filter_by(status='active').all() - -# Scoped get query -item = scoped_query(Model).filter_by(id=item_id).first_or_404() - -# Create with org -new_item = Model(name='...', organization_id=org_id, ...) - -# Verify cross-reference -related = scoped_query(RelatedModel).filter_by(id=ref_id).first_or_404() -``` - -## Support - -- **Documentation:** `docs/ROUTE_MIGRATION_GUIDE.md` -- **Examples:** `app/routes/organizations.py` -- **Tests:** `tests/test_multi_tenant.py` -- **Help:** Review this plan and the migration guide - -## Conclusion - -This is a **gradual migration plan**. You don't need to update all routes at once. Start with critical routes, test thoroughly, then move to the next priority level. The infrastructure is solid and will support you throughout the process. - -**Remember:** -- One route file at a time -- Test after each update -- Follow the patterns in the guide -- Don't rush - correctness over speed - -Good luck! 🚀 - diff --git a/docs/SECRETS_MANAGEMENT_GUIDE.md b/docs/SECRETS_MANAGEMENT_GUIDE.md deleted file mode 100644 index e20367c..0000000 --- a/docs/SECRETS_MANAGEMENT_GUIDE.md +++ /dev/null @@ -1,568 +0,0 @@ -# Secrets Management & Rotation Guide - -## Overview - -This guide provides comprehensive instructions for managing, storing, and rotating secrets in TimeTracker deployments. - -## Table of Contents - -1. [Secret Types](#secret-types) -2. [Generating Secrets](#generating-secrets) -3. [Storing Secrets](#storing-secrets) -4. [Rotation Schedule](#rotation-schedule) -5. [Rotation Procedures](#rotation-procedures) -6. [Secret Scanning](#secret-scanning) -7. [Best Practices](#best-practices) - ---- - -## Secret Types - -### Critical Secrets - -**Never commit these to version control!** - -1. **SECRET_KEY** - Flask session encryption key -2. **DATABASE_URL** - Database connection string with credentials -3. **STRIPE_SECRET_KEY** - Stripe API secret key -4. **STRIPE_WEBHOOK_SECRET** - Stripe webhook signing secret -5. **OIDC_CLIENT_SECRET** - OAuth/OIDC client secret -6. **SMTP_PASSWORD** - Email server password -7. **TLS/SSL Private Keys** - HTTPS certificates - -### Semi-Sensitive - -1. **STRIPE_PUBLISHABLE_KEY** - Stripe public key (client-side) -2. **OIDC_CLIENT_ID** - OAuth client ID -3. **SMTP_USERNAME** - Email server username - -### Non-Sensitive (Configuration) - -1. **TZ** - Timezone -2. **CURRENCY** - Default currency -3. **ADMIN_USERNAMES** - Admin user list - ---- - -## Generating Secrets - -### SECRET_KEY - -Generate a cryptographically secure random key: - -```bash -# Method 1: Using Python -python -c "import secrets; print(secrets.token_hex(32))" - -# Method 2: Using OpenSSL -openssl rand -hex 32 - -# Method 3: Using /dev/urandom -head -c 32 /dev/urandom | base64 - -# Example output: -# 7f3d2e1a9b8c4d5e6f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2 -``` - -**Requirements:** -- Minimum 32 bytes (64 hex characters) -- Truly random (use cryptographic RNG) -- Unique per environment - -### Database Passwords - -Generate strong database passwords: - -```bash -# Method 1: Random alphanumeric + special chars -python -c "import secrets, string; chars = string.ascii_letters + string.digits + '!@#$%^&*'; print(''.join(secrets.choice(chars) for _ in range(32)))" - -# Method 2: Using OpenSSL -openssl rand -base64 32 - -# Example output: -# aB3$dF5*gH7@jK9#lM2&nP4^qR6!sT8 -``` - -**Requirements:** -- Minimum 24 characters -- Mix of letters, numbers, special characters -- Avoid quotes and backslashes (shell escaping issues) - -### Stripe Secrets - -**Obtain from Stripe Dashboard:** - -1. Log in to [Stripe Dashboard](https://dashboard.stripe.com/) -2. Navigate to **Developers → API Keys** -3. Copy **Secret key** (starts with `sk_`) -4. Navigate to **Developers → Webhooks** -5. Copy **Signing secret** (starts with `whsec_`) - -**Important:** -- Use **test keys** (`sk_test_`, `whsec_test_`) for development -- Use **live keys** (`sk_live_`, `whsec_live_`) for production only - -### OIDC/OAuth Secrets - -**Obtain from your identity provider:** - -- **Azure AD**: Azure Portal → App Registrations → Your App → Certificates & Secrets -- **Okta**: Okta Admin → Applications → Your App → Client Credentials -- **Google**: Google Cloud Console → APIs & Services → Credentials - -### TLS Certificates - -```bash -# For development (self-signed): -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes - -# For production, use Let's Encrypt: -certbot certonly --webroot -w /var/www/html -d your-domain.com -``` - ---- - -## Storing Secrets - -### Development - -**Use `.env` file (NEVER commit!):** - -```bash -# .env -SECRET_KEY=your-dev-secret-key-here -DATABASE_URL=postgresql://user:pass@localhost:5432/timetracker_dev -STRIPE_SECRET_KEY=sk_test_... -STRIPE_WEBHOOK_SECRET=whsec_test_... -``` - -**Add to `.gitignore`:** - -```gitignore -.env -.env.local -.env.*.local -*.key -*.pem -``` - -### Production - -#### Option 1: Environment Variables - -**Docker Compose:** - -```yaml -services: - app: - environment: - - SECRET_KEY=${SECRET_KEY} - - DATABASE_URL=${DATABASE_URL} -``` - -**Systemd Service:** - -```ini -[Service] -Environment="SECRET_KEY=..." -Environment="DATABASE_URL=..." -EnvironmentFile=/etc/timetracker/secrets.env -``` - -#### Option 2: Docker Secrets - -```yaml -services: - app: - secrets: - - db_password - - stripe_secret - environment: - - DATABASE_URL=postgresql://timetracker:${db_password}@db:5432/timetracker - -secrets: - db_password: - file: ./secrets/db_password.txt - stripe_secret: - file: ./secrets/stripe_secret.txt -``` - -#### Option 3: Cloud Provider Secrets Management - -**AWS Secrets Manager:** - -```bash -# Store secret -aws secretsmanager create-secret --name timetracker/secret-key --secret-string "your-secret" - -# Retrieve in application -aws secretsmanager get-secret-value --secret-id timetracker/secret-key --query SecretString --output text -``` - -**Azure Key Vault:** - -```bash -# Store secret -az keyvault secret set --vault-name timetracker-vault --name secret-key --value "your-secret" - -# Retrieve in application -az keyvault secret show --vault-name timetracker-vault --name secret-key --query value -o tsv -``` - -**Google Secret Manager:** - -```bash -# Store secret -echo -n "your-secret" | gcloud secrets create secret-key --data-file=- - -# Retrieve in application -gcloud secrets versions access latest --secret="secret-key" -``` - -#### Option 4: HashiCorp Vault - -```bash -# Store secret -vault kv put secret/timetracker secret_key="your-secret" database_url="postgresql://..." - -# Retrieve in application -vault kv get -field=secret_key secret/timetracker -``` - ---- - -## Rotation Schedule - -### Critical Secrets - -| Secret | Rotation Frequency | Impact of Rotation | -|--------|-------------------|-------------------| -| SECRET_KEY | Every 90 days | Users logged out | -| Database passwords | Every 90 days | Brief downtime | -| Stripe keys | Every 6 months | None (if done correctly) | -| OIDC client secrets | Every 6 months | Brief auth disruption | -| TLS certificates | Every 12 months (auto with Let's Encrypt) | None (if renewed before expiry) | -| SMTP passwords | Every 90 days | None | - -### Compliance Requirements - -- **PCI DSS**: Rotate credentials every 90 days -- **SOC 2**: Document rotation policy and evidence -- **ISO 27001**: Regular key management reviews - ---- - -## Rotation Procedures - -### Rotating SECRET_KEY - -**⚠️ Warning: All users will be logged out** - -1. **Generate new key:** - - ```bash - python -c "import secrets; print(secrets.token_hex(32))" - ``` - -2. **Update environment variables:** - - ```bash - # Update .env or secrets manager - SECRET_KEY=new-secret-key-here - ``` - -3. **Restart application:** - - ```bash - docker-compose restart app - # or - systemctl restart timetracker - ``` - -4. **Notify users:** Send email about session invalidation - -5. **Document rotation:** Log date and reason - -### Rotating Database Passwords - -**Zero-downtime rotation:** - -1. **Create new database user:** - - ```sql - CREATE USER timetracker_new WITH PASSWORD 'new-password'; - GRANT ALL PRIVILEGES ON DATABASE timetracker TO timetracker_new; - GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO timetracker_new; - ``` - -2. **Update application config:** - - ```bash - DATABASE_URL=postgresql://timetracker_new:new-password@db:5432/timetracker - ``` - -3. **Restart application:** - - ```bash - docker-compose restart app - ``` - -4. **Verify application works** - -5. **Remove old user:** - - ```sql - DROP USER timetracker_old; - ``` - -### Rotating Stripe Keys - -**No downtime required:** - -1. **Create new secret key in Stripe Dashboard** - -2. **Update application with both keys:** - - ```bash - # Temporarily use both old and new keys - STRIPE_SECRET_KEY=sk_live_new... - STRIPE_SECRET_KEY_OLD=sk_live_old... # Keep for rollback - ``` - -3. **Deploy and verify** - -4. **Roll old key in Stripe Dashboard** after 24 hours - -5. **Remove old key from config** - -### Rotating TLS Certificates - -**Automatic with Let's Encrypt:** - -```bash -# Certbot auto-renewal (runs via cron) -certbot renew --quiet - -# Reload web server -systemctl reload nginx -``` - -**Manual renewal:** - -1. **Obtain new certificate** (60 days before expiry) - -2. **Test new certificate:** - - ```bash - openssl s_client -connect your-domain.com:443 -servername your-domain.com - ``` - -3. **Update nginx/Apache config** - -4. **Reload web server** - -5. **Verify:** https://www.ssllabs.com/ssltest/ - ---- - -## Secret Scanning - -### Prevent Secrets in Git - -**Install git-secrets:** - -```bash -# macOS -brew install git-secrets - -# Ubuntu/Debian -sudo apt-get install git-secrets - -# Configure for repository -cd timetracker -git secrets --install -git secrets --register-aws -``` - -### GitHub Secret Scanning - -Enabled automatically in the repository: -- Scans all commits for known secret patterns -- Alerts via Security tab -- Supports partner patterns (AWS, Stripe, etc.) - -### Gitleaks - -```bash -# Install -brew install gitleaks - -# Scan repository -gitleaks detect --source . --verbose - -# Scan before commit (pre-commit hook) -gitleaks protect --verbose --staged -``` - -### Remove Committed Secrets - -**If you accidentally commit a secret:** - -1. **Immediately rotate the secret** - -2. **Remove from git history:** - - ```bash - # Using BFG Repo-Cleaner (recommended) - brew install bfg - bfg --replace-text secrets.txt # File with secrets to remove - git reflog expire --expire=now --all - git gc --prune=now --aggressive - - # Or using git filter-branch - git filter-branch --force --index-filter \ - 'git rm --cached --ignore-unmatch path/to/secret/file' \ - --prune-empty --tag-name-filter cat -- --all - ``` - -3. **Force push (⚠️ requires team coordination):** - - ```bash - git push --force --all - git push --force --tags - ``` - ---- - -## Best Practices - -### Do's ✅ - -- **Use environment variables** or secret managers -- **Generate cryptographically random secrets** -- **Rotate secrets regularly** per schedule -- **Use different secrets** for dev/staging/production -- **Audit secret access** regularly -- **Encrypt secrets at rest** -- **Limit access** to secrets (principle of least privilege) -- **Document rotation procedures** -- **Test rotation** in staging first -- **Monitor for exposed secrets** (GitHub Secret Scanning, Gitleaks) - -### Don'ts ❌ - -- **Never commit secrets** to version control -- **Never log secrets** or print in error messages -- **Never share secrets** via email or Slack -- **Never reuse secrets** across environments -- **Never hardcode secrets** in application code -- **Never use weak secrets** (e.g., "password123") -- **Never skip rotation** schedule -- **Never store secrets** in client-side code - -### Secret Strength Requirements - -```python -# Minimum entropy requirements -SECRET_KEY: 256 bits (32 bytes) -Database password: 128 bits (16 bytes) -API keys: 128 bits (16 bytes) -TOTP secrets: 160 bits (20 bytes) -``` - ---- - -## Automation - -### Automated Secret Rotation (AWS Example) - -```python -import boto3 -import os -from datetime import datetime, timedelta - -def rotate_secret_key(): - """Rotate Flask SECRET_KEY in AWS Secrets Manager""" - client = boto3.client('secretsmanager') - - # Generate new secret - import secrets - new_secret = secrets.token_hex(32) - - # Update in Secrets Manager - client.put_secret_value( - SecretId='timetracker/secret-key', - SecretString=new_secret - ) - - # Tag with rotation date - client.tag_resource( - SecretId='timetracker/secret-key', - Tags=[ - {'Key': 'LastRotated', 'Value': datetime.utcnow().isoformat()}, - {'Key': 'NextRotation', 'Value': (datetime.utcnow() + timedelta(days=90)).isoformat()} - ] - ) - - print(f"Secret rotated successfully at {datetime.utcnow()}") - -if __name__ == '__main__': - rotate_secret_key() -``` - -### Scheduled Rotation (Cron) - -```bash -# /etc/cron.d/secret-rotation - -# Rotate SECRET_KEY every 90 days (manual verification required) -0 2 1 */3 * /opt/timetracker/scripts/rotate-secret-key.sh >> /var/log/secret-rotation.log 2>&1 - -# Check TLS certificate expiry daily -0 3 * * * /opt/timetracker/scripts/check-cert-expiry.sh >> /var/log/cert-check.log 2>&1 -``` - ---- - -## Emergency Procedures - -### Suspected Secret Compromise - -1. **Immediately rotate the compromised secret** -2. **Audit logs** for unauthorized access -3. **Notify affected users** if user data compromised -4. **Document incident** and timeline -5. **Review security controls** to prevent recurrence - -### Lost Secret - -1. **Check secret manager** or backup systems -2. **If unrecoverable, generate new secret** and rotate -3. **Update all dependent systems** -4. **Document incident** - ---- - -## Compliance Checklist - -- [ ] All secrets generated using cryptographically secure RNG -- [ ] No secrets in version control (verified with git-secrets/Gitleaks) -- [ ] Secrets stored in secure secret manager or encrypted at rest -- [ ] Access to secrets limited to authorized personnel only -- [ ] Rotation schedule documented and followed -- [ ] Rotation procedures tested in staging -- [ ] Audit logs enabled for secret access -- [ ] Incident response plan for secret compromise -- [ ] Regular secret access reviews (quarterly) -- [ ] Automated secret expiry monitoring - ---- - -## Support - -For questions about secrets management: -- Documentation: https://docs.your-domain.com -- Security team: security@your-domain.com -- On-call: +1-555-SECURITY - diff --git a/docs/SECURITY_COMPLIANCE_README.md b/docs/SECURITY_COMPLIANCE_README.md deleted file mode 100644 index c49ca77..0000000 --- a/docs/SECURITY_COMPLIANCE_README.md +++ /dev/null @@ -1,541 +0,0 @@ -# Security & Compliance Guide - -## Overview - -This document outlines the comprehensive security and compliance features implemented in TimeTracker, including TLS/HTTPS configuration, password policies, 2FA, rate limiting, GDPR compliance, and penetration testing guidelines. - -## Table of Contents - -1. [TLS/HTTPS Configuration](#tlshttps-configuration) -2. [Security Headers](#security-headers) -3. [Password Policies](#password-policies) -4. [Two-Factor Authentication (2FA)](#two-factor-authentication-2fa) -5. [Rate Limiting](#rate-limiting) -6. [Secrets Management](#secrets-management) -7. [GDPR Compliance](#gdpr-compliance) -8. [Data Retention Policies](#data-retention-policies) -9. [Dependency Scanning](#dependency-scanning) -10. [Penetration Testing](#penetration-testing) -11. [Security Checklist](#security-checklist) - ---- - -## TLS/HTTPS Configuration - -### Production Deployment - -**Always use HTTPS in production.** Configure your reverse proxy (nginx, Apache, or cloud load balancer) to handle TLS termination. - -### Example nginx Configuration - -```nginx -server { - listen 443 ssl http2; - server_name your-timetracker-domain.com; - - # TLS certificates - ssl_certificate /path/to/fullchain.pem; - ssl_certificate_key /path/to/privkey.pem; - - # Modern TLS configuration - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; - ssl_prefer_server_ciphers off; - - # HSTS - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; - - # Proxy to TimeTracker - location / { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} - -# Redirect HTTP to HTTPS -server { - listen 80; - server_name your-timetracker-domain.com; - return 301 https://$server_name$request_uri; -} -``` - -### Environment Variables for HTTPS - -```bash -SESSION_COOKIE_SECURE=true -REMEMBER_COOKIE_SECURE=true -PREFERRED_URL_SCHEME=https -``` - ---- - -## Security Headers - -TimeTracker automatically applies comprehensive security headers to all responses: - -- **X-Content-Type-Options**: `nosniff` - Prevents MIME type sniffing -- **X-Frame-Options**: `DENY` - Prevents clickjacking attacks -- **X-XSS-Protection**: `1; mode=block` - Enables XSS filter -- **Strict-Transport-Security**: `max-age=31536000; includeSubDomains; preload` - Enforces HTTPS -- **Content-Security-Policy**: Restricts resource loading to trusted sources -- **Referrer-Policy**: `strict-origin-when-cross-origin` - Controls referrer information -- **Permissions-Policy**: Disables unnecessary browser features - -### Customizing Security Headers - -You can customize security headers via environment variables: - -```bash -CONTENT_SECURITY_POLICY="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.example.com" -``` - ---- - -## Password Policies - -### Default Policy - -- **Minimum length**: 12 characters -- **Complexity requirements**: - - At least one uppercase letter - - At least one lowercase letter - - At least one digit - - At least one special character (!@#$%^&*(),.?":{}|<>) -- **Password history**: Last 5 passwords cannot be reused -- **Account lockout**: 5 failed attempts = 30-minute lockout - -### Configuration - -```bash -PASSWORD_MIN_LENGTH=12 -PASSWORD_REQUIRE_UPPERCASE=true -PASSWORD_REQUIRE_LOWERCASE=true -PASSWORD_REQUIRE_DIGITS=true -PASSWORD_REQUIRE_SPECIAL=true -PASSWORD_EXPIRY_DAYS=0 # 0 = no expiry, or set to 90 for 90-day rotation -PASSWORD_HISTORY_COUNT=5 -``` - -### Password Change - -Users can change their passwords at `/security/password/change`. - -Passwords are hashed using `pbkdf2:sha256` with automatic salting. - ---- - -## Two-Factor Authentication (2FA) - -TimeTracker supports TOTP-based 2FA using authenticator apps (Google Authenticator, Authy, etc.). - -### Enabling 2FA - -1. Navigate to `/security/2fa/setup` -2. Scan the QR code with your authenticator app -3. Enter the 6-digit code to verify -4. Save your backup codes securely - -### Backup Codes - -- 10 single-use backup codes are generated during setup -- Each code can be used once to bypass 2FA -- Regenerate codes at `/security/2fa/backup-codes/regenerate` - -### Login Flow with 2FA - -1. User enters username and password -2. If 2FA is enabled, redirected to `/security/2fa/verify-login` -3. User enters TOTP code or backup code -4. Login completes on successful verification - -### Disabling 2FA - -- Navigate to `/security/2fa/manage` -- Confirm with password to disable - ---- - -## Rate Limiting - -Rate limiting protects against brute-force attacks and DoS attempts. - -### Default Rate Limits - -**Authentication Endpoints:** -- Login: 5 attempts per minute -- Registration: 3 per hour -- Password reset: 3 per hour -- 2FA verification: 10 per 5 minutes - -**API Endpoints:** -- Read operations: 100 per minute -- Write operations: 60 per minute -- Delete operations: 30 per minute -- Bulk operations: 10 per minute - -**GDPR Endpoints:** -- Data export: 5 per hour -- Data deletion: 2 per hour - -### Configuration - -```bash -RATELIMIT_ENABLED=true -RATELIMIT_DEFAULT="200 per day;50 per hour" -RATELIMIT_STORAGE_URI="redis://localhost:6379" # For distributed rate limiting -``` - -### Exemptions - -The following endpoints are exempt from rate limiting: -- `/_health` -- `/health` -- `/metrics` -- `/webhooks/stripe` - ---- - -## Secrets Management - -### Secret Key Management - -**Never commit secrets to version control!** - -#### Generate Strong Secrets - -```bash -# Generate SECRET_KEY -python -c "import secrets; print(secrets.token_hex(32))" - -# Generate STRIPE secrets -# Obtain from Stripe Dashboard - -# Generate database passwords -python -c "import secrets; print(''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*') for _ in range(32)))" -``` - -#### Environment Variables - -Store secrets in environment variables or a secure vault: - -```bash -# Critical secrets -SECRET_KEY=your-generated-secret-key -DATABASE_URL=postgresql://user:password@host:5432/dbname - -# Stripe (if using billing) -STRIPE_SECRET_KEY=sk_live_... -STRIPE_PUBLISHABLE_KEY=pk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# OIDC (if using SSO) -OIDC_CLIENT_SECRET=your-oidc-secret -``` - -### Secret Rotation - -**Recommended rotation schedule:** - -- **SECRET_KEY**: Every 90 days (will invalidate sessions) -- **Database passwords**: Every 90 days -- **API keys**: Every 6 months -- **TLS certificates**: Automatic with Let's Encrypt, or 1 year for manual certs - -#### Rotating SECRET_KEY - -1. Generate new key: `python -c "import secrets; print(secrets.token_hex(32))"` -2. Update environment variable -3. Restart application -4. Users will be logged out and need to re-authenticate - ---- - -## GDPR Compliance - -### Data Export - -Organizations and users can export all their data in JSON or CSV format. - -**Organization Export (Admin only):** -- Navigate to `/gdpr/export` -- Select format (JSON or CSV) -- Download complete organization data - -**User Export:** -- Navigate to `/gdpr/export/user` -- Download personal data - -### Data Deletion - -**Organization Deletion (Admin only):** - -1. Navigate to `/gdpr/delete/request` -2. Confirm organization name -3. Deletion is scheduled with 30-day grace period -4. Cancel anytime before grace period expires at `/gdpr/delete/cancel` - -**User Account Deletion:** - -1. Navigate to `/gdpr/delete/user` -2. Confirm username and password -3. Account is immediately deleted -4. Time entries are anonymized (not deleted) for audit/billing purposes - -### Configuration - -```bash -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -GDPR_DELETION_DELAY_DAYS=30 # Grace period before permanent deletion -``` - ---- - -## Data Retention Policies - -Automatically clean up old data to comply with retention requirements. - -### Configuration - -```bash -DATA_RETENTION_DAYS=365 # Keep data for 1 year, then delete -``` - -### Retention Rules - -- **Completed time entries**: Deleted after retention period (unless in unpaid invoices) -- **Completed/cancelled tasks**: Deleted after retention period -- **Draft invoices**: Deleted after 90 days -- **Paid invoices**: Kept for 7 years (tax compliance) -- **Pending organization deletions**: Processed when grace period expires - -### Manual Cleanup - -```python -from app.utils.data_retention import DataRetentionPolicy - -# Get summary of data eligible for cleanup -summary = DataRetentionPolicy.get_retention_summary() - -# Perform cleanup -result = DataRetentionPolicy.cleanup_old_data() -``` - -### Scheduled Cleanup - -Configure a cron job to run cleanup daily: - -```bash -0 2 * * * cd /app && flask cleanup-data -``` - ---- - -## Dependency Scanning - -Automated security scanning runs on every push and pull request via GitHub Actions. - -### Enabled Scans - -1. **Safety**: Python dependency vulnerability scanning -2. **pip-audit**: Alternative Python dependency scanner -3. **Bandit**: Static code analysis for Python security issues -4. **Gitleaks**: Secret detection in git history -5. **Trivy**: Docker image vulnerability scanning -6. **CodeQL**: Advanced semantic code analysis - -### Viewing Scan Results - -- Check the **Security** tab in your GitHub repository -- Review workflow runs for detailed reports -- Artifacts are uploaded for each scan type - -### Responding to Vulnerabilities - -1. Review the vulnerability report -2. Check if it affects your deployment -3. Update the vulnerable package: `pip install --upgrade package-name` -4. Test the application -5. Commit and deploy - ---- - -## Penetration Testing - -### Pre-Deployment Checklist - -Before going live or after major changes, perform: - -#### 1. **Manual Security Review** - -- [ ] All secrets removed from code/configs -- [ ] HTTPS enabled and enforced -- [ ] Security headers verified -- [ ] Rate limiting tested -- [ ] Authentication flows tested -- [ ] Authorization checks validated -- [ ] Input validation on all forms -- [ ] SQL injection prevention verified -- [ ] XSS prevention verified - -#### 2. **Automated Scanning** - -```bash -# Run dependency scan -pip-audit --requirement requirements.txt - -# Run Bandit -bandit -r app/ -ll - -# Run OWASP ZAP (if available) -docker run -v $(pwd):/zap/wrk/:rw -t owasp/zap2docker-stable zap-baseline.py -t https://your-app.com -r zap-report.html -``` - -#### 3. **Authentication Testing** - -- [ ] Brute force protection works (account lockout) -- [ ] 2FA cannot be bypassed -- [ ] Session timeout works -- [ ] Password policy enforced -- [ ] Password reset flow secure - -#### 4. **Authorization Testing** - -- [ ] Users cannot access other organizations' data -- [ ] Non-admins cannot access admin functions -- [ ] API endpoints require authentication -- [ ] Object-level authorization enforced - -#### 5. **Data Protection Testing** - -- [ ] GDPR export includes all user data -- [ ] GDPR deletion removes all traces -- [ ] Backup codes work for 2FA -- [ ] Passwords properly hashed -- [ ] Sensitive data encrypted at rest - -### External Penetration Testing - -For production deployments, consider hiring a professional penetration testing service: - -**Recommended Services:** -- **Cobalt.io**: Crowdsourced penetration testing -- **HackerOne**: Bug bounty and pentesting platform -- **Synack**: Continuous security testing -- **Bishop Fox**: Full-service security firm - -**Testing Scope:** -- Authentication and session management -- Authorization and access control -- Data validation and injection attacks -- Business logic vulnerabilities -- API security -- Infrastructure security - ---- - -## Security Checklist - -### Development - -- [ ] No secrets in code or version control -- [ ] Input validation on all user inputs -- [ ] Output encoding for XSS prevention -- [ ] Parameterized queries for SQL injection prevention -- [ ] CSRF protection enabled -- [ ] Security headers configured -- [ ] Error messages don't leak sensitive info -- [ ] Logging doesn't include sensitive data - -### Deployment - -- [ ] HTTPS enforced -- [ ] Strong SECRET_KEY generated -- [ ] Database credentials secured -- [ ] Firewall rules configured -- [ ] Unnecessary ports closed -- [ ] SSH keys only (no password auth) -- [ ] Application running as non-root user -- [ ] Security updates enabled -- [ ] Backup strategy implemented - -### Operations - -- [ ] Regular security scans scheduled -- [ ] Vulnerability management process -- [ ] Incident response plan documented -- [ ] Security logs monitored -- [ ] Access controls reviewed quarterly -- [ ] Secrets rotated per schedule -- [ ] Dependency updates applied promptly -- [ ] Backup restoration tested - ---- - -## Incident Response - -### If a Security Breach is Suspected - -1. **Contain**: Immediately isolate affected systems -2. **Assess**: Determine scope and impact -3. **Notify**: Inform stakeholders and users if data compromised -4. **Remediate**: Fix vulnerabilities, rotate secrets -5. **Document**: Record timeline and actions taken -6. **Review**: Conduct post-mortem, improve processes - -### Reporting Security Issues - -Please report security vulnerabilities to: **security@your-domain.com** - -**Do not** create public GitHub issues for security vulnerabilities. - ---- - -## Compliance Certifications - -To achieve compliance certifications, additional measures may be required: - -### SOC 2 - -- Implement comprehensive audit logging -- Document all security policies -- Regular access reviews -- Vendor risk management - -### ISO 27001 - -- Information Security Management System (ISMS) -- Risk assessment and treatment -- Security awareness training -- Business continuity planning - -### GDPR (Covered) - -- ✅ Right to access (data export) -- ✅ Right to erasure (data deletion) -- ✅ Data retention policies -- ✅ Consent management -- ✅ Data breach notification procedures - ---- - -## References - -- [OWASP Top 10](https://owasp.org/www-project-top-ten/) -- [NIST Cybersecurity Framework](https://www.nist.gov/cyberframework) -- [GDPR Guidelines](https://gdpr.eu/) -- [CIS Controls](https://www.cisecurity.org/controls/) - ---- - -## Support - -For security questions or assistance: -- Email: security@your-domain.com -- Documentation: https://docs.your-domain.com -- Community: https://community.your-domain.com - diff --git a/env.auth.example b/env.auth.example deleted file mode 100644 index ea1919a..0000000 --- a/env.auth.example +++ /dev/null @@ -1,78 +0,0 @@ -# ============================================================================ -# Authentication & User Management Configuration -# ============================================================================ -# Copy this to your .env file and configure for your environment - -# ---------------------------------------------------------------------------- -# Email Configuration (Required for invitations and password reset) -# ---------------------------------------------------------------------------- -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@gmail.com -SMTP_PASSWORD=your-app-password -SMTP_USE_TLS=true -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker - -# ---------------------------------------------------------------------------- -# User Registration -# ---------------------------------------------------------------------------- -# Allow users to self-register (true/false) -ALLOW_SELF_REGISTER=true - -# Admin usernames (comma-separated) -ADMIN_USERNAMES=admin,superuser - -# ---------------------------------------------------------------------------- -# Session Configuration -# ---------------------------------------------------------------------------- -# Session lifetime in seconds (default: 86400 = 24 hours) -PERMANENT_SESSION_LIFETIME=86400 - -# Remember cookie duration in days -REMEMBER_COOKIE_DAYS=365 - -# Secure cookies (set to true in production with HTTPS) -SESSION_COOKIE_SECURE=false -REMEMBER_COOKIE_SECURE=false - -# ---------------------------------------------------------------------------- -# Security -# ---------------------------------------------------------------------------- -# IMPORTANT: Generate a strong random secret key for production -# Example: python -c "import secrets; print(secrets.token_hex(32))" -SECRET_KEY=dev-secret-key-change-in-production - -# ---------------------------------------------------------------------------- -# Authentication Method -# ---------------------------------------------------------------------------- -# Options: local, oidc, both -AUTH_METHOD=local - -# ---------------------------------------------------------------------------- -# Rate Limiting -# ---------------------------------------------------------------------------- -# Format: "count per period" separated by semicolons -RATELIMIT_DEFAULT=200 per day;50 per hour - -# Rate limit storage (memory:// for single instance, redis:// for distributed) -RATELIMIT_STORAGE_URI=memory:// - -# ---------------------------------------------------------------------------- -# Email Configuration Examples -# ---------------------------------------------------------------------------- - -# Gmail: -# SMTP_HOST=smtp.gmail.com -# SMTP_PORT=587 -# SMTP_USERNAME=your-email@gmail.com -# SMTP_PASSWORD=your-app-password # Generate at https://myaccount.google.com/apppasswords -# SMTP_USE_TLS=true - -# SendGrid: -# SMTP_HOST=smtp.sendgrid.net -# SMTP_PORT=587 -# SMTP_USERNAME=apikey -# SMTP_PASSWORD=your-sendgrid-api-key -# SMTP_USE_TLS=true - diff --git a/env.example b/env.example index 9766a71..12f2ddc 100644 --- a/env.example +++ b/env.example @@ -59,30 +59,3 @@ WTF_CSRF_TIME_LIMIT=3600 # Logging LOG_LEVEL=INFO LOG_FILE=/data/logs/timetracker.log - -# Security settings -FORCE_HTTPS=true # Redirect HTTP to HTTPS (disable for local dev) -REMEMBER_COOKIE_SECURE=false # Set to 'true' in production with HTTPS -CONTENT_SECURITY_POLICY= # Custom CSP if needed (optional) - -# Password policy -PASSWORD_MIN_LENGTH=12 -PASSWORD_REQUIRE_UPPERCASE=true -PASSWORD_REQUIRE_LOWERCASE=true -PASSWORD_REQUIRE_DIGITS=true -PASSWORD_REQUIRE_SPECIAL=true -PASSWORD_EXPIRY_DAYS=0 # 0 = no expiry, or set to 90 for 90-day rotation -PASSWORD_HISTORY_COUNT=5 - -# Rate limiting -RATELIMIT_ENABLED=true -RATELIMIT_DEFAULT=200 per day;50 per hour -RATELIMIT_STORAGE_URI=memory:// # Use redis://localhost:6379 for production - -# GDPR compliance -GDPR_EXPORT_ENABLED=true -GDPR_DELETION_ENABLED=true -GDPR_DELETION_DELAY_DAYS=30 # Grace period before permanent deletion - -# Data retention -DATA_RETENTION_DAYS=0 # 0 = no automatic deletion, or set to 365 for 1-year retention \ No newline at end of file diff --git a/env.local-test.example b/env.local-test.example index 13176bc..dbfad34 100644 --- a/env.local-test.example +++ b/env.local-test.example @@ -33,3 +33,5 @@ REMEMBER_COOKIE_SECURE=false FLASK_ENV=development FLASK_DEBUG=true +# License server (disabled for local testing) +LICENSE_SERVER_ENABLED=false diff --git a/env.stripe.example b/env.stripe.example deleted file mode 100644 index 0b1bdd5..0000000 --- a/env.stripe.example +++ /dev/null @@ -1,170 +0,0 @@ -# ============================================================================ -# STRIPE BILLING CONFIGURATION -# ============================================================================ -# Copy this file to .env and fill in your actual Stripe credentials -# -# Get your keys from: https://dashboard.stripe.com/apikeys -# Create products at: https://dashboard.stripe.com/products -# ============================================================================ - -# ---------------------------------------------------------------------------- -# Stripe API Keys (REQUIRED) -# ---------------------------------------------------------------------------- -# Test keys (for development - start with sk_test_ and pk_test_) -STRIPE_SECRET_KEY=sk_test_your_secret_key_here -STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here - -# Production keys (for production - start with sk_live_ and pk_live_) -# STRIPE_SECRET_KEY=sk_live_your_live_secret_key_here -# STRIPE_PUBLISHABLE_KEY=pk_live_your_live_publishable_key_here - -# ---------------------------------------------------------------------------- -# Webhook Secret (REQUIRED for webhook verification) -# ---------------------------------------------------------------------------- -# Get from Stripe Dashboard → Developers → Webhooks → [Your endpoint] → Signing secret -# Or use `stripe listen --print-secret` for local development -STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here - -# ---------------------------------------------------------------------------- -# Product Price IDs (REQUIRED) -# ---------------------------------------------------------------------------- -# Create products in Stripe Dashboard → Products -# Copy the Price ID (starts with price_) for each product - -# Single User Plan (€5/month, quantity=1) -STRIPE_SINGLE_USER_PRICE_ID=price_xxxxxxxxxxxxxxxxxxxxx - -# Team Plan (€6/user/month, quantity=number of seats) -STRIPE_TEAM_PRICE_ID=price_xxxxxxxxxxxxxxxxxxxxx - -# ---------------------------------------------------------------------------- -# Trial Configuration (OPTIONAL) -# ---------------------------------------------------------------------------- -# Enable/disable free trial periods -STRIPE_ENABLE_TRIALS=true - -# Number of trial days (default: 14) -STRIPE_TRIAL_DAYS=14 - -# ---------------------------------------------------------------------------- -# Proration Settings (OPTIONAL) -# ---------------------------------------------------------------------------- -# Enable proration when seats are added/removed mid-billing cycle -# If enabled, customers are charged/credited proportionally -STRIPE_ENABLE_PRORATION=true - -# ---------------------------------------------------------------------------- -# Tax Configuration (OPTIONAL) -# ---------------------------------------------------------------------------- -# Tax behavior for prices -# - 'exclusive': Tax added on top of price (recommended) -# - 'inclusive': Tax included in price -STRIPE_TAX_BEHAVIOR=exclusive - -# ============================================================================ -# SETUP INSTRUCTIONS -# ============================================================================ -# -# 1. CREATE STRIPE ACCOUNT -# - Sign up at https://stripe.com -# - Complete account verification -# -# 2. GET API KEYS -# - Go to: https://dashboard.stripe.com/apikeys -# - Copy both Publishable and Secret keys -# - Use test keys for development (sk_test_* and pk_test_*) -# -# 3. CREATE PRODUCTS -# - Go to: https://dashboard.stripe.com/products -# - Create "TimeTracker Single User" product: -# * Price: €5.00 -# * Billing: Monthly recurring -# * Currency: EUR -# - Create "TimeTracker Team" product: -# * Price: €6.00 -# * Billing: Monthly recurring -# * Currency: EUR -# * Enable quantity-based pricing -# - Copy the Price IDs (price_*) -# -# 4. CONFIGURE WEBHOOKS -# - Go to: https://dashboard.stripe.com/webhooks -# - Add endpoint: https://your-domain.com/billing/webhooks/stripe -# - Select events: -# * invoice.paid -# * invoice.payment_failed -# * invoice.payment_action_required -# * customer.subscription.created -# * customer.subscription.updated -# * customer.subscription.deleted -# * customer.subscription.trial_will_end -# - Copy the Signing Secret (whsec_*) -# -# 5. ENABLE CUSTOMER PORTAL -# - Go to: https://dashboard.stripe.com/settings/billing/portal -# - Click "Activate test link" -# - Enable features: -# * Update subscription -# * Cancel subscription -# * Update payment method -# * View invoice history -# -# 6. ENABLE STRIPE TAX (for EU VAT) -# - Go to: https://dashboard.stripe.com/settings/tax -# - Enable automatic tax calculation -# - Add your business location -# -# 7. RUN DATABASE MIGRATION -# psql -U timetracker -d timetracker -f migrations/add_stripe_billing_fields.sql -# -# 8. RESTART APPLICATION -# The application will now use Stripe for billing -# -# ============================================================================ -# LOCAL DEVELOPMENT WITH WEBHOOKS -# ============================================================================ -# -# Install Stripe CLI: -# brew install stripe/stripe-cli/stripe (macOS) -# scoop install stripe (Windows) -# -# Login to Stripe: -# stripe login -# -# Forward webhooks to localhost: -# stripe listen --forward-to localhost:5000/billing/webhooks/stripe -# -# This will output a webhook secret - add it to STRIPE_WEBHOOK_SECRET above -# -# Test webhooks: -# stripe trigger invoice.paid -# stripe trigger invoice.payment_failed -# stripe trigger customer.subscription.updated -# -# ============================================================================ -# TEST CARDS (Test Mode Only) -# ============================================================================ -# -# Success: 4242 4242 4242 4242 -# Decline: 4000 0000 0000 0002 -# 3D Secure: 4000 0025 0000 3155 -# -# Use any future expiry date (e.g., 12/34) -# Use any 3-digit CVC (e.g., 123) -# Use any ZIP code (e.g., 12345) -# -# More test cards: https://stripe.com/docs/testing -# -# ============================================================================ -# SECURITY NOTES -# ============================================================================ -# -# ⚠️ NEVER commit this file with real credentials to version control -# ⚠️ Use different keys for test and production environments -# ⚠️ Rotate keys regularly in production -# ⚠️ Store production keys in secure secrets manager (e.g., AWS Secrets Manager) -# ⚠️ Enable Stripe Radar for fraud protection -# ⚠️ Monitor webhook logs for suspicious activity -# -# ============================================================================ - diff --git a/env.testing b/env.testing deleted file mode 100644 index fd20ed7..0000000 --- a/env.testing +++ /dev/null @@ -1,166 +0,0 @@ -# ============================================================================ -# TimeTracker Environment Configuration - TESTING -# ============================================================================ -# Complete environment file for testing the TimeTracker application -# Fill in the TODO sections with your actual credentials -# Copy this file to .env before using: cp env.testing .env -# ============================================================================ - -# ---------------------------------------------------------------------------- -# Flask Settings -# ---------------------------------------------------------------------------- -SECRET_KEY=dev-test-secret-key-change-in-production-12345678 -FLASK_ENV=development -FLASK_DEBUG=true - -# ---------------------------------------------------------------------------- -# Database Settings -# ---------------------------------------------------------------------------- -DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker -POSTGRES_DB=timetracker -POSTGRES_USER=timetracker -POSTGRES_PASSWORD=timetracker -POSTGRES_HOST=db - -# ---------------------------------------------------------------------------- -# Session Settings -# ---------------------------------------------------------------------------- -SESSION_COOKIE_SECURE=false -SESSION_COOKIE_HTTPONLY=true -PERMANENT_SESSION_LIFETIME=86400 -REMEMBER_COOKIE_DAYS=365 -REMEMBER_COOKIE_SECURE=false - -# ---------------------------------------------------------------------------- -# Application Settings -# ---------------------------------------------------------------------------- -TZ=Europe/Brussels -CURRENCY=EUR -ROUNDING_MINUTES=1 -SINGLE_ACTIVE_TIMER=true -IDLE_TIMEOUT_MINUTES=30 - -# ---------------------------------------------------------------------------- -# User Management -# ---------------------------------------------------------------------------- -ALLOW_SELF_REGISTER=true -ADMIN_USERNAMES=admin - -# ---------------------------------------------------------------------------- -# Authentication -# ---------------------------------------------------------------------------- -# Options: local | oidc | both -AUTH_METHOD=local - -# ---------------------------------------------------------------------------- -# Rate Limiting -# ---------------------------------------------------------------------------- -RATELIMIT_DEFAULT=200 per day;50 per hour -RATELIMIT_STORAGE_URI=memory:// - -# ---------------------------------------------------------------------------- -# Email Configuration (SMTP) -# ---------------------------------------------------------------------------- -# TODO: Fill in your SMTP credentials for email functionality -# For Gmail, generate an app password at: https://myaccount.google.com/apppasswords -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@gmail.com -SMTP_PASSWORD=your-app-password-here -SMTP_USE_TLS=true -SMTP_FROM_EMAIL=noreply@timetracker.com -SMTP_FROM_NAME=TimeTracker - -# Alternative SMTP providers: -# SendGrid: -# SMTP_HOST=smtp.sendgrid.net -# SMTP_PORT=587 -# SMTP_USERNAME=apikey -# SMTP_PASSWORD=your-sendgrid-api-key - -# ---------------------------------------------------------------------------- -# Stripe Billing Configuration -# ---------------------------------------------------------------------------- -# TODO: Get your test keys from: https://dashboard.stripe.com/apikeys -STRIPE_SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE -STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE - -# TODO: Get webhook secret from running: stripe listen --forward-to localhost:5000/billing/webhooks/stripe -STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET_HERE - -# TODO: Create products in Stripe Dashboard and paste Price IDs here -# Single User Plan (€5/month) -STRIPE_SINGLE_USER_PRICE_ID=price_YOUR_SINGLE_USER_PRICE_ID - -# Team Plan (€6/user/month) -STRIPE_TEAM_PRICE_ID=price_YOUR_TEAM_PRICE_ID - -# Stripe Optional Settings -STRIPE_ENABLE_TRIALS=true -STRIPE_TRIAL_DAYS=14 -STRIPE_ENABLE_PRORATION=true -STRIPE_TAX_BEHAVIOR=exclusive - -# ---------------------------------------------------------------------------- -# Backup Settings -# ---------------------------------------------------------------------------- -BACKUP_RETENTION_DAYS=30 -BACKUP_TIME=02:00 - -# ---------------------------------------------------------------------------- -# File Upload Settings -# ---------------------------------------------------------------------------- -MAX_CONTENT_LENGTH=16777216 -UPLOAD_FOLDER=/data/uploads - -# ---------------------------------------------------------------------------- -# CSRF Protection -# ---------------------------------------------------------------------------- -WTF_CSRF_ENABLED=false -WTF_CSRF_TIME_LIMIT=3600 - -# ---------------------------------------------------------------------------- -# Logging -# ---------------------------------------------------------------------------- -LOG_LEVEL=INFO -LOG_FILE=/data/logs/timetracker.log - -# ============================================================================ -# SETUP CHECKLIST -# ============================================================================ -# -# ✅ 1. Database is configured (default PostgreSQL settings) -# ❌ 2. TODO: Configure SMTP settings (required for invitations/password reset) -# ❌ 3. TODO: Add Stripe API keys (required for billing) -# ❌ 4. TODO: Create Stripe products and add Price IDs -# ❌ 5. TODO: Run Stripe CLI for webhook testing -# ❌ 6. TODO: Run database migration: -# psql -U timetracker -d timetracker -f migrations/add_stripe_billing_fields.sql -# -# ============================================================================ -# TESTING STRIPE -# ============================================================================ -# -# Test Card Numbers (Test Mode Only): -# - Success: 4242 4242 4242 4242 -# - Decline: 4000 0000 0000 0002 -# - 3D Secure: 4000 0025 0000 3155 -# -# Use any future expiry date (e.g., 12/34) -# Use any 3-digit CVC (e.g., 123) -# Use any ZIP code (e.g., 12345) -# -# Start webhook listener: -# stripe listen --forward-to localhost:5000/billing/webhooks/stripe -# -# ============================================================================ -# SECURITY NOTES -# ============================================================================ -# -# ⚠️ This is a TESTING configuration - do not use in production -# ⚠️ Never commit this file with real credentials to version control -# ⚠️ Generate a strong SECRET_KEY for production -# ⚠️ Use environment-specific configurations for production -# -# ============================================================================ - diff --git a/fix_migration_chain.py b/fix_migration_chain.py deleted file mode 100644 index deaf9cc..0000000 --- a/fix_migration_chain.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Fix migration chain by stamping migration 018 if it hasn't been applied. - -This script checks if the organizations table exists (created by migration 018) -and if so, stamps the database to register migration 018 in alembic_version. -""" -import sys -from sqlalchemy import create_engine, inspect, text -from app import create_app, db -from app.config import Config - -def fix_migration_chain(): - """Fix the migration chain by stamping migration 018 if needed.""" - - # Create app to get database connection - app = create_app() - - with app.app_context(): - inspector = inspect(db.engine) - tables = inspector.get_table_names() - - print("Checking database state...") - print(f"Found {len(tables)} tables in database") - - # Check if organizations table exists (created by migration 018) - if 'organizations' in tables: - print("✓ Organizations table exists (migration 018 was applied)") - - # Check if migration 018 is in alembic_version - try: - result = db.session.execute( - text("SELECT version_num FROM alembic_version") - ).fetchone() - - if result: - current_version = result[0] - print(f"Current migration version: {current_version}") - - if current_version == '017': - print("\n⚠ Migration 018 tables exist but not stamped!") - print("Stamping database with migration 018...") - - # Update alembic_version to 018 - db.session.execute( - text("UPDATE alembic_version SET version_num = '018'") - ) - db.session.commit() - - print("✓ Database stamped with migration 018") - print("\nNow you can run: flask db upgrade") - print("This will apply migration 019 (auth features)") - return True - - elif current_version == '018': - print("✓ Migration 018 is already stamped") - print("\nYou can now run: flask db upgrade") - print("This will apply migration 019 (auth features)") - return True - - elif current_version == '019_add_auth_features' or current_version == '019': - print("✓ All migrations are up to date!") - return True - - else: - print(f"⚠ Unexpected migration version: {current_version}") - print("Please check your migration status manually") - return False - - else: - print("⚠ No migration version found in alembic_version table") - print("Initializing with migration 018...") - - db.session.execute( - text("INSERT INTO alembic_version (version_num) VALUES ('018')") - ) - db.session.commit() - - print("✓ Database initialized with migration 018") - print("\nNow you can run: flask db upgrade") - return True - - except Exception as e: - print(f"Error checking alembic_version: {e}") - - # Try to create alembic_version table if it doesn't exist - print("Creating alembic_version table...") - db.session.execute( - text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL, CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))") - ) - db.session.execute( - text("INSERT INTO alembic_version (version_num) VALUES ('018')") - ) - db.session.commit() - - print("✓ Created alembic_version table and stamped with migration 018") - print("\nNow you can run: flask db upgrade") - return True - - else: - print("⚠ Organizations table doesn't exist") - print("You need to run migrations from scratch") - print("\nRun: flask db upgrade") - return False - -if __name__ == '__main__': - try: - success = fix_migration_chain() - sys.exit(0 if success else 1) - except Exception as e: - print(f"\n❌ Error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - diff --git a/fix_stripe_lazy_init.py b/fix_stripe_lazy_init.py deleted file mode 100644 index decc3f2..0000000 --- a/fix_stripe_lazy_init.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Fix stripe_service.py to add lazy initialization calls -""" -import re - -# Read the file -with open('app/utils/stripe_service.py', 'r', encoding='utf-8') as f: - content = f.read() - -# Add self._ensure_initialized() before each "if not self.is_configured():" check -# that doesn't already have it -content = re.sub( - r'(\n )(if not self\.is_configured\(\):)', - r'\1self._ensure_initialized()\n \2', - content -) - -# Also add it to methods that check organization.stripe_customer_id directly -content = re.sub( - r'(\n )(if not organization\.stripe_customer_id:)', - r'\1self._ensure_initialized()\n \2', - content -) - -# Write back -with open('app/utils/stripe_service.py', 'w', encoding='utf-8') as f: - f.write(content) - -print("✓ Fixed stripe_service.py") -print("Added lazy initialization calls to all Stripe methods") - diff --git a/migrations/add_stripe_billing_fields.sql b/migrations/add_stripe_billing_fields.sql deleted file mode 100644 index e994855..0000000 --- a/migrations/add_stripe_billing_fields.sql +++ /dev/null @@ -1,84 +0,0 @@ --- Migration: Add Stripe Billing Fields --- Description: Adds billing-related fields to organizations table and creates subscription_events table --- Date: 2025-10-07 - --- Add new billing fields to organizations table -ALTER TABLE organizations -ADD COLUMN IF NOT EXISTS stripe_price_id VARCHAR(100), -ADD COLUMN IF NOT EXISTS subscription_quantity INTEGER DEFAULT 1 NOT NULL, -ADD COLUMN IF NOT EXISTS next_billing_date TIMESTAMP, -ADD COLUMN IF NOT EXISTS billing_issue_detected_at TIMESTAMP, -ADD COLUMN IF NOT EXISTS last_billing_email_sent_at TIMESTAMP; - --- Update existing stripe_subscription_status column to allow more statuses -COMMENT ON COLUMN organizations.stripe_subscription_status IS 'Subscription status: active, trialing, past_due, canceled, incomplete, incomplete_expired, unpaid'; - --- Create subscription_events table -CREATE TABLE IF NOT EXISTS subscription_events ( - id SERIAL PRIMARY KEY, - organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - - -- Event details - event_type VARCHAR(100) NOT NULL, - event_id VARCHAR(100) UNIQUE, - - -- Stripe resource references - stripe_customer_id VARCHAR(100), - stripe_subscription_id VARCHAR(100), - stripe_invoice_id VARCHAR(100), - stripe_payment_intent_id VARCHAR(100), - - -- Event data and status - status VARCHAR(50), - previous_status VARCHAR(50), - quantity INTEGER, - previous_quantity INTEGER, - amount NUMERIC(10, 2), - currency VARCHAR(3), - - -- Processing info - processed BOOLEAN DEFAULT FALSE NOT NULL, - processing_error TEXT, - retry_count INTEGER DEFAULT 0 NOT NULL, - - -- Full event payload (for debugging) - raw_payload TEXT, - - -- Metadata - notes TEXT, - created_by INTEGER REFERENCES users(id), - - -- Timestamps - event_timestamp TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT NOW() NOT NULL, - processed_at TIMESTAMP, - - -- Indexes for performance - CONSTRAINT subscription_events_org_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE -); - --- Create indexes for subscription_events -CREATE INDEX IF NOT EXISTS idx_subscription_events_org_id ON subscription_events(organization_id); -CREATE INDEX IF NOT EXISTS idx_subscription_events_event_type ON subscription_events(event_type); -CREATE INDEX IF NOT EXISTS idx_subscription_events_event_id ON subscription_events(event_id); -CREATE INDEX IF NOT EXISTS idx_subscription_events_customer_id ON subscription_events(stripe_customer_id); -CREATE INDEX IF NOT EXISTS idx_subscription_events_subscription_id ON subscription_events(stripe_subscription_id); -CREATE INDEX IF NOT EXISTS idx_subscription_events_event_timestamp ON subscription_events(event_timestamp); -CREATE INDEX IF NOT EXISTS idx_subscription_events_processed ON subscription_events(processed) WHERE NOT processed; - --- Add comments to tables and columns for documentation -COMMENT ON TABLE subscription_events IS 'Tracks all Stripe subscription lifecycle events for audit and debugging'; -COMMENT ON COLUMN subscription_events.event_type IS 'Type of Stripe event (e.g., invoice.paid, customer.subscription.updated)'; -COMMENT ON COLUMN subscription_events.event_id IS 'Unique Stripe event ID'; -COMMENT ON COLUMN subscription_events.processed IS 'Whether this event has been successfully processed'; -COMMENT ON COLUMN subscription_events.raw_payload IS 'Full JSON payload from Stripe webhook'; - -COMMENT ON COLUMN organizations.stripe_price_id IS 'Current Stripe price ID being used'; -COMMENT ON COLUMN organizations.subscription_quantity IS 'Number of seats/users in subscription'; -COMMENT ON COLUMN organizations.next_billing_date IS 'Date of next billing cycle'; -COMMENT ON COLUMN organizations.billing_issue_detected_at IS 'Timestamp when payment failure was detected'; -COMMENT ON COLUMN organizations.last_billing_email_sent_at IS 'Last time billing notification email was sent (for dunning management)'; - --- Migration complete -SELECT 'Stripe billing fields migration completed successfully' AS status; - diff --git a/migrations/enable_row_level_security.sql b/migrations/enable_row_level_security.sql deleted file mode 100644 index c723e67..0000000 --- a/migrations/enable_row_level_security.sql +++ /dev/null @@ -1,329 +0,0 @@ --- ============================================ --- Row Level Security (RLS) for Multi-Tenancy --- ============================================ --- --- This script enables PostgreSQL Row Level Security to enforce --- organization-level data isolation at the database level. --- --- IMPORTANT: This provides defense-in-depth security, ensuring that --- even if application-level checks fail, data isolation is maintained. --- --- Usage: --- psql -U timetracker -d timetracker -f enable_row_level_security.sql --- --- Prerequisites: --- - PostgreSQL 9.5+ --- - The organization_id column must exist on all tables --- - The current_setting function is used to pass organization_id --- --- ============================================ - -BEGIN; - --- ============================================ --- Helper function to get current organization from context --- ============================================ - -CREATE OR REPLACE FUNCTION current_organization_id() -RETURNS INTEGER AS $$ -BEGIN - -- Try to get organization_id from current_setting - -- This will be set by the application for each request - RETURN NULLIF(current_setting('app.current_organization_id', TRUE), '')::INTEGER; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION current_organization_id() IS -'Returns the current organization ID from the session variable set by the application'; - - --- ============================================ --- Function to check if user is a super admin --- (Super admins can access data across organizations) --- ============================================ - -CREATE OR REPLACE FUNCTION is_super_admin() -RETURNS BOOLEAN AS $$ -BEGIN - -- Check if current role has super admin flag - RETURN COALESCE(current_setting('app.is_super_admin', TRUE)::BOOLEAN, FALSE); -EXCEPTION - WHEN OTHERS THEN - RETURN FALSE; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION is_super_admin() IS -'Returns true if the current session user is a super admin'; - - --- ============================================ --- Create RLS policies for each tenant-scoped table --- ============================================ - --- Organizations table --- ============================================ -ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; - --- Policy: Users can only see organizations they are members of -CREATE POLICY organizations_tenant_isolation ON organizations - FOR ALL - USING ( - is_super_admin() OR - id = current_organization_id() OR - id IN ( - SELECT organization_id - FROM memberships - WHERE user_id = current_user_id() - AND status = 'active' - ) - ); - -COMMENT ON POLICY organizations_tenant_isolation ON organizations IS -'Users can only access organizations they are members of, unless they are super admins'; - - --- Memberships table --- ============================================ -ALTER TABLE memberships ENABLE ROW LEVEL SECURITY; - --- Policy: Users can only see memberships for their organizations -CREATE POLICY memberships_tenant_isolation ON memberships - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY memberships_tenant_isolation ON memberships IS -'Users can only access memberships within their current organization'; - - --- Projects table --- ============================================ -ALTER TABLE projects ENABLE ROW LEVEL SECURITY; - -CREATE POLICY projects_tenant_isolation ON projects - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY projects_tenant_isolation ON projects IS -'Users can only access projects within their current organization'; - - --- Clients table --- ============================================ -ALTER TABLE clients ENABLE ROW LEVEL SECURITY; - -CREATE POLICY clients_tenant_isolation ON clients - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY clients_tenant_isolation ON clients IS -'Users can only access clients within their current organization'; - - --- Time Entries table --- ============================================ -ALTER TABLE time_entries ENABLE ROW LEVEL SECURITY; - -CREATE POLICY time_entries_tenant_isolation ON time_entries - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY time_entries_tenant_isolation ON time_entries IS -'Users can only access time entries within their current organization'; - - --- Tasks table --- ============================================ -ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tasks_tenant_isolation ON tasks - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY tasks_tenant_isolation ON tasks IS -'Users can only access tasks within their current organization'; - - --- Invoices table --- ============================================ -ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; - -CREATE POLICY invoices_tenant_isolation ON invoices - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY invoices_tenant_isolation ON invoices IS -'Users can only access invoices within their current organization'; - - --- Comments table --- ============================================ -ALTER TABLE comments ENABLE ROW LEVEL SECURITY; - -CREATE POLICY comments_tenant_isolation ON comments - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY comments_tenant_isolation ON comments IS -'Users can only access comments within their current organization'; - - --- Focus Sessions table --- ============================================ -ALTER TABLE focus_sessions ENABLE ROW LEVEL SECURITY; - -CREATE POLICY focus_sessions_tenant_isolation ON focus_sessions - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY focus_sessions_tenant_isolation ON focus_sessions IS -'Users can only access focus sessions within their current organization'; - - --- Saved Filters table --- ============================================ -ALTER TABLE saved_filters ENABLE ROW LEVEL SECURITY; - -CREATE POLICY saved_filters_tenant_isolation ON saved_filters - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY saved_filters_tenant_isolation ON saved_filters IS -'Users can only access saved filters within their current organization'; - - --- Task Activities table --- ============================================ -ALTER TABLE task_activities ENABLE ROW LEVEL SECURITY; - -CREATE POLICY task_activities_tenant_isolation ON task_activities - FOR ALL - USING ( - is_super_admin() OR - organization_id = current_organization_id() - ); - -COMMENT ON POLICY task_activities_tenant_isolation ON task_activities IS -'Users can only access task activities within their current organization'; - - --- ============================================ --- Create helper function for application to set organization context --- ============================================ - -CREATE OR REPLACE FUNCTION set_organization_context(org_id INTEGER, is_admin BOOLEAN DEFAULT FALSE) -RETURNS VOID AS $$ -BEGIN - -- Set the organization ID for the current transaction - PERFORM set_config('app.current_organization_id', org_id::TEXT, FALSE); - - -- Set super admin flag - PERFORM set_config('app.is_super_admin', is_admin::TEXT, FALSE); -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION set_organization_context(INTEGER, BOOLEAN) IS -'Sets the organization context for the current transaction. Call this at the start of each request.'; - - --- ============================================ --- Create helper function to clear organization context --- ============================================ - -CREATE OR REPLACE FUNCTION clear_organization_context() -RETURNS VOID AS $$ -BEGIN - PERFORM set_config('app.current_organization_id', '', FALSE); - PERFORM set_config('app.is_super_admin', 'false', FALSE); -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION clear_organization_context() IS -'Clears the organization context. Call this at the end of each request.'; - - --- ============================================ --- Verification Query --- ============================================ - --- To verify RLS is working, run these queries: --- --- -- Set context for organization 1 --- SELECT set_organization_context(1); --- SELECT * FROM projects; -- Should only show org 1 projects --- --- -- Set context for organization 2 --- SELECT set_organization_context(2); --- SELECT * FROM projects; -- Should only show org 2 projects --- --- -- Clear context --- SELECT clear_organization_context(); --- SELECT * FROM projects; -- Should show no projects (unless super admin) - - -COMMIT; - --- ============================================ --- IMPORTANT NOTES --- ============================================ --- --- 1. Application Integration: --- The application must call set_organization_context() at the start --- of each request to set the proper organization context. --- --- 2. Connection Pooling: --- If using connection pooling, ensure context is set for each --- request and cleared afterwards, or use transaction-level settings. --- --- 3. Superuser Access: --- Database superusers bypass RLS. Use dedicated application users --- with limited privileges for normal operations. --- --- 4. Performance: --- RLS policies are applied to every query. Ensure proper indexes --- exist on organization_id columns (already created in migration). --- --- 5. Testing: --- Always test RLS policies thoroughly before deploying to production. --- Verify that users cannot access data from other organizations. --- --- ============================================ - -\echo '✅ Row Level Security policies created successfully!' -\echo '' -\echo 'Next steps:' -\echo '1. Update application code to call set_organization_context() at request start' -\echo '2. Test RLS policies with different organization contexts' -\echo '3. Monitor query performance with RLS enabled' -\echo '4. Consider creating application-specific database users (not superusers)' - diff --git a/migrations/fix_migration_018.sql b/migrations/fix_migration_018.sql deleted file mode 100644 index 3cf6c03..0000000 --- a/migrations/fix_migration_018.sql +++ /dev/null @@ -1,168 +0,0 @@ --- Manual fix for migration 018 if it partially failed --- Run this if the migration added columns but failed on indexes/constraints - --- First, check if we're at the right migration state --- The migration should have added organization_id to all tables - -BEGIN; - --- ======================================== --- Check what exists --- ======================================== -DO $$ -BEGIN - -- Check if organizations table exists - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'organizations') THEN - RAISE NOTICE '✓ Organizations table exists'; - ELSE - RAISE EXCEPTION 'Organizations table does not exist - migration did not complete step 1'; - END IF; - - -- Check if memberships table exists - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'memberships') THEN - RAISE NOTICE '✓ Memberships table exists'; - ELSE - RAISE EXCEPTION 'Memberships table does not exist - migration did not complete step 2'; - END IF; - - -- Check if organization_id was added to projects - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'projects' AND column_name = 'organization_id') THEN - RAISE NOTICE '✓ Projects.organization_id exists'; - ELSE - RAISE EXCEPTION 'Projects.organization_id does not exist - migration did not complete step 5'; - END IF; -END $$; - --- ======================================== --- Create missing indexes (safe - will skip if exists) --- ======================================== - --- Helper function to safely create index -CREATE OR REPLACE FUNCTION create_index_if_not_exists(index_name TEXT, table_name TEXT, columns TEXT) -RETURNS VOID AS $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = index_name) THEN - EXECUTE format('CREATE INDEX %I ON %I (%s)', index_name, table_name, columns); - RAISE NOTICE 'Created index: %', index_name; - ELSE - RAISE NOTICE 'Index already exists: %', index_name; - END IF; -END; -$$ LANGUAGE plpgsql; - --- Projects indexes -SELECT create_index_if_not_exists('idx_projects_org_status', 'projects', 'organization_id, status'); -SELECT create_index_if_not_exists('idx_projects_org_client', 'projects', 'organization_id, client_id'); - --- Clients indexes -SELECT create_index_if_not_exists('idx_clients_org_status', 'clients', 'organization_id, status'); - --- Time entries indexes -SELECT create_index_if_not_exists('idx_time_entries_org_user', 'time_entries', 'organization_id, user_id'); -SELECT create_index_if_not_exists('idx_time_entries_org_project', 'time_entries', 'organization_id, project_id'); -SELECT create_index_if_not_exists('idx_time_entries_org_dates', 'time_entries', 'organization_id, start_time, end_time'); - --- Tasks indexes -SELECT create_index_if_not_exists('idx_tasks_org_project', 'tasks', 'organization_id, project_id'); -SELECT create_index_if_not_exists('idx_tasks_org_status', 'tasks', 'organization_id, status'); -SELECT create_index_if_not_exists('idx_tasks_org_assigned', 'tasks', 'organization_id, assigned_to'); - --- Invoices indexes -SELECT create_index_if_not_exists('idx_invoices_org_status', 'invoices', 'organization_id, status'); -SELECT create_index_if_not_exists('idx_invoices_org_client', 'invoices', 'organization_id, client_id'); - --- Comments indexes -SELECT create_index_if_not_exists('idx_comments_org_project', 'comments', 'organization_id, project_id'); -SELECT create_index_if_not_exists('idx_comments_org_task', 'comments', 'organization_id, task_id'); -SELECT create_index_if_not_exists('idx_comments_org_user', 'comments', 'organization_id, user_id'); - --- Drop helper function -DROP FUNCTION create_index_if_not_exists(TEXT, TEXT, TEXT); - --- ======================================== --- Update unique constraints for clients --- ======================================== -DO $$ -BEGIN - -- Drop old unique constraint on clients.name if it exists - IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'clients_name_key') THEN - ALTER TABLE clients DROP CONSTRAINT clients_name_key; - RAISE NOTICE 'Dropped old constraint: clients_name_key'; - ELSE - RAISE NOTICE 'Constraint clients_name_key does not exist (already dropped or never existed)'; - END IF; - - -- Create new unique constraint per organization - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_clients_org_name') THEN - ALTER TABLE clients ADD CONSTRAINT uq_clients_org_name UNIQUE (organization_id, name); - RAISE NOTICE 'Created constraint: uq_clients_org_name'; - ELSE - RAISE NOTICE 'Constraint uq_clients_org_name already exists'; - END IF; -END $$; - --- ======================================== --- Update unique constraints for invoices --- ======================================== -DO $$ -BEGIN - -- Drop old unique constraint on invoices.invoice_number if it exists - IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invoices_invoice_number_key') THEN - ALTER TABLE invoices DROP CONSTRAINT invoices_invoice_number_key; - RAISE NOTICE 'Dropped old constraint: invoices_invoice_number_key'; - ELSE - RAISE NOTICE 'Constraint invoices_invoice_number_key does not exist (already dropped or never existed)'; - END IF; - - -- Create new unique constraint per organization - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_invoices_org_number') THEN - ALTER TABLE invoices ADD CONSTRAINT uq_invoices_org_number UNIQUE (organization_id, invoice_number); - RAISE NOTICE 'Created constraint: uq_invoices_org_number'; - ELSE - RAISE NOTICE 'Constraint uq_invoices_org_number already exists'; - END IF; -END $$; - --- ======================================== --- Mark migration as complete --- ======================================== -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM alembic_version WHERE version_num = '018') THEN - RAISE NOTICE '✓ Migration 018 already marked as complete'; - ELSE - UPDATE alembic_version SET version_num = '018'; - RAISE NOTICE '✓ Marked migration 018 as complete'; - END IF; -END $$; - -COMMIT; - --- ======================================== --- Verification --- ======================================== -SELECT 'Migration 018 fix completed successfully!' as status; - --- Show current state -SELECT 'Alembic version: ' || version_num as info FROM alembic_version; - --- Count organizations and memberships -SELECT 'Organizations: ' || COUNT(*)::TEXT as info FROM organizations; -SELECT 'Memberships: ' || COUNT(*)::TEXT as info FROM memberships; - --- Verify organization_id columns exist -SELECT - 'Table: ' || table_name || ' has organization_id: ' || - CASE WHEN EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = t.table_name - AND column_name = 'organization_id' - ) THEN 'YES' ELSE 'NO' END as verification -FROM ( - SELECT unnest(ARRAY['projects', 'clients', 'time_entries', 'tasks', - 'invoices', 'comments', 'focus_sessions', - 'saved_filters', 'task_activities']) as table_name -) t; - diff --git a/migrations/versions/018_add_multi_tenant_support.py b/migrations/versions/018_add_multi_tenant_support.py deleted file mode 100644 index 59b1894..0000000 --- a/migrations/versions/018_add_multi_tenant_support.py +++ /dev/null @@ -1,392 +0,0 @@ -"""add multi-tenant support with organizations and memberships - -Revision ID: 018 -Revises: 017 -Create Date: 2025-10-07 00:00:00 -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import text -from datetime import datetime - - -# revision identifiers, used by Alembic. -revision = '018' -down_revision = '017' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Add multi-tenant support with organizations and memberships. - - This migration: - 1. Creates organizations table - 2. Creates memberships table - 3. Creates a default organization - 4. Adds organization_id to all tenant-scoped tables - 5. Migrates existing data to the default organization - 6. Creates memberships for all existing users - """ - bind = op.get_bind() - inspector = sa.inspect(bind) - existing_tables = inspector.get_table_names() - - # ======================================== - # Step 1: Create organizations table - # ======================================== - if 'organizations' not in existing_tables: - print("Creating organizations table...") - op.create_table('organizations', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=200), nullable=False), - sa.Column('slug', sa.String(length=100), nullable=False), - sa.Column('contact_email', sa.String(length=200), nullable=True), - sa.Column('contact_phone', sa.String(length=50), nullable=True), - sa.Column('billing_email', sa.String(length=200), nullable=True), - sa.Column('status', sa.String(length=20), nullable=False, server_default='active'), - sa.Column('subscription_plan', sa.String(length=50), nullable=False, server_default='free'), - sa.Column('max_users', sa.Integer(), nullable=True), - sa.Column('max_projects', sa.Integer(), nullable=True), - sa.Column('timezone', sa.String(length=50), nullable=False, server_default='UTC'), - sa.Column('currency', sa.String(length=3), nullable=False, server_default='EUR'), - sa.Column('date_format', sa.String(length=20), nullable=False, server_default='YYYY-MM-DD'), - sa.Column('logo_filename', sa.String(length=255), nullable=True), - sa.Column('primary_color', sa.String(length=7), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('slug', name='uq_organizations_slug') - ) - - # Create indexes - op.create_index('ix_organizations_name', 'organizations', ['name']) - op.create_index('ix_organizations_slug', 'organizations', ['slug']) - op.create_index('ix_organizations_status', 'organizations', ['status']) - - # ======================================== - # Step 2: Create default organization - # ======================================== - print("Creating default organization...") - result = bind.execute(text(""" - INSERT INTO organizations (name, slug, status, subscription_plan, timezone, currency, created_at, updated_at) - VALUES ('Default Organization', 'default', 'active', 'enterprise', 'UTC', 'EUR', :now, :now) - RETURNING id - """), {"now": datetime.utcnow()}) - - default_org_id = result.scalar() - print(f"Created default organization with ID: {default_org_id}") - - # ======================================== - # Step 3: Create memberships table - # ======================================== - if 'memberships' not in existing_tables: - print("Creating memberships table...") - op.create_table('memberships', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('organization_id', sa.Integer(), nullable=False), - sa.Column('role', sa.String(length=20), nullable=False, server_default='member'), - sa.Column('status', sa.String(length=20), nullable=False, server_default='active'), - sa.Column('invited_by', sa.Integer(), nullable=True), - sa.Column('invited_at', sa.DateTime(), nullable=True), - sa.Column('invitation_token', sa.String(length=100), nullable=True), - sa.Column('invitation_accepted_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('last_activity_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'organization_id', 'status', name='uq_user_org_status') - ) - - # Create indexes - op.create_index('ix_memberships_user_id', 'memberships', ['user_id']) - op.create_index('ix_memberships_organization_id', 'memberships', ['organization_id']) - op.create_index('ix_memberships_invitation_token', 'memberships', ['invitation_token']) - op.create_index('idx_memberships_user_org', 'memberships', ['user_id', 'organization_id']) - op.create_index('idx_memberships_org_role', 'memberships', ['organization_id', 'role']) - - # ======================================== - # Step 4: Create memberships for all existing users - # ======================================== - print("Creating memberships for existing users...") - bind.execute(text(""" - INSERT INTO memberships (user_id, organization_id, role, status, created_at, updated_at) - SELECT - id, - :org_id, - CASE WHEN role = 'admin' THEN 'admin' ELSE 'member' END, - 'active', - :now, - :now - FROM users - WHERE NOT EXISTS ( - SELECT 1 FROM memberships - WHERE memberships.user_id = users.id - AND memberships.organization_id = :org_id - ) - """), {"org_id": default_org_id, "now": datetime.utcnow()}) - - # ======================================== - # Step 5: Add organization_id to all tables - # ======================================== - - tables_to_update = [ - 'projects', - 'clients', - 'time_entries', - 'tasks', - 'invoices', - 'comments', - 'focus_sessions', - 'saved_filters', - 'task_activities' - ] - - for table_name in tables_to_update: - if table_name not in existing_tables: - print(f"Table {table_name} does not exist, skipping...") - continue - - # Check if organization_id column already exists - columns = [col['name'] for col in inspector.get_columns(table_name)] - if 'organization_id' in columns: - print(f"Column organization_id already exists in {table_name}, skipping...") - continue - - print(f"Adding organization_id to {table_name}...") - - # Add the column as nullable first - op.add_column(table_name, - sa.Column('organization_id', sa.Integer(), nullable=True) - ) - - # Populate with default organization - bind.execute(text(f""" - UPDATE {table_name} - SET organization_id = :org_id - WHERE organization_id IS NULL - """), {"org_id": default_org_id}) - - # Make it non-nullable - op.alter_column(table_name, 'organization_id', nullable=False) - - # Add foreign key constraint - op.create_foreign_key( - f'fk_{table_name}_organization_id', - table_name, 'organizations', - ['organization_id'], ['id'], - ondelete='CASCADE' - ) - - # Create index - op.create_index(f'ix_{table_name}_organization_id', table_name, ['organization_id']) - - # ======================================== - # Step 6: Create composite indexes for common queries - # ======================================== - print("Creating composite indexes...") - - def safe_create_index(index_name, table_name, columns): - """Safely create an index, ignoring if it already exists.""" - try: - # Check if index already exists - result = bind.execute(text(f""" - SELECT 1 FROM pg_indexes - WHERE indexname = :index_name - """), {"index_name": index_name}) - - if result.scalar(): - print(f" Index {index_name} already exists, skipping...") - return - - op.create_index(index_name, table_name, columns) - print(f" Created index {index_name}") - except Exception as e: - print(f" Warning: Could not create index {index_name}: {e}") - - def safe_drop_constraint(constraint_name, table_name, type_='unique'): - """Safely drop a constraint, ignoring if it doesn't exist.""" - try: - # Check if constraint exists - result = bind.execute(text(""" - SELECT 1 FROM pg_constraint - WHERE conname = :constraint_name - """), {"constraint_name": constraint_name}) - - if not result.scalar(): - print(f" Constraint {constraint_name} doesn't exist, skipping...") - return - - op.drop_constraint(constraint_name, table_name, type_=type_) - print(f" Dropped constraint {constraint_name}") - except Exception as e: - print(f" Warning: Could not drop constraint {constraint_name}: {e}") - - def safe_create_constraint(constraint_name, table_name, columns, type_='unique'): - """Safely create a constraint, ignoring if it already exists.""" - try: - # Check if constraint already exists - result = bind.execute(text(""" - SELECT 1 FROM pg_constraint - WHERE conname = :constraint_name - """), {"constraint_name": constraint_name}) - - if result.scalar(): - print(f" Constraint {constraint_name} already exists, skipping...") - return - - if type_ == 'unique': - op.create_unique_constraint(constraint_name, table_name, columns) - print(f" Created constraint {constraint_name}") - except Exception as e: - print(f" Warning: Could not create constraint {constraint_name}: {e}") - - # Projects - if 'projects' in existing_tables: - print(" Creating indexes for projects...") - safe_create_index('idx_projects_org_status', 'projects', ['organization_id', 'status']) - safe_create_index('idx_projects_org_client', 'projects', ['organization_id', 'client_id']) - - # Clients - Update unique constraint to be per-organization - if 'clients' in existing_tables: - print(" Updating constraints for clients...") - safe_drop_constraint('clients_name_key', 'clients', type_='unique') - safe_create_constraint('uq_clients_org_name', 'clients', ['organization_id', 'name'], type_='unique') - safe_create_index('idx_clients_org_status', 'clients', ['organization_id', 'status']) - - # Time Entries - if 'time_entries' in existing_tables: - print(" Creating indexes for time_entries...") - safe_create_index('idx_time_entries_org_user', 'time_entries', ['organization_id', 'user_id']) - safe_create_index('idx_time_entries_org_project', 'time_entries', ['organization_id', 'project_id']) - safe_create_index('idx_time_entries_org_dates', 'time_entries', ['organization_id', 'start_time', 'end_time']) - - # Tasks - if 'tasks' in existing_tables: - print(" Creating indexes for tasks...") - safe_create_index('idx_tasks_org_project', 'tasks', ['organization_id', 'project_id']) - safe_create_index('idx_tasks_org_status', 'tasks', ['organization_id', 'status']) - safe_create_index('idx_tasks_org_assigned', 'tasks', ['organization_id', 'assigned_to']) - - # Invoices - Update unique constraint to be per-organization - if 'invoices' in existing_tables: - print(" Updating constraints for invoices...") - safe_drop_constraint('invoices_invoice_number_key', 'invoices', type_='unique') - safe_create_constraint('uq_invoices_org_number', 'invoices', ['organization_id', 'invoice_number'], type_='unique') - safe_create_index('idx_invoices_org_status', 'invoices', ['organization_id', 'status']) - safe_create_index('idx_invoices_org_client', 'invoices', ['organization_id', 'client_id']) - - # Comments - if 'comments' in existing_tables: - print(" Creating indexes for comments...") - safe_create_index('idx_comments_org_project', 'comments', ['organization_id', 'project_id']) - safe_create_index('idx_comments_org_task', 'comments', ['organization_id', 'task_id']) - safe_create_index('idx_comments_org_user', 'comments', ['organization_id', 'user_id']) - - print("✅ Multi-tenant migration completed successfully!") - - -def downgrade() -> None: - """Remove multi-tenant support. - - WARNING: This will remove all organization and membership data! - """ - bind = op.get_bind() - inspector = sa.inspect(bind) - existing_tables = inspector.get_table_names() - - print("⚠️ WARNING: Downgrading multi-tenant support will remove organization data!") - - # Remove organization_id from all tables - tables_to_update = [ - 'projects', - 'clients', - 'time_entries', - 'tasks', - 'invoices', - 'comments', - 'focus_sessions', - 'saved_filters', - 'task_activities' - ] - - for table_name in tables_to_update: - if table_name not in existing_tables: - continue - - columns = [col['name'] for col in inspector.get_columns(table_name)] - if 'organization_id' not in columns: - continue - - print(f"Removing organization_id from {table_name}...") - - # Drop indexes - try: - op.drop_index(f'ix_{table_name}_organization_id', table_name=table_name) - except Exception: - pass - - # Drop foreign key - try: - op.drop_constraint(f'fk_{table_name}_organization_id', table_name, type_='foreignkey') - except Exception: - pass - - # Drop column - op.drop_column(table_name, 'organization_id') - - # Drop composite indexes - print("Dropping composite indexes...") - try: - if 'projects' in existing_tables: - op.drop_index('idx_projects_org_status', table_name='projects') - op.drop_index('idx_projects_org_client', table_name='projects') - - if 'clients' in existing_tables: - op.drop_constraint('uq_clients_org_name', 'clients', type_='unique') - op.drop_index('idx_clients_org_status', table_name='clients') - # Recreate old unique constraint - op.create_unique_constraint('clients_name_key', 'clients', ['name']) - - if 'time_entries' in existing_tables: - op.drop_index('idx_time_entries_org_user', table_name='time_entries') - op.drop_index('idx_time_entries_org_project', table_name='time_entries') - op.drop_index('idx_time_entries_org_dates', table_name='time_entries') - - if 'tasks' in existing_tables: - op.drop_index('idx_tasks_org_project', table_name='tasks') - op.drop_index('idx_tasks_org_status', table_name='tasks') - op.drop_index('idx_tasks_org_assigned', table_name='tasks') - - if 'invoices' in existing_tables: - op.drop_constraint('uq_invoices_org_number', 'invoices', type_='unique') - op.drop_index('idx_invoices_org_status', table_name='invoices') - op.drop_index('idx_invoices_org_client', table_name='invoices') - # Recreate old unique constraint - op.create_unique_constraint('invoices_invoice_number_key', 'invoices', ['invoice_number']) - - if 'comments' in existing_tables: - op.drop_index('idx_comments_org_project', table_name='comments') - op.drop_index('idx_comments_org_task', table_name='comments') - op.drop_index('idx_comments_org_user', table_name='comments') - except Exception as e: - print(f"Error dropping indexes: {e}") - - # Drop memberships table - if 'memberships' in existing_tables: - print("Dropping memberships table...") - op.drop_table('memberships') - - # Drop organizations table - if 'organizations' in existing_tables: - print("Dropping organizations table...") - op.drop_table('organizations') - - print("✅ Multi-tenant downgrade completed!") - diff --git a/migrations/versions/019_add_auth_features.py b/migrations/versions/019_add_auth_features.py deleted file mode 100644 index b9778dd..0000000 --- a/migrations/versions/019_add_auth_features.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Add authentication features (passwords, 2FA, tokens) - -Revision ID: 019_add_auth_features -Revises: 018_add_multi_tenant_support -Create Date: 2025-10-07 12:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = '019' -down_revision = '018' -branch_labels = None -depends_on = None - - -def upgrade(): - """Add authentication features""" - - # Add password and 2FA fields to users table - op.add_column('users', sa.Column('password_hash', sa.String(255), nullable=True)) - op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='false')) - op.add_column('users', sa.Column('totp_secret', sa.String(32), nullable=True)) - op.add_column('users', sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default='false')) - op.add_column('users', sa.Column('backup_codes', sa.Text(), nullable=True)) - - # Make email unique - try: - op.create_unique_constraint('uq_users_email', 'users', ['email']) - except Exception: - pass # Constraint may already exist - - # Create password_reset_tokens table - op.create_table( - 'password_reset_tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(100), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.Column('used', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('used_at', sa.DateTime(), nullable=True), - sa.Column('ip_address', sa.String(45), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - ) - op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id']) - op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=True) - - # Create email_verification_tokens table - op.create_table( - 'email_verification_tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(200), nullable=False), - sa.Column('token', sa.String(100), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.Column('verified', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('verified_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - ) - op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id']) - op.create_index('ix_email_verification_tokens_token', 'email_verification_tokens', ['token'], unique=True) - - # Create refresh_tokens table - op.create_table( - 'refresh_tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(100), nullable=False), - sa.Column('device_id', sa.String(100), nullable=True), - sa.Column('device_name', sa.String(200), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.Column('last_used_at', sa.DateTime(), nullable=False), - sa.Column('revoked', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('revoked_at', sa.DateTime(), nullable=True), - sa.Column('ip_address', sa.String(45), nullable=True), - sa.Column('user_agent', sa.String(500), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - ) - op.create_index('ix_refresh_tokens_user_id', 'refresh_tokens', ['user_id']) - op.create_index('ix_refresh_tokens_token', 'refresh_tokens', ['token'], unique=True) - op.create_index('ix_refresh_tokens_device_id', 'refresh_tokens', ['device_id']) - op.create_index('idx_refresh_tokens_user_device', 'refresh_tokens', ['user_id', 'device_id']) - - # Add Stripe fields to organizations table - op.add_column('organizations', sa.Column('stripe_customer_id', sa.String(100), nullable=True)) - op.add_column('organizations', sa.Column('stripe_subscription_id', sa.String(100), nullable=True)) - op.add_column('organizations', sa.Column('stripe_subscription_status', sa.String(20), nullable=True)) - op.add_column('organizations', sa.Column('stripe_price_id', sa.String(100), nullable=True)) - op.add_column('organizations', sa.Column('subscription_quantity', sa.Integer(), nullable=False, server_default='1')) - op.add_column('organizations', sa.Column('trial_ends_at', sa.DateTime(), nullable=True)) - op.add_column('organizations', sa.Column('subscription_ends_at', sa.DateTime(), nullable=True)) - op.add_column('organizations', sa.Column('next_billing_date', sa.DateTime(), nullable=True)) - op.add_column('organizations', sa.Column('billing_issue_detected_at', sa.DateTime(), nullable=True)) - op.add_column('organizations', sa.Column('last_billing_email_sent_at', sa.DateTime(), nullable=True)) - - try: - op.create_index('ix_organizations_stripe_customer_id', 'organizations', ['stripe_customer_id'], unique=True) - except Exception: - pass # Index may already exist - - # Create subscription_events table for tracking Stripe webhooks - op.create_table( - 'subscription_events', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('stripe_event_id', sa.String(100), nullable=False), - sa.Column('event_type', sa.String(100), nullable=False), - sa.Column('organization_id', sa.Integer(), nullable=True), - sa.Column('event_data', sa.Text(), nullable=True), - sa.Column('processed', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('processed_at', sa.DateTime(), nullable=True), - sa.Column('processing_error', sa.Text(), nullable=True), - sa.Column('retry_count', sa.Integer(), nullable=False, server_default='0'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='SET NULL'), - ) - op.create_index('ix_subscription_events_stripe_event_id', 'subscription_events', ['stripe_event_id'], unique=True) - op.create_index('idx_subscription_events_org', 'subscription_events', ['organization_id']) - op.create_index('idx_subscription_events_type', 'subscription_events', ['event_type']) - op.create_index('idx_subscription_events_created', 'subscription_events', ['created_at']) - - -def downgrade(): - """Remove authentication features""" - - # Drop subscription_events table - op.drop_index('idx_subscription_events_created', table_name='subscription_events') - op.drop_index('idx_subscription_events_type', table_name='subscription_events') - op.drop_index('idx_subscription_events_org', table_name='subscription_events') - op.drop_index('ix_subscription_events_stripe_event_id', table_name='subscription_events') - op.drop_table('subscription_events') - - # Drop Stripe fields from organizations - op.drop_column('organizations', 'last_billing_email_sent_at') - op.drop_column('organizations', 'billing_issue_detected_at') - op.drop_column('organizations', 'next_billing_date') - op.drop_column('organizations', 'subscription_ends_at') - op.drop_column('organizations', 'trial_ends_at') - op.drop_column('organizations', 'subscription_quantity') - op.drop_column('organizations', 'stripe_price_id') - op.drop_column('organizations', 'stripe_subscription_status') - op.drop_column('organizations', 'stripe_subscription_id') - op.drop_column('organizations', 'stripe_customer_id') - - # Drop refresh_tokens table - op.drop_index('idx_refresh_tokens_user_device', table_name='refresh_tokens') - op.drop_index('ix_refresh_tokens_device_id', table_name='refresh_tokens') - op.drop_index('ix_refresh_tokens_token', table_name='refresh_tokens') - op.drop_index('ix_refresh_tokens_user_id', table_name='refresh_tokens') - op.drop_table('refresh_tokens') - - # Drop email_verification_tokens table - op.drop_index('ix_email_verification_tokens_token', table_name='email_verification_tokens') - op.drop_index('ix_email_verification_tokens_user_id', table_name='email_verification_tokens') - op.drop_table('email_verification_tokens') - - # Drop password_reset_tokens table - op.drop_index('ix_password_reset_tokens_token', table_name='password_reset_tokens') - op.drop_index('ix_password_reset_tokens_user_id', table_name='password_reset_tokens') - op.drop_table('password_reset_tokens') - - # Remove email unique constraint - try: - op.drop_constraint('uq_users_email', 'users', type_='unique') - except Exception: - pass - - # Remove password and 2FA fields from users - op.drop_column('users', 'backup_codes') - op.drop_column('users', 'totp_enabled') - op.drop_column('users', 'totp_secret') - op.drop_column('users', 'email_verified') - op.drop_column('users', 'password_hash') - diff --git a/migrations/versions/020_add_onboarding_checklist.py b/migrations/versions/020_add_onboarding_checklist.py deleted file mode 100644 index b1df940..0000000 --- a/migrations/versions/020_add_onboarding_checklist.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Add onboarding checklist table - -Revision ID: 020_add_onboarding_checklist -Revises: 019_add_auth_features -Create Date: 2025-01-08 10:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = '020' -down_revision = '019' -branch_labels = None -depends_on = None - - -def upgrade(): - """Create onboarding_checklists table.""" - op.create_table( - 'onboarding_checklists', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('organization_id', sa.Integer(), nullable=False), - - # Task completion flags - sa.Column('invited_team_member', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('invited_team_member_at', sa.DateTime(), nullable=True), - - sa.Column('created_project', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('created_project_at', sa.DateTime(), nullable=True), - - sa.Column('created_time_entry', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('created_time_entry_at', sa.DateTime(), nullable=True), - - sa.Column('set_working_hours', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('set_working_hours_at', sa.DateTime(), nullable=True), - - sa.Column('customized_settings', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('customized_settings_at', sa.DateTime(), nullable=True), - - sa.Column('added_billing_info', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('added_billing_info_at', sa.DateTime(), nullable=True), - - sa.Column('created_client', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('created_client_at', sa.DateTime(), nullable=True), - - sa.Column('generated_report', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('generated_report_at', sa.DateTime(), nullable=True), - - # Overall status - sa.Column('completed', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('completed_at', sa.DateTime(), nullable=True), - - # Dismiss/skip tracking - sa.Column('dismissed', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('dismissed_at', sa.DateTime(), nullable=True), - - # Timestamps - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - - # Primary key - sa.PrimaryKeyConstraint('id'), - - # Foreign key - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), - - # Unique constraint - one checklist per organization - sa.UniqueConstraint('organization_id', name='uq_onboarding_checklist_org'), - ) - - # Create indexes - op.create_index('ix_onboarding_checklists_organization_id', 'onboarding_checklists', ['organization_id']) - - print("✅ Created onboarding_checklists table") - - -def downgrade(): - """Drop onboarding_checklists table.""" - op.drop_index('ix_onboarding_checklists_organization_id', table_name='onboarding_checklists') - op.drop_table('onboarding_checklists') - print("❌ Dropped onboarding_checklists table") - diff --git a/migrations/versions/021_add_promo_codes.py b/migrations/versions/021_add_promo_codes.py deleted file mode 100644 index e10e47f..0000000 --- a/migrations/versions/021_add_promo_codes.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Add promo codes support - -Revision ID: 021_add_promo_codes -Revises: 020 -Create Date: 2025-01-07 12:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = '021' -down_revision = '020' -branch_labels = None -depends_on = None - - -def upgrade(): - # Create promo_codes table - op.create_table('promo_codes', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('code', sa.String(length=50), nullable=False), - sa.Column('description', sa.String(length=200), nullable=True), - sa.Column('discount_type', sa.String(length=20), nullable=False, server_default='percent'), - sa.Column('discount_value', sa.Numeric(precision=10, scale=2), nullable=False), - sa.Column('duration', sa.String(length=20), nullable=False, server_default='once'), - sa.Column('duration_in_months', sa.Integer(), nullable=True), - sa.Column('max_redemptions', sa.Integer(), nullable=True), - sa.Column('times_redeemed', sa.Integer(), nullable=False, server_default='0'), - sa.Column('valid_from', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.Column('valid_until', sa.DateTime(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), - sa.Column('stripe_coupon_id', sa.String(length=100), nullable=True), - sa.Column('stripe_promotion_code_id', sa.String(length=100), nullable=True), - sa.Column('first_time_only', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('min_seats', sa.Integer(), nullable=True), - sa.Column('max_seats', sa.Integer(), nullable=True), - sa.Column('plan_restrictions', sa.String(length=200), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.Column('created_by', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code') - ) - op.create_index('idx_promo_codes_active', 'promo_codes', ['is_active']) - op.create_index('idx_promo_codes_code', 'promo_codes', ['code']) - - # Create promo_code_redemptions table - op.create_table('promo_code_redemptions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('promo_code_id', sa.Integer(), nullable=False), - sa.Column('organization_id', sa.Integer(), nullable=False), - sa.Column('redeemed_by', sa.Integer(), nullable=True), - sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), - sa.Column('stripe_subscription_id', sa.String(length=100), nullable=True), - sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), - sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ), - sa.ForeignKeyConstraint(['redeemed_by'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_redemptions_org', 'promo_code_redemptions', ['organization_id']) - op.create_index('idx_redemptions_promo_code', 'promo_code_redemptions', ['promo_code_id']) - - # Add promo code columns to organizations table - op.add_column('organizations', sa.Column('promo_code', sa.String(length=50), nullable=True)) - op.add_column('organizations', sa.Column('promo_code_applied_at', sa.DateTime(), nullable=True)) - - # Insert early adopter promo code - op.execute(""" - INSERT INTO promo_codes ( - code, description, discount_type, discount_value, - duration, duration_in_months, is_active, first_time_only, - valid_from, valid_until - ) VALUES ( - 'EARLY2025', - 'Early Adopter Discount - 20% off for 3 months', - 'percent', - 20.00, - 'repeating', - 3, - true, - true, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP + INTERVAL '6 months' - ) - """) - - -def downgrade(): - # Drop columns from organizations table - op.drop_column('organizations', 'promo_code_applied_at') - op.drop_column('organizations', 'promo_code') - - op.drop_index('idx_redemptions_promo_code', table_name='promo_code_redemptions') - op.drop_index('idx_redemptions_org', table_name='promo_code_redemptions') - op.drop_table('promo_code_redemptions') - op.drop_index('idx_promo_codes_code', table_name='promo_codes') - op.drop_index('idx_promo_codes_active', table_name='promo_codes') - op.drop_table('promo_codes') - diff --git a/migrations/versions/022_add_password_security_fields.py b/migrations/versions/022_add_password_security_fields.py deleted file mode 100644 index 6f50d81..0000000 --- a/migrations/versions/022_add_password_security_fields.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Add password security fields to users table - -Revision ID: 022 -Revises: 021 -Create Date: 2025-10-07 21:25:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = '022' -down_revision = '021' -branch_labels = None -depends_on = None - - -def upgrade(): - """Add password security fields""" - # Use connection to check if columns exist - conn = op.get_bind() - inspector = sa.inspect(conn) - - # Only add columns if the users table exists - tables = inspector.get_table_names() - if 'users' not in tables: - # Table doesn't exist yet, skip (will be created by earlier migrations) - return - - existing_columns = {col['name'] for col in inspector.get_columns('users')} - - # Add each column only if it doesn't exist - if 'password_changed_at' not in existing_columns: - op.add_column('users', sa.Column('password_changed_at', sa.DateTime(), nullable=True)) - - if 'password_history' not in existing_columns: - op.add_column('users', sa.Column('password_history', sa.Text(), nullable=True)) - - if 'failed_login_attempts' not in existing_columns: - op.add_column('users', sa.Column('failed_login_attempts', sa.Integer(), nullable=False, server_default=sa.text('0'))) - - if 'account_locked_until' not in existing_columns: - op.add_column('users', sa.Column('account_locked_until', sa.DateTime(), nullable=True)) - - -def downgrade(): - """Remove password security fields""" - # Check if columns exist before dropping - conn = op.get_bind() - inspector = sa.inspect(conn) - - tables = inspector.get_table_names() - if 'users' not in tables: - return - - existing_columns = {col['name'] for col in inspector.get_columns('users')} - - if 'account_locked_until' in existing_columns: - op.drop_column('users', 'account_locked_until') - - if 'failed_login_attempts' in existing_columns: - op.drop_column('users', 'failed_login_attempts') - - if 'password_history' in existing_columns: - op.drop_column('users', 'password_history') - - if 'password_changed_at' in existing_columns: - op.drop_column('users', 'password_changed_at') - diff --git a/requirements-security.txt b/requirements-security.txt deleted file mode 100644 index 7182598..0000000 --- a/requirements-security.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Security scanning and testing tools -# Install with: pip install -r requirements-security.txt - -# Dependency vulnerability scanning -safety==3.0.1 -pip-audit==2.7.0 - -# Static code analysis -bandit[toml]==1.7.5 - -# Secret detection -detect-secrets==1.4.0 - -# Security headers testing -python-owasp-zap-v2.4==0.0.21 # OWASP ZAP API client (optional) - -# Password strength testing -zxcvbn==4.4.28 # Optional: More advanced password strength checking - diff --git a/requirements.txt b/requirements.txt index 573f2d0..692727b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,9 +50,3 @@ flake8==6.1.0 cryptography==45.0.6 markdown==3.6 bleach==6.1.0 -PyJWT==2.8.0 -pyotp==2.9.0 -qrcode==7.4.2 - -# Payment processing -stripe==7.9.0 \ No newline at end of file diff --git a/scripts/update_routes_for_multi_tenant.py b/scripts/update_routes_for_multi_tenant.py deleted file mode 100644 index 19bb162..0000000 --- a/scripts/update_routes_for_multi_tenant.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -""" -Automated script to update route files for multi-tenant support. - -This script applies the multi-tenant patterns to all route files: -1. Adds tenancy imports -2. Adds @require_organization_access() decorators -3. Replaces Model.query with scoped_query(Model) -4. Adds organization_id to create operations - -Usage: - python scripts/update_routes_for_multi_tenant.py -""" - -import os -import re -from pathlib import Path - -# Base directory -BASE_DIR = Path(__file__).parent.parent -ROUTES_DIR = BASE_DIR / 'app' / 'routes' - -# Import statements to add -TENANCY_IMPORTS = """from app.utils.tenancy import ( - get_current_organization_id, - get_current_organization, - scoped_query, - require_organization_access, - ensure_organization_access -)""" - -def add_tenancy_imports(content): - """Add tenancy imports after existing imports.""" - # Find the last import statement - import_pattern = r'(from .+ import .+\n)(?!from|import)' - match = re.search(import_pattern, content) - - if match and 'tenancy' not in content: - insert_pos = match.end() - return content[:insert_pos] + '\n' + TENANCY_IMPORTS + '\n' + content[insert_pos:] - return content - -def add_decorator_to_route(content): - """Add @require_organization_access() decorator to routes.""" - # Pattern: @route_bp.route(...)\n@login_required\ndef func_name - pattern = r'(@\w+_bp\.route\([^)]+\)\n)(@login_required\n)(def \w+\([^)]*\):)' - - def replacement(match): - if '@require_organization_access()' in content[max(0, match.start()-200):match.end()+50]: - return match.group(0) # Already has decorator - return match.group(1) + match.group(2) + '@require_organization_access()\n' + match.group(3) - - return re.sub(pattern, replacement, content) - -def replace_model_query(content): - """Replace Model.query with scoped_query(Model).""" - # Pattern: ModelName.query - models = ['Project', 'Client', 'TimeEntry', 'Task', 'Invoice', 'Comment', 'FocusSession'] - - for model in models: - # Replace Model.query with scoped_query(Model) - # But not in comments or strings - pattern = rf'\b{model}\.query\b' - content = re.sub(pattern, f'scoped_query({model})', content) - - return content - -def update_route_file(filepath): - """Update a single route file.""" - print(f"Updating {filepath.name}...") - - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - - original_content = content - - # Apply transformations - content = add_tenancy_imports(content) - content = add_decorator_to_route(content) - content = replace_model_query(content) - - # Only write if changed - if content != original_content: - with open(filepath, 'w', encoding='utf-8') as f: - f.write(content) - print(f" ✓ Updated {filepath.name}") - return True - else: - print(f" - No changes needed for {filepath.name}") - return False - -def main(): - """Main function to update all route files.""" - print("=" * 60) - print("Multi-Tenant Route Update Script") - print("=" * 60) - print() - - # Files to update (in priority order) - route_files = [ - 'projects.py', # Already updated manually - 'clients.py', - 'timer.py', - 'tasks.py', - 'invoices.py', - 'comments.py', - 'reports.py', - 'analytics.py', - 'api.py', - 'admin.py', - ] - - updated_count = 0 - for filename in route_files: - filepath = ROUTES_DIR / filename - if filepath.exists(): - if update_route_file(filepath): - updated_count += 1 - else: - print(f" ⚠ File not found: {filename}") - - print() - print("=" * 60) - print(f"Updated {updated_count} file(s)") - print("=" * 60) - print() - print("⚠️ IMPORTANT: Manual review required!") - print() - print("The automated updates handle common patterns, but you MUST:") - print("1. Review each file for correctness") - print("2. Add organization_id to CREATE operations") - print("3. Verify client/project relationships are scoped") - print("4. Test each route thoroughly") - print() - print("See docs/ROUTE_MIGRATION_GUIDE.md for details.") - -if __name__ == '__main__': - main() - diff --git a/templates/admin/billing_reconciliation.html b/templates/admin/billing_reconciliation.html deleted file mode 100644 index 60be652..0000000 --- a/templates/admin/billing_reconciliation.html +++ /dev/null @@ -1,153 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ _('Billing Reconciliation') }} - {{ app_name }}{% endblock %} - -{% block content %} -
- {% from "_components.html" import page_header %} -
-
- {% set actions %} - - {{ _('Back to Customers') }} - - {% endset %} - {{ page_header('fas fa-sync', _('Billing Reconciliation'), _('Monitor sync status between Stripe and local database'), actions) }} -
-
- - -
-
- {% from "_components.html" import summary_card %} - {{ summary_card('fas fa-building', 'primary', 'Total Organizations', sync_results.total) }} -
-
- {{ summary_card('fas fa-check-circle', 'success', 'Synced', sync_results.synced) }} -
-
- {{ summary_card('fas fa-exclamation-triangle', 'warning', 'With Discrepancies', sync_results.with_discrepancies) }} -
-
- {{ summary_card('fas fa-times-circle', 'danger', 'Errors', sync_results.errors) }} -
-
- - -
-
-
-
-
- {{ _('Organization Sync Status') }} -
-
- -
-
-
-
- - - - - - - - - - - - {% for org_result in sync_results.organizations %} - - - - - - - - {% endfor %} - -
{{ _('Organization') }}{{ _('Sync Status') }}{{ _('Discrepancies') }}{{ _('Details') }}{{ _('Actions') }}
- {{ org_result.name }}
- {{ org_result.slug }} -
- {% if org_result.synced %} - - {{ _('Synced') }} - - {% else %} - - {{ _('Error') }} - - {% endif %} - - {% if org_result.discrepancy_count > 0 %} - {{ org_result.discrepancy_count }} - {% else %} - 0 - {% endif %} - - {% if org_result.error %} - {{ org_result.error }} - {% elif org_result.discrepancies %} - -
-
-
    - {% for disc in org_result.discrepancies %} -
  • - {{ disc.field }}: - {% if disc.error %} - {{ disc.error }} - {% else %} - Local: {{ disc.local }} → Stripe: {{ disc.stripe }} - {% endif %} -
  • - {% endfor %} -
-
-
- {% else %} - - {{ _('No issues') }} - - {% endif %} -
-
- -
-
-
-
-
-
-
- - -
-
-
-
{{ _('About Billing Reconciliation') }}
-

{{ _('This tool checks for discrepancies between your local database and Stripe. Common issues include:') }}

-
    -
  • {{ _('Subscription status mismatches (e.g., cancelled in Stripe but active locally)') }}
  • -
  • {{ _('Quantity differences (number of seats)') }}
  • -
  • {{ _('Missing subscriptions in Stripe') }}
  • -
  • {{ _('Billing cycle date inconsistencies') }}
  • -
-

{{ _('Note') }}: {{ _('Discrepancies are automatically corrected when detected. Use re-sync to check for new issues.') }}

-
-
-
-
-{% endblock %} - diff --git a/templates/admin/customer_detail.html b/templates/admin/customer_detail.html deleted file mode 100644 index fb964cf..0000000 --- a/templates/admin/customer_detail.html +++ /dev/null @@ -1,445 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ organization.name }} - {{ _('Customer Detail') }} - {{ app_name }}{% endblock %} - -{% block content %} -
- {% from "_components.html" import page_header %} -
-
- {% set actions %} - - {{ _('Back to Customers') }} - - {% endset %} - {{ page_header('fas fa-building', organization.name, organization.slug, actions) }} -
-
- - -
- -
-
-
-
- {{ _('Organization Status') }} -
-
-
-
-
{{ _('Status') }}:
-
- {% if organization.is_active %} - {{ _('Active') }} - {% elif organization.is_suspended %} - {{ _('Suspended') }} - {% else %} - {{ organization.status|title }} - {% endif %} -
-
-
-
{{ _('Created') }}:
-
{{ organization.created_at.strftime('%Y-%m-%d') if organization.created_at else 'N/A' }}
-
-
-
{{ _('Active Members') }}:
-
- {{ memberships|selectattr('status', 'equalto', 'active')|list|length }} -
-
-
-
{{ _('Contact Email') }}:
-
{{ organization.contact_email or 'N/A' }}
-
- - -
-
{{ _('Actions') }}
-
- {% if organization.is_active %} -
- -
- {% else %} -
- -
- {% endif %} -
-
-
-
-
- - -
-
-
-
- {{ _('Subscription') }} -
-
-
- {% if organization.stripe_customer_id %} -
-
{{ _('Plan') }}:
-
{{ organization.subscription_plan_display }}
-
- - {% if organization.stripe_subscription_id %} -
-
{{ _('Status') }}:
-
- - {{ organization.stripe_subscription_status|title if organization.stripe_subscription_status else 'Unknown' }} - -
-
-
-
{{ _('Seats') }}:
-
{{ organization.subscription_quantity }}
-
- {% if organization.next_billing_date %} -
-
{{ _('Next Billing') }}:
-
{{ organization.next_billing_date.strftime('%Y-%m-%d') }}
-
- {% endif %} - - -
-
{{ _('Subscription Management') }}
- - -
-
- {{ _('Seats') }} - - -
-
- - - {% if organization.subscription_ends_at %} -
- -
- {% else %} -
- - -
-
- - -
- {% endif %} -
- {% else %} -

{{ _('No active subscription') }}

- {% endif %} - -
- - {{ _('Stripe Customer ID') }}: {{ organization.stripe_customer_id }} - -
- {% else %} -

{{ _('No Stripe customer account') }}

- {% endif %} -
-
-
-
- - -
-
-
-
-
- {{ _('Members') }} -
-
-
-
- - - - - - - - - - - - - {% for membership in memberships %} - - - - - - - - - {% else %} - - - - {% endfor %} - -
{{ _('User') }}{{ _('Email') }}{{ _('Role') }}{{ _('Status') }}{{ _('Last Activity') }}{{ _('Joined') }}
{{ membership.user.display_name }}{{ membership.user.email or 'N/A' }} - - {{ membership.role|title }} - - - - {{ membership.status|title }} - - - {% if membership.last_activity_at %} - {{ membership.last_activity_at.strftime('%Y-%m-%d %H:%M') }} - {% elif membership.user.last_login %} - {{ membership.user.last_login.strftime('%Y-%m-%d %H:%M') }} - {% else %} - {{ _('Never') }} - {% endif %} - {{ membership.created_at.strftime('%Y-%m-%d') if membership.created_at else 'N/A' }}
- {{ _('No members') }} -
-
-
-
-
-
- - - {% if stripe_data %} -
-
-
-
-
- {{ _('Recent Invoices') }} -
-
-
- {% if stripe_data.invoices %} -
- - - - - - - - - - - - {% for invoice in stripe_data.invoices %} - - - - - - - - {% endfor %} - -
{{ _('Number') }}{{ _('Date') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Actions') }}
{{ invoice.number or invoice.id[:8] }}{{ invoice.created.strftime('%Y-%m-%d') }}{{ "%.2f"|format(invoice.amount_paid) }} {{ invoice.currency }} - - {{ invoice.status|title }} - - -
- {% if invoice.hosted_invoice_url %} - - - - {% endif %} - {% if invoice.paid and invoice.amount_paid > 0 %} - - {% endif %} -
-
-
- {% else %} -
- {{ _('No invoices') }} -
- {% endif %} -
-
-
- - -
-
-
-
- {{ _('Payment Methods') }} -
-
-
- {% if stripe_data.payment_methods %} -
    - {% for pm in stripe_data.payment_methods %} -
  • - - {{ pm.card.brand|title }} •••• {{ pm.card.last4 }} - ({{ pm.card.exp_month }}/{{ pm.card.exp_year }}) -
  • - {% endfor %} -
- {% else %} -

{{ _('No payment methods') }}

- {% endif %} -
-
- - {% if stripe_data.refunds %} -
-
-
- {{ _('Recent Refunds') }} -
-
-
-
    - {% for refund in stripe_data.refunds %} -
  • - {{ "%.2f"|format(refund.amount) }} {{ refund.currency|upper }} - {{ refund.status }} -
    {{ refund.created.strftime('%Y-%m-%d') }} - {{ refund.reason or 'No reason' }} -
  • - {% endfor %} -
-
-
- {% endif %} -
-
- {% endif %} - - -
-
-
-
-
- {{ _('Recent Events') }} -
-
-
- {% if recent_events %} -
- - - - - - - - - - - {% for event in recent_events %} - - - - - - - {% endfor %} - -
{{ _('Date') }}{{ _('Event Type') }}{{ _('Status') }}{{ _('Notes') }}
{{ event.created_at.strftime('%Y-%m-%d %H:%M') }}{{ event.event_type }} - - {{ _('Processed') if event.processed else _('Pending') }} - - {{ event.notes or '-' }}
-
- {% else %} -
- {{ _('No recent events') }} -
- {% endif %} -
-
-
-
-
- - - - - -{% endblock %} - diff --git a/templates/admin/customers.html b/templates/admin/customers.html deleted file mode 100644 index a3af1b3..0000000 --- a/templates/admin/customers.html +++ /dev/null @@ -1,131 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ _('Customer Management') }} - {{ app_name }}{% endblock %} - -{% block content %} -
- {% from "_components.html" import page_header %} -
-
- {% set actions %} - - {{ _('Billing Reconciliation') }} - - - {{ _('Webhook Logs') }} - - {% endset %} - {{ page_header('fas fa-building', _('Customer Management'), _('Manage organizations, subscriptions, and billing'), actions) }} -
-
- - -
-
- {% from "_components.html" import summary_card %} - {{ summary_card('fas fa-building', 'primary', 'Total Organizations', customer_data|length) }} -
-
- {% set active_count = customer_data|selectattr('organization.is_active')|list|length %} - {{ summary_card('fas fa-check-circle', 'success', 'Active Organizations', active_count) }} -
-
- {% set total_members = customer_data|sum(attribute='active_members') %} - {{ summary_card('fas fa-users', 'info', 'Total Active Users', total_members) }} -
-
- {% set with_subscriptions = customer_data|selectattr('organization.stripe_subscription_id')|list|length %} - {{ summary_card('fas fa-credit-card', 'warning', 'Paying Customers', with_subscriptions) }} -
-
- - -
-
-
-
-
- {{ _('All Organizations') }} -
-
-
-
- - - - - - - - - - - - - - {% for data in customer_data %} - {% set org = data.organization %} - - - - - - - - - - {% else %} - - - - {% endfor %} - -
{{ _('Organization') }}{{ _('Status') }}{{ _('Subscription') }}{{ _('Active Users') }}{{ _('Invoices') }}{{ _('Last Activity') }}{{ _('Actions') }}
- {{ org.name }}
- {{ org.slug }} -
- {% if org.is_active %} - {{ _('Active') }} - {% elif org.is_suspended %} - {{ _('Suspended') }} - {% else %} - {{ org.status|title }} - {% endif %} - - {% if org.stripe_subscription_id %} - - {{ org.stripe_subscription_status|title if org.stripe_subscription_status else 'Unknown' }} -
- {{ org.subscription_plan_display }} ({{ org.subscription_quantity }} seats) - {% else %} - {{ _('No subscription') }} - {% endif %} -
- {{ data.active_members }} - - {{ data.invoice_count }} - - {% if data.last_activity %} - {{ data.last_activity.strftime('%Y-%m-%d %H:%M') }} - {% else %} - {{ _('Never') }} - {% endif %} - - - {{ _('View') }} - -
-
- -
{{ _('No organizations found') }}
-

{{ _('Organizations will appear here once they are created.') }}

-
-
-
-
-
-
-
-
-{% endblock %} - diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 0288c45..c983f45 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -14,9 +14,12 @@ {{ _('Create Backup') }} - + {{ _('Restore') }} + + {{ _('Metrics Status') }} + {% endset %} {{ page_header('fas fa-cogs', _('Admin Dashboard'), _('Manage users, system settings, and core operations at a glance.'), actions) }} @@ -41,29 +44,7 @@
- - diff --git a/templates/admin/license_status.html b/templates/admin/license_status.html new file mode 100644 index 0000000..b6ddae2 --- /dev/null +++ b/templates/admin/license_status.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Metrics Server Status') }} - {{ _('Admin') }}{% endblock %} + +{% block content %} +
+
+
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+
+ {{ _('Client Information') }} +
+
+
+ + + + + + + + + + + + + + + + + +
{{ _('App Identifier:') }}{{ status.app_identifier }}
{{ _('App Version:') }}{{ status.app_version }}
{{ _('Instance ID:') }}{{ status.instance_id or _('Not registered') }}
{{ _('Registration Status:') }} + {% if status.is_registered %} + {{ _('Registered') }} + {% else %} + {{ _('Not Registered') }} + {% endif %} +
+
+
+
+ +
+
+
+
+ {{ _('Connection Status') }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Client Running:') }} + {% if status.is_running %} + {{ _('Running') }} + {% else %} + {{ _('Stopped') }} + {% endif %} +
{{ _('Server Health:') }} + {% if status.server_healthy %} + {{ _('Healthy') }} + {% else %} + {{ _('Not Responding') }} + {% endif %} +
{{ _('Offline Data:') }} + {% if status.offline_data_count > 0 %} + {{ status.offline_data_count }} {{ _('pending') }} + {% else %} + {{ _('None') }} + {% endif %} +
{{ _('Server URL:') }}{{ status.server_url }}
{{ _('Heartbeat Interval:') }}{{ status.heartbeat_interval }} {{ _('seconds') }}
{{ _('Analytics Enabled:') }} + {% if status.analytics_enabled %} + {{ _('Yes') }} + {% else %} + {{ _('No') }} + {% endif %} +
+
+
+
+
+ +
+
+
+
+
+ {{ _('Configuration') }} +
+
+
+
+
{{ _('About This Implementation') }}
+

+ {{ _('This TimeTracker application communicates with a metrics server for monitoring and analytics purposes only.') }} + {{ _('No license is required') }} {{ _('to use the application. The metrics server is used to:') }} +

+
    +
  • {{ _('Track application usage and features') }}
  • +
  • {{ _('Monitor system health and performance') }}
  • +
  • {{ _('Collect analytics data for improvement') }}
  • +
  • {{ _('Provide support and troubleshooting information') }}
  • +
+
+ +
+
+
{{ _('Server Configuration') }}
+
    +
  • {{ _('Server URL:') }} {{ status.server_url }}
  • +
  • {{ _('Heartbeat Interval:') }} {{ status.heartbeat_interval }} {{ _('seconds') }}
  • +
+
+
+
{{ _('Features') }}
+
    +
  • {{ _('Automatic instance registration') }}
  • +
  • {{ _('Periodic heartbeats') }}
  • +
  • {{ _('Usage data collection') }}
  • +
  • {{ _('Offline data storage') }}
  • +
  • {{ _('Graceful error handling') }}
  • +
+
+
+ +
+
+
{{ _('Privacy & Analytics Settings') }}
+
+ + {{ _('Analytics Setting:') }} + {% if settings and settings.allow_analytics %} + {{ _('Enabled') }} + {{ _('System information is being shared with the metrics server') }} + {% else %} + {{ _('Disabled') }} + {{ _('System information sharing is disabled') }} + {% endif %} +
+ + + {{ _('This setting can be changed in') }} {{ _('System Settings') }}. + {{ _('License validation continues to work regardless of this setting.') }} + +
+
+
+
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 15a2222..e9e3013 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -350,9 +350,9 @@
- {{ _('Controls whether analytics and usage tracking is enabled for the application.') }} + {{ _('When enabled, basic system information (OS, version, etc.) may be shared with the metrics server for monitoring purposes.') }} {{ _('Core functionality will continue to work regardless of this setting.') }} -
{{ _('This helps improve the application.') }} +
{{ _('This helps improve the application and monitor system health.') }}
@@ -409,7 +409,7 @@
  • {{ _('Self Register allows new usernames to be created on login.') }}
  • {{ _('Company branding settings are used for PDF invoice generation.') }}
  • {{ _('Company logos can be uploaded directly through the interface (PNG, JPG, JPEG, GIF, SVG, WEBP formats supported).') }}
  • -
  • {{ _('Analytics setting controls whether usage tracking is enabled.') }}
  • +
  • {{ _('Analytics setting controls whether system information is shared with the metrics server for monitoring purposes.') }}
  • {{ _('Core functionality will continue to work regardless of the analytics setting.') }}
  • diff --git a/templates/admin/webhook_detail.html b/templates/admin/webhook_detail.html deleted file mode 100644 index af2f1a1..0000000 --- a/templates/admin/webhook_detail.html +++ /dev/null @@ -1,278 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ _('Webhook Event Detail') }} - {{ app_name }}{% endblock %} - -{% block content %} -
    - {% from "_components.html" import page_header %} -
    -
    - {% set actions %} - - {{ _('Back to Logs') }} - - {% if event.processing_error or not event.processed %} -
    - -
    - {% endif %} - {% endset %} - {{ page_header('fas fa-file-alt', _('Webhook Event Detail'), 'Event #' ~ event.id, actions) }} -
    -
    - -
    - -
    -
    -
    -
    - {{ _('Event Information') }} -
    -
    -
    - - - - - - - - - - - {% if event.stripe_event_id %} - - - - - {% endif %} - - - - - - - - - - - - - -
    {{ _('Event ID') }}:{{ event.id }}
    {{ _('Event Type') }}:{{ event.event_type }}
    {{ _('Stripe Event ID') }}:{{ event.stripe_event_id }}
    {{ _('Organization') }}: - {% if event.organization %} - - {{ event.organization.name }} - - {% else %} - N/A - {% endif %} -
    {{ _('Created At') }}:{{ event.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
    {{ _('Updated At') }}:{{ event.updated_at.strftime('%Y-%m-%d %H:%M:%S') if event.updated_at else 'N/A' }}
    -
    -
    -
    - - -
    -
    -
    -
    - {{ _('Processing Status') }} -
    -
    -
    - - - - - - - - - - - - - - - {% if event.processing_error %} - - - - - {% endif %} - -
    {{ _('Status') }}: - {% if event.processed %} - {% if event.processing_error %} - - {{ _('Error') }} - - {% else %} - - {{ _('Processed') }} - - {% endif %} - {% else %} - - {{ _('Pending') }} - - {% endif %} -
    {{ _('Processed At') }}:{{ event.processed_at.strftime('%Y-%m-%d %H:%M:%S') if event.processed_at else 'N/A' }}
    {{ _('Retry Count') }}: - {{ event.retry_count }} -
    {{ _('Error') }}: -
    - {{ event.processing_error }} -
    -
    -
    -
    -
    -
    - - - {% if event.stripe_customer_id or event.stripe_subscription_id or event.stripe_invoice_id %} -
    -
    -
    -
    -
    - {{ _('Transaction Details') }} -
    -
    -
    -
    - {% if event.stripe_customer_id %} -
    - {{ _('Customer ID') }}:
    - {{ event.stripe_customer_id }} -
    - {% endif %} - {% if event.stripe_subscription_id %} -
    - {{ _('Subscription ID') }}:
    - {{ event.stripe_subscription_id }} -
    - {% endif %} - {% if event.stripe_invoice_id %} -
    - {{ _('Invoice ID') }}:
    - {{ event.stripe_invoice_id }} -
    - {% endif %} - {% if event.stripe_charge_id %} -
    - {{ _('Charge ID') }}:
    - {{ event.stripe_charge_id }} -
    - {% endif %} - {% if event.stripe_refund_id %} -
    - {{ _('Refund ID') }}:
    - {{ event.stripe_refund_id }} -
    - {% endif %} - {% if event.amount %} -
    - {{ _('Amount') }}:
    - {{ "%.2f"|format(event.amount) }} {{ event.currency|upper }} -
    - {% endif %} - {% if event.status %} -
    - {{ _('Status') }}:
    - {{ event.status|title }} -
    - {% endif %} - {% if event.previous_status %} -
    - {{ _('Previous Status') }}:
    - {{ event.previous_status|title }} -
    - {% endif %} - {% if event.quantity %} -
    - {{ _('Quantity') }}:
    - {{ event.quantity }} - {% if event.previous_quantity %} - (was {{ event.previous_quantity }}) - {% endif %} -
    - {% endif %} -
    -
    -
    -
    -
    - {% endif %} - - - {% if event.notes %} -
    -
    -
    -
    -
    - {{ _('Notes') }} -
    -
    -
    -

    {{ event.notes }}

    -
    -
    -
    -
    - {% endif %} - - - {% if event.raw_payload or event.event_data %} -
    -
    -
    -
    -
    - {{ _('Raw Event Data') }} -
    -
    -
    -
    - {% if event.raw_payload %} -
    -

    - -

    -
    -
    -
    {{ event.raw_payload }}
    -
    -
    -
    - {% endif %} - {% if event.event_data %} -
    -

    - -

    -
    -
    -
    {{ event.event_data }}
    -
    -
    -
    - {% endif %} -
    -
    -
    -
    -
    - {% endif %} -
    -{% endblock %} - diff --git a/templates/admin/webhook_logs.html b/templates/admin/webhook_logs.html deleted file mode 100644 index f3b2116..0000000 --- a/templates/admin/webhook_logs.html +++ /dev/null @@ -1,208 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ _('Webhook Logs') }} - {{ app_name }}{% endblock %} - -{% block content %} -
    - {% from "_components.html" import page_header %} -
    -
    - {% set actions %} - - {{ _('Back to Customers') }} - - {% endset %} - {{ page_header('fas fa-history', _('Webhook Logs'), _('View and manage Stripe webhook events'), actions) }} -
    -
    - - -
    -
    -
    -
    -
    {{ _('Filters') }}
    -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - {{ _('Webhook Events') }} -
    -
    -
    - {% if events %} -
    - - - - - - - - - - - - - - {% for event in events %} - - - - - - - - - - {% endfor %} - -
    {{ _('Date') }}{{ _('Event Type') }}{{ _('Organization') }}{{ _('Status') }}{{ _('Amount') }}{{ _('Notes') }}{{ _('Actions') }}
    {{ event.created_at.strftime('%Y-%m-%d %H:%M:%S') }}{{ event.event_type }} - {% if event.organization %} - - {{ event.organization.name }} - - {% else %} - N/A - {% endif %} - - {% if event.processed %} - {% if event.processing_error %} - - {{ _('Error') }} - - {% else %} - - {{ _('Processed') }} - - {% endif %} - {% else %} - - {{ _('Pending') }} - - {% endif %} - {% if event.retry_count > 0 %} - {{ _('Retries') }}: {{ event.retry_count }} - {% endif %} - - {% if event.amount %} - {{ "%.2f"|format(event.amount) }} {{ event.currency }} - {% else %} - - - {% endif %} - - {{ event.notes[:50] if event.notes else '-' }}{% if event.notes and event.notes|length > 50 %}...{% endif %} - -
    - - - - {% if event.processing_error or not event.processed %} -
    - -
    - {% endif %} -
    -
    -
    - - - {% if pagination.pages > 1 %} - - {% endif %} - {% else %} -
    -
    - -
    {{ _('No webhook events found') }}
    -

    {{ _('Webhook events will appear here when received from Stripe.') }}

    -
    -
    - {% endif %} -
    -
    -
    -
    -
    -{% endblock %} - diff --git a/templates/main/help.html b/templates/main/help.html index cccdcc4..8e06bb7 100644 --- a/templates/main/help.html +++ b/templates/main/help.html @@ -641,6 +641,7 @@
  • {{ _('View system information and health') }}
  • {{ _('Monitor application performance') }}
  • {{ _('Review system logs and errors') }}
  • +
  • {{ _('Track metrics server status') }}
  • diff --git a/test_license_server.py b/test_license_server.py new file mode 100644 index 0000000..d849ec2 --- /dev/null +++ b/test_license_server.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Test script for the license server integration +""" + +import os +import sys +import time + +# Add the app directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) + +def test_license_server(): + """Test the license server client functionality""" + print("Testing License Server Integration") + print("=" * 40) + + try: + # Test 1: Import the license server module + print("1. Testing module import...") + from app.utils.license_server import LicenseServerClient, init_license_client + print(" ✓ Module imported successfully") + + # Test 2: Create client instance + print("2. Testing client creation...") + client = LicenseServerClient("testapp", "1.0.0") + print(" ✓ Client created successfully") + print(f" - Server URL: {client.server_url}") + print(f" - App ID: {client.app_identifier}") + print(f" - App Version: {client.app_version}") + + # Test 3: Collect system info + print("3. Testing system info collection...") + system_info = client._collect_system_info() + print(" ✓ System info collected") + print(f" - OS: {system_info.get('os', 'Unknown')}") + print(f" - Architecture: {system_info.get('architecture', 'Unknown')}") + print(f" - Python: {system_info.get('python_version', 'Unknown')}") + + # Test 4: Check server health (this will fail if server is not running) + print("4. Testing server health check...") + try: + health = client.check_server_health() + if health: + print(" ✓ Server is healthy") + else: + print(" ⚠ Server is not responding (expected if server is not running)") + except Exception as e: + print(f" ⚠ Server health check failed: {e}") + + # Test 5: Test client start/stop + print("5. Testing client lifecycle...") + try: + if client.start(): + print(" ✓ Client started successfully") + time.sleep(2) # Let it run for a moment + client.stop() + print(" ✓ Client stopped successfully") + else: + print(" ⚠ Client start failed (expected if server is not running)") + except Exception as e: + print(f" ⚠ Client lifecycle test failed: {e}") + + # Test 6: Test usage event sending + print("6. Testing usage event...") + try: + from app.utils.license_server import send_usage_event + result = send_usage_event("test_event", {"test": "data"}) + if result: + print(" ✓ Usage event sent successfully") + else: + print(" ⚠ Usage event failed (expected if server is not running)") + except Exception as e: + print(f" ⚠ Usage event test failed: {e}") + + print("\n" + "=" * 40) + print("Test completed!") + print("\nNote: Some tests may fail if the license server is not running.") + print("This is expected behavior and does not indicate a problem with the integration.") + + except ImportError as e: + print(f"✗ Import error: {e}") + print("Make sure you're running this from the project root directory") + return False + except Exception as e: + print(f"✗ Test failed: {e}") + return False + + return True + +def test_cli_commands(): + """Test the CLI commands""" + print("\nTesting CLI Commands") + print("=" * 40) + + try: + from app.utils.license_server import get_license_client + + # Test global client functions + print("1. Testing global client functions...") + client = get_license_client() + if client: + print(" ✓ Global client retrieved") + status = client.get_status() + print(f" - Status: {status}") + else: + print(" ⚠ Global client not available (expected if not initialized)") + + print(" ✓ CLI command tests completed") + + except Exception as e: + print(f" ⚠ CLI test failed: {e}") + +if __name__ == "__main__": + print("License Server Integration Test") + print("=" * 50) + + success = test_license_server() + test_cli_commands() + + if success: + print("\n🎉 All tests completed successfully!") + print("\nTo test with a running license server:") + print("1. Start the license server on http://localhost:8081") + print("2. Run: flask license-test") + print("3. Check: flask license-status") + else: + print("\n❌ Some tests failed. Check the output above for details.") + sys.exit(1) diff --git a/tests/test_multi_tenant.py b/tests/test_multi_tenant.py deleted file mode 100644 index f3789a0..0000000 --- a/tests/test_multi_tenant.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Tests for multi-tenant functionality and data isolation. - -These tests verify that: -1. Organizations and memberships are created correctly -2. Data is properly scoped to organizations -3. Tenant isolation is enforced -4. Users cannot access data from other organizations -5. Row Level Security (RLS) works correctly -""" - -import pytest -from datetime import datetime, timedelta -from app import create_app, db -from app.models import ( - Organization, Membership, User, Project, Client, - TimeEntry, Task, Invoice, Comment -) -from app.utils.tenancy import ( - set_current_organization, get_current_organization_id, - user_has_access_to_organization, switch_organization, - scoped_query, ensure_organization_access -) -from app.utils.rls import set_rls_context, clear_rls_context, test_rls_isolation - - -@pytest.fixture -def app(): - """Create and configure a test Flask application.""" - app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'}) - - with app.app_context(): - db.create_all() - yield app - db.session.remove() - db.drop_all() - - -@pytest.fixture -def client(app): - """Create a test client for the Flask application.""" - return app.test_client() - - -@pytest.fixture -def organizations(app): - """Create test organizations.""" - with app.app_context(): - org1 = Organization( - name='Test Organization 1', - slug='test-org-1', - contact_email='org1@example.com' - ) - org2 = Organization( - name='Test Organization 2', - slug='test-org-2', - contact_email='org2@example.com' - ) - - db.session.add(org1) - db.session.add(org2) - db.session.commit() - - return org1, org2 - - -@pytest.fixture -def users(app, organizations): - """Create test users with memberships.""" - with app.app_context(): - org1, org2 = organizations - - # User 1: Member of org1 (admin) - user1 = User(username='user1', email='user1@example.com', role='admin') - user1.is_active = True - - # User 2: Member of org1 (regular member) - user2 = User(username='user2', email='user2@example.com', role='user') - user2.is_active = True - - # User 3: Member of org2 (admin) - user3 = User(username='user3', email='user3@example.com', role='admin') - user3.is_active = True - - # User 4: Member of both org1 and org2 - user4 = User(username='user4', email='user4@example.com', role='user') - user4.is_active = True - - db.session.add_all([user1, user2, user3, user4]) - db.session.commit() - - # Create memberships - memberships = [ - Membership(user1.id, org1.id, role='admin'), - Membership(user2.id, org1.id, role='member'), - Membership(user3.id, org2.id, role='admin'), - Membership(user4.id, org1.id, role='member'), - Membership(user4.id, org2.id, role='member'), - ] - - for m in memberships: - db.session.add(m) - - db.session.commit() - - return user1, user2, user3, user4 - - -class TestOrganizationModel: - """Test Organization model functionality.""" - - def test_create_organization(self, app): - """Test creating an organization.""" - with app.app_context(): - org = Organization( - name='Test Org', - slug='test-org', - contact_email='test@example.com' - ) - db.session.add(org) - db.session.commit() - - assert org.id is not None - assert org.name == 'Test Org' - assert org.slug == 'test-org' - assert org.is_active is True - - def test_organization_slug_auto_generation(self, app): - """Test that slug is auto-generated from name if not provided.""" - with app.app_context(): - org = Organization(name='My Test Organization') - db.session.add(org) - db.session.commit() - - assert org.slug == 'my-test-organization' - - def test_organization_slug_uniqueness(self, app): - """Test that duplicate slugs are handled correctly.""" - with app.app_context(): - org1 = Organization(name='Test Org') - db.session.add(org1) - db.session.commit() - - org2 = Organization(name='Test Org') # Same name - db.session.add(org2) - db.session.commit() - - assert org1.slug == 'test-org' - assert org2.slug == 'test-org-1' # Auto-incremented - - def test_organization_properties(self, app, organizations): - """Test organization properties.""" - with app.app_context(): - org1, org2 = organizations - - assert org1.member_count == 0 # No memberships yet - assert org1.admin_count == 0 - assert org1.project_count == 0 - - -class TestMembershipModel: - """Test Membership model functionality.""" - - def test_create_membership(self, app, organizations, users): - """Test creating a membership.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - # Verify memberships were created - assert Membership.user_is_member(user1.id, org1.id) is True - assert Membership.user_is_admin(user1.id, org1.id) is True - assert Membership.user_is_member(user1.id, org2.id) is False - - def test_user_multiple_organizations(self, app, organizations, users): - """Test that a user can belong to multiple organizations.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - # User4 is in both orgs - assert Membership.user_is_member(user4.id, org1.id) is True - assert Membership.user_is_member(user4.id, org2.id) is True - - memberships = Membership.get_user_active_memberships(user4.id) - assert len(memberships) == 2 - - def test_membership_role_changes(self, app, organizations, users): - """Test changing membership roles.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - membership = Membership.find_membership(user2.id, org1.id) - assert membership.role == 'member' - - membership.promote_to_admin() - assert membership.role == 'admin' - assert membership.is_admin is True - - membership.demote_from_admin() - assert membership.role == 'member' - assert membership.is_admin is False - - -class TestTenantDataIsolation: - """Test that data is properly isolated between tenants.""" - - def test_projects_isolation(self, app, organizations, users): - """Test that projects are isolated between organizations.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - # Create clients for each org - client1 = Client(name='Client 1', organization_id=org1.id) - client2 = Client(name='Client 2', organization_id=org2.id) - db.session.add_all([client1, client2]) - db.session.commit() - - # Create projects in each organization - project1 = Project( - name='Project 1', - organization_id=org1.id, - client_id=client1.id - ) - project2 = Project( - name='Project 2', - organization_id=org2.id, - client_id=client2.id - ) - - db.session.add_all([project1, project2]) - db.session.commit() - - # Verify projects exist - assert Project.query.count() == 2 - - # Set context to org1 and use scoped query - set_current_organization(org1.id, org1) - org1_projects = scoped_query(Project).all() - - assert len(org1_projects) == 1 - assert org1_projects[0].id == project1.id - - # Set context to org2 - set_current_organization(org2.id, org2) - org2_projects = scoped_query(Project).all() - - assert len(org2_projects) == 1 - assert org2_projects[0].id == project2.id - - def test_time_entries_isolation(self, app, organizations, users): - """Test that time entries are isolated between organizations.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - # Create necessary data - client1 = Client(name='Client 1', organization_id=org1.id) - client2 = Client(name='Client 2', organization_id=org2.id) - db.session.add_all([client1, client2]) - db.session.commit() - - project1 = Project(name='Project 1', organization_id=org1.id, client_id=client1.id) - project2 = Project(name='Project 2', organization_id=org2.id, client_id=client2.id) - db.session.add_all([project1, project2]) - db.session.commit() - - # Create time entries - now = datetime.utcnow() - entry1 = TimeEntry( - user_id=user1.id, - project_id=project1.id, - organization_id=org1.id, - start_time=now, - end_time=now + timedelta(hours=1) - ) - entry2 = TimeEntry( - user_id=user3.id, - project_id=project2.id, - organization_id=org2.id, - start_time=now, - end_time=now + timedelta(hours=1) - ) - - db.session.add_all([entry1, entry2]) - db.session.commit() - - # Test isolation - set_current_organization(org1.id, org1) - org1_entries = scoped_query(TimeEntry).all() - assert len(org1_entries) == 1 - assert org1_entries[0].organization_id == org1.id - - set_current_organization(org2.id, org2) - org2_entries = scoped_query(TimeEntry).all() - assert len(org2_entries) == 1 - assert org2_entries[0].organization_id == org2.id - - def test_ensure_organization_access(self, app, organizations, users): - """Test that ensure_organization_access prevents cross-org access.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - client1 = Client(name='Client 1', organization_id=org1.id) - client2 = Client(name='Client 2', organization_id=org2.id) - db.session.add_all([client1, client2]) - db.session.commit() - - project1 = Project(name='Project 1', organization_id=org1.id, client_id=client1.id) - project2 = Project(name='Project 2', organization_id=org2.id, client_id=client2.id) - db.session.add_all([project1, project2]) - db.session.commit() - - # Set context to org1 - set_current_organization(org1.id, org1) - - # Should succeed - project belongs to org1 - ensure_organization_access(project1) - - # Should fail - project belongs to org2 - with pytest.raises(PermissionError): - ensure_organization_access(project2) - - -class TestTenancyHelpers: - """Test tenancy helper functions.""" - - def test_user_has_access_to_organization(self, app, organizations, users): - """Test checking user access to organizations.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - assert user_has_access_to_organization(user1.id, org1.id) is True - assert user_has_access_to_organization(user1.id, org2.id) is False - assert user_has_access_to_organization(user4.id, org1.id) is True - assert user_has_access_to_organization(user4.id, org2.id) is True - - def test_switch_organization(self, app, organizations, users): - """Test switching between organizations.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - # User4 can switch between both orgs - # Note: This would normally be tested with a request context - # For unit tests, we just verify the logic - - set_current_organization(org1.id, org1) - assert get_current_organization_id() == org1.id - - set_current_organization(org2.id, org2) - assert get_current_organization_id() == org2.id - - -class TestClientNameUniqueness: - """Test that client names are unique per organization.""" - - def test_client_names_unique_per_org(self, app, organizations): - """Test that client names must be unique within an organization.""" - with app.app_context(): - org1, org2 = organizations - - # Create client in org1 - client1 = Client(name='Acme Corp', organization_id=org1.id) - db.session.add(client1) - db.session.commit() - - # Should be able to create same name in org2 - client2 = Client(name='Acme Corp', organization_id=org2.id) - db.session.add(client2) - db.session.commit() - - assert client1.name == client2.name - assert client1.organization_id != client2.organization_id - - # Should NOT be able to create duplicate in org1 - client3 = Client(name='Acme Corp', organization_id=org1.id) - db.session.add(client3) - - with pytest.raises(Exception): # Will raise IntegrityError - db.session.commit() - - -class TestInvoiceNumberUniqueness: - """Test that invoice numbers are unique per organization.""" - - def test_invoice_numbers_unique_per_org(self, app, organizations, users): - """Test that invoice numbers must be unique within an organization.""" - with app.app_context(): - org1, org2 = organizations - user1, user2, user3, user4 = users - - # Create necessary data - client1 = Client(name='Client 1', organization_id=org1.id) - client2 = Client(name='Client 2', organization_id=org2.id) - db.session.add_all([client1, client2]) - db.session.commit() - - project1 = Project(name='Project 1', organization_id=org1.id, client_id=client1.id) - project2 = Project(name='Project 2', organization_id=org2.id, client_id=client2.id) - db.session.add_all([project1, project2]) - db.session.commit() - - # Create invoice in org1 - invoice1 = Invoice( - invoice_number='INV-001', - organization_id=org1.id, - project_id=project1.id, - client_name='Client 1', - due_date=datetime.utcnow().date(), - created_by=user1.id, - client_id=client1.id - ) - db.session.add(invoice1) - db.session.commit() - - # Should be able to use same number in org2 - invoice2 = Invoice( - invoice_number='INV-001', - organization_id=org2.id, - project_id=project2.id, - client_name='Client 2', - due_date=datetime.utcnow().date(), - created_by=user3.id, - client_id=client2.id - ) - db.session.add(invoice2) - db.session.commit() - - assert invoice1.invoice_number == invoice2.invoice_number - assert invoice1.organization_id != invoice2.organization_id - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) -