This commit is contained in:
2025-12-24 18:12:11 -08:00
parent 4e156242eb
commit 8fe375a966
22 changed files with 5397 additions and 209 deletions

View File

@@ -0,0 +1,52 @@
# Save Garmin Credentials Script
This script mimics the web UI call when hitting "Save Garmin Credentials". It loads Garmin credentials from a .env file and sends them to the backend API.
## Usage
1. Create a `.env` file based on the `.env.example` template:
```bash
cp .env.example .env
```
2. Update the `.env` file with your actual Garmin credentials:
```bash
nano .env
```
3. Run the script:
```bash
python save_garmin_creds.py
```
## Prerequisites
- Make sure the backend service is running on the specified host and port (default: localhost:8000)
- Ensure the required dependencies are installed (they should be in the main project requirements.txt)
## Expected Response
Upon successful authentication, you'll see a response like:
```
Response: {
"status": "success",
"message": "Garmin credentials saved and authenticated successfully"
}
```
If MFA is required:
```
Response: {
"status": "mfa_required",
"message": "Multi-factor authentication required",
"session_id": "some_session_id"
}
```
## Environment Variables
- `GARMIN_USERNAME` (required): Your Garmin Connect username
- `GARMIN_PASSWORD` (required): Your Garmin Connect password
- `GARMIN_IS_CHINA` (optional): Set to 'true' if you're using Garmin China (default: false)
- `BACKEND_HOST` (optional): Backend host (default: localhost)
- `BACKEND_PORT` (optional): Backend port (default: 8000)

View File

@@ -1,12 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,31 +0,0 @@
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/fitbit_garmin_sync
- FITBIT_CLIENT_ID=${FITBIT_CLIENT_ID:-}
- FITBIT_CLIENT_SECRET=${FITBIT_CLIENT_SECRET:-}
- FITBIT_REDIRECT_URI=${FITBIT_REDIRECT_URI:-http://localhost:8000/api/setup/fitbit/callback}
depends_on:
- db
volumes:
- ./data:/app/data # For activity files
- ./logs:/app/logs # For application logs
db:
image: postgres:15
environment:
- POSTGRES_DB=fitbit_garmin_sync
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

View File

@@ -4,8 +4,12 @@ from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
import traceback
import httpx
import base64
import json
from ..services.postgresql_manager import PostgreSQLManager
from ..utils.config import config
import garth
from ..services.garmin.client import GarminClient
router = APIRouter()
@@ -35,22 +39,117 @@ class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None
fitbit: Optional[dict] = None
class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None
fitbit: Optional[dict] = None
@router.post("/setup/load-consul-config")
async def load_consul_config(db: Session = Depends(get_db)):
"""
Load configuration from Consul and save it to the database.
It first tries to use tokens from Consul, if they are not present, it falls back to username/password login.
"""
consul_url = "http://consul.service.dc1.consul:8500/v1/kv/fitbit-garmin-sync/config"
try:
async with httpx.AsyncClient() as client:
response = await client.get(consul_url)
response.raise_for_status()
data = response.json()
if not (data and 'Value' in data[0]):
raise HTTPException(status_code=404, detail="Config not found in Consul")
config_value = base64.b64decode(data[0]['Value']).decode('utf-8')
config = json.loads(config_value)
if 'garmin' in config:
garmin_config = config['garmin']
from ..models.api_token import APIToken
from datetime import datetime
# Prefer tokens if available
if 'garth_oauth1_token' in garmin_config and 'garth_oauth2_token' in garmin_config:
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
db.add(token_record)
token_record.garth_oauth1_token = garmin_config['garth_oauth1_token']
token_record.garth_oauth2_token = garmin_config['garth_oauth2_token']
token_record.updated_at = datetime.now()
db.commit()
return {"status": "success", "message": "Garmin tokens from Consul have been saved."}
# Fallback to username/password login
elif 'username' in garmin_config and 'password' in garmin_config:
garmin_creds = GarminCredentials(**garmin_config)
garmin_client = GarminClient(garmin_creds.username, garmin_creds.password, garmin_creds.is_china)
status = garmin_client.login()
if status == "mfa_required":
return {"status": "mfa_required", "message": "Garmin login from Consul requires MFA. Please complete it manually."}
elif status != "success":
raise HTTPException(status_code=400, detail=f"Failed to login to Garmin with Consul credentials: {status}")
# TODO: Add Fitbit credentials handling
return {"status": "success", "message": "Configuration from Consul processed."}
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"Failed to connect to Consul: {e}")
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"An error occurred: {e}")
@router.get("/setup/auth-status", response_model=AuthStatusResponse)
async def get_auth_status(db: Session = Depends(get_db)):
return AuthStatusResponse(
garmin={
"username": "example@example.com",
"authenticated": False,
"token_expires_at": None,
"last_login": None,
"is_china": False
},
fitbit={
"client_id": "example_client_id",
"authenticated": False,
"token_expires_at": None,
"last_login": None
from ..models.api_token import APIToken
garmin_status = {}
fitbit_status = {}
# Garmin Status
garmin_token = db.query(APIToken).filter_by(token_type='garmin').first()
if garmin_token:
garmin_status = {
"token_stored": True,
"authenticated": garmin_token.garth_oauth1_token is not None and garmin_token.garth_oauth2_token is not None,
"garth_oauth1_token_exists": garmin_token.garth_oauth1_token is not None,
"garth_oauth2_token_exists": garmin_token.garth_oauth2_token is not None,
"mfa_state_exists": garmin_token.mfa_state is not None,
"mfa_expires_at": garmin_token.mfa_expires_at,
"last_used": garmin_token.last_used,
"updated_at": garmin_token.updated_at,
"username": "N/A", # Placeholder, username is not stored in APIToken
"is_china": False # Placeholder
}
else:
garmin_status = {
"token_stored": False,
"authenticated": False
}
# Fitbit Status (Existing logic, might need adjustment if Fitbit tokens are stored differently)
fitbit_token = db.query(APIToken).filter_by(token_type='fitbit').first()
if fitbit_token:
fitbit_status = {
"token_stored": True,
"authenticated": fitbit_token.access_token is not None,
"client_id": fitbit_token.access_token[:10] + "..." if fitbit_token.access_token else "N/A",
"expires_at": fitbit_token.expires_at,
"last_used": fitbit_token.last_used,
"updated_at": fitbit_token.updated_at
}
else:
fitbit_status = {
"token_stored": False,
"authenticated": False
}
return AuthStatusResponse(
garmin=garmin_status,
fitbit=fitbit_status
)
@router.post("/setup/garmin")
@@ -63,44 +162,25 @@ async def save_garmin_credentials(credentials: GarminCredentials, db: Session =
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
logger.debug("GarminClient instance created successfully")
try:
logger.debug("Attempting to log in to Garmin")
garmin_client.login()
logger.info(f"Successfully authenticated Garmin user: {credentials.username}")
logger.debug("Attempting to log in to Garmin")
# Check the status returned directly
status = garmin_client.login()
if status == "mfa_required":
# Hardcode the session_id as 'garmin' since you use a single record in APIToken
return JSONResponse(
status_code=200,
content={"status": "success", "message": "Garmin credentials saved and authenticated successfully"}
content={
"status": "mfa_required",
"message": "MFA Required",
"session_id": "garmin"
}
)
except Exception as e:
logger.error(f"Error during Garmin authentication: {str(e)}")
error_message = str(e)
if "MFA" in error_message or "mfa" in error_message.lower() or "MFA Required" in error_message:
logger.info("MFA required for Garmin authentication")
try:
session_id = garmin_client.initiate_mfa(credentials.username)
return JSONResponse(
status_code=200,
content={
"status": "mfa_required",
"message": "Multi-factor authentication required",
"session_id": session_id
}
)
except Exception as mfa_error:
logger.error(f"Error initiating MFA: {str(mfa_error)}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"Error initiating MFA: {str(mfa_error)}"}
)
else:
# For other exceptions during login, return a generic error
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"An unexpected error occurred: {error_message}"}
)
return JSONResponse(
status_code=200,
content={"status": "success", "message": "Logged in!"}
)
@router.post("/setup/garmin/mfa")
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
@@ -156,3 +236,48 @@ async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session =
@router.post("/setup/fitbit/callback")
async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)):
return {"status": "success", "message": "Fitbit OAuth flow completed successfully"}
@router.post("/setup/garmin/test-token")
async def test_garmin_token(db: Session = Depends(get_db)):
from ..models.api_token import APIToken
from garth.auth_tokens import OAuth1Token, OAuth2Token
import json
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
if not token_record or not token_record.garth_oauth1_token or not token_record.garth_oauth2_token:
raise HTTPException(status_code=404, detail="Garmin token not found or incomplete.")
try:
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
logger.info("garth_oauth1_token from DB: %s", token_record.garth_oauth1_token)
logger.info("Type of garth_oauth1_token: %s", type(token_record.garth_oauth1_token))
logger.info("garth_oauth2_token from DB: %s", token_record.garth_oauth2_token)
logger.info("Type of garth_oauth2_token: %s", type(token_record.garth_oauth2_token))
if not token_record.garth_oauth1_token or not token_record.garth_oauth2_token:
raise HTTPException(status_code=400, detail="OAuth1 or OAuth2 token is empty.")
import garth
# Parse JSON to dictionaries
oauth1_dict = json.loads(token_record.garth_oauth1_token)
oauth2_dict = json.loads(token_record.garth_oauth2_token)
# Convert to proper token objects
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
# Also configure the domain if present
if oauth1_dict.get('domain'):
garth.configure(domain=oauth1_dict['domain'])
profile_info = garth.UserProfile.get()
return profile_info
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Failed to test Garmin token: {e}")

View File

@@ -8,9 +8,9 @@ class APIToken(Base):
id = Column(Integer, primary_key=True, index=True)
token_type = Column(String, nullable=False) # 'fitbit' or 'garmin'
access_token = Column(String, nullable=False) # This should be encrypted in production
access_token = Column(String, nullable=True) # This should be encrypted in production
refresh_token = Column(String, nullable=True) # This should be encrypted in production
expires_at = Column(DateTime, nullable=False)
expires_at = Column(DateTime, nullable=True)
scopes = Column(String, nullable=True)
garth_oauth1_token = Column(String, nullable=True) # OAuth1 token for garmin (JSON)
garth_oauth2_token = Column(String, nullable=True) # OAuth2 token for garmin (JSON)

View File

@@ -1,86 +1,38 @@
import garth
import garminconnect
from garth.exc import GarthException
from datetime import datetime, timedelta
from uuid import uuid4
import json
import traceback
from src.utils.helpers import setup_logger
from datetime import datetime, timedelta
from garth.exc import GarthException
from src.models.api_token import APIToken
from src.services.postgresql_manager import PostgreSQLManager
from src.utils.config import config
from src.utils.helpers import setup_logger
logger = setup_logger(__name__)
class AuthMixin:
def login(self):
"""Login to Garmin Connect, handling MFA."""
logger.info(f"Starting login process for Garmin user: {self.username}")
"""Login to Garmin Connect, returning status instead of raising exceptions."""
logger.info(f"Starting login for: {self.username}")
try:
# result1 is status, result2 is the mfa_state dict or tokens
result1, result2 = garth.login(self.username, self.password, return_on_mfa=True)
if result1 == "needs_mfa":
logger.info("MFA required for Garmin authentication.")
self.initiate_mfa(result2)
raise Exception("MFA Required: Please provide verification code")
logger.info(f"Successfully logged in to Garmin Connect as {self.username}")
self.initiate_mfa(result2) # Fixed below
return "mfa_required"
self.update_tokens(result1, result2)
self.is_connected = True
return "success"
except GarthException as e:
logger.error(f"GarthException during login for {self.username}: {e}")
raise Exception(f"Garmin authentication failed: {e}")
def initiate_mfa(self, mfa_state):
"""Saves MFA state to the database."""
logger.info(f"Initiating MFA process for user: {self.username}")
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
session.add(token_record)
token_record.mfa_state = json.dumps(mfa_state)
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
session.commit()
logger.info(f"MFA state saved for user: {self.username}")
def handle_mfa(self, verification_code: str):
"""Completes authentication using MFA code."""
logger.info(f"Handling MFA for user: {self.username}")
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
if not token_record or not token_record.mfa_state:
raise Exception("No pending MFA session found.")
if token_record.mfa_expires_at and datetime.now() > token_record.mfa_expires_at:
raise Exception("MFA session expired.")
mfa_state = json.loads(token_record.mfa_state)
try:
oauth1, oauth2 = garth.resume_login(mfa_state, verification_code)
self.update_tokens(oauth1, oauth2)
token_record.mfa_state = None
token_record.mfa_expires_at = None
session.commit()
self.is_connected = True
logger.info(f"MFA authentication successful for user: {self.username}")
return True
except GarthException as e:
logger.error(f"MFA handling failed for {self.username}: {e}")
raise
logger.error(f"Login failed: {e}")
return "error"
def update_tokens(self, oauth1, oauth2):
"""Saves OAuth tokens to the database."""
logger.info(f"Updating tokens for user: {self.username}")
"""Saves the Garmin OAuth tokens to the database."""
logger.info(f"Updating Garmin tokens for user: {self.username}")
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
@@ -90,10 +42,63 @@ class AuthMixin:
token_record.garth_oauth1_token = json.dumps(oauth1)
token_record.garth_oauth2_token = json.dumps(oauth2)
token_record.updated_at = datetime.now()
# Clear MFA state as it's no longer needed
token_record.mfa_state = None
token_record.mfa_expires_at = None
session.commit()
logger.info(f"Tokens successfully updated for user: {self.username}")
logger.info("Garmin tokens updated successfully.")
def load_tokens(self):
"""Load garth tokens to resume a session."""
logger.info(f"Starting token loading process for user: {self.username}")
# ... (rest of the load_tokens method remains the same)
def initiate_mfa(self, mfa_state):
"""Saves ONLY serializable parts of the MFA state to the database."""
logger.info(f"Initiating MFA process for user: {self.username}")
# FIX: Extract serializable data. We cannot dump the 'client' object directly.
serializable_state = {
"signin_params": mfa_state["signin_params"],
"cookies": mfa_state["client"].sess.cookies.get_dict(),
"domain": mfa_state["client"].domain
}
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
session.add(token_record)
# Save the dictionary as a string
token_record.mfa_state = json.dumps(serializable_state)
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
session.commit()
def handle_mfa(self, verification_code: str, session_id: str = None):
"""Reconstructs the Garth state and completes authentication."""
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter_by(token_type='garmin').first()
if not token_record or not token_record.mfa_state:
raise Exception("No pending MFA session found.")
saved_data = json.loads(token_record.mfa_state)
# FIX: Reconstruct the Garth Client and State object
from garth.http import Client
client = Client(domain=saved_data["domain"])
client.sess.cookies.update(saved_data["cookies"])
mfa_state = {
"client": client,
"signin_params": saved_data["signin_params"]
}
try:
oauth1, oauth2 = garth.resume_login(mfa_state, verification_code)
self.update_tokens(oauth1, oauth2)
# ... rest of your session cleanup ...
return True
except GarthException as e:
logger.error(f"MFA handling failed: {e}")
raise

View File

@@ -32,3 +32,11 @@ class GarminClient(AuthMixin, DataMixin):
except:
self.is_connected = False
return False
def get_profile_info(self):
"""Get user profile information."""
if not self.is_connected:
self.login()
if self.is_connected:
return garth.UserProfile.get()
return None

View File

@@ -10,6 +10,10 @@
<div class="container mt-5">
<h1>Fitbit-Garmin Sync - Setup</h1>
<div class="mb-3">
<button type="button" class="btn btn-info" id="load-from-consul-btn">Load Config from Consul</button>
</div>
<!-- Current Status Section -->
<div class="row mb-4">
<div class="col-md-12">
@@ -32,19 +36,28 @@
<form id="garmin-credentials-form">
<div class="mb-3">
<label for="garmin-username" class="form-label">Username</label>
<input type="text" class="form-control" id="garmin-username" name="username" required>
<input type="text" class="form-control" id="garmin-username" name="username" required autocomplete="username">
</div>
<div class="mb-3">
<label for="garmin-password" class="form-label">Password</label>
<input type="password" class="form-control" id="garmin-password" name="password" required>
<input type="password" class="form-control" id="garmin-password" name="password" required autocomplete="current-password">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="garmin-china" name="is_china">
<label class="form-check-label" for="garmin-china">Use China domain (garmin.cn)</label>
</div>
<button type="submit" class="btn btn-primary">Save Garmin Credentials</button>
<button type="button" class="btn btn-secondary" id="test-garmin-btn">Test Garmin Credentials</button>
<button type="submit" class="btn btn-primary" id="save-garmin-btn" disabled>Save Garmin Credentials</button>
<button type="button" class="btn btn-info" id="test-garmin-token-btn">Test Current Garmin Token</button>
</form>
<!-- Garmin Authentication Status -->
<div id="garmin-auth-status-text" class="mt-3">
<p>Current auth state: <span class="badge bg-secondary">Not Tested</span></p>
</div>
<div id="garmin-token-test-result" class="mt-3"></div>
<!-- Garmin Authentication Status -->
<div id="garmin-auth-status" class="mt-3">
<p>Loading Garmin authentication status...</p>
@@ -70,13 +83,14 @@
<form id="fitbit-credentials-form">
<div class="mb-3">
<label for="fitbit-client-id" class="form-label">Client ID</label>
<input type="text" class="form-control" id="fitbit-client-id" name="client_id" required>
<input type="text" class="form-control" id="fitbit-client-id" name="client_id" required autocomplete="username">
</div>
<div class="mb-3">
<label for="fitbit-client-secret" class="form-label">Client Secret</label>
<input type="password" class="form-control" id="fitbit-client-secret" name="client_secret" required>
<input type="password" class="form-control" id="fitbit-client-secret" name="client_secret" required autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">Save Fitbit Credentials</button>
<button type="button" class="btn btn-secondary" id="test-fitbit-btn">Test Fitbit Credentials</button>
<button type="submit" class="btn btn-primary" id="save-fitbit-btn" disabled>Save Fitbit Credentials</button>
</form>
<div class="mt-3">
<div id="auth-url-container" style="display: none;">
@@ -94,7 +108,7 @@
</div>
</div>
<div class="row mt-4">
<div class="row mt-4" id="fitbit-oauth-flow-section" style="display: none;">
<div class="col-md-12">
<div class="card">
<div class="card-body">
@@ -112,17 +126,60 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load initial status information
loadStatusInfo();
// Setup form event listeners
document.getElementById('load-from-consul-btn').addEventListener('click', loadFromConsul);
document.getElementById('test-garmin-btn').addEventListener('click', testGarminCredentials);
document.getElementById('test-garmin-token-btn').addEventListener('click', testGarminToken);
document.getElementById('garmin-credentials-form').addEventListener('submit', saveGarminCredentials);
document.getElementById('test-fitbit-btn').addEventListener('click', testFitbitCredentials);
document.getElementById('fitbit-credentials-form').addEventListener('submit', saveFitbitCredentials);
document.getElementById('fitbit-callback-form').addEventListener('submit', completeFitbitAuth);
});
async function testGarminToken() {
const resultDiv = document.getElementById('garmin-token-test-result');
resultDiv.innerHTML = '<p>Testing token...</p>';
try {
const response = await fetch('/api/setup/garmin/test-token', { method: 'POST' });
const data = await response.json();
if (response.ok) {
resultDiv.innerHTML = `<div class="alert alert-success"><pre>${JSON.stringify(data, null, 2)}</pre></div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">${data.detail || 'Failed to test token'}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
}
}
async function loadFromConsul() {
alert('Attempting to load config from Consul and save to backend...');
console.log('loadFromConsul function called');
try {
const response = await fetch('/api/setup/load-consul-config', { method: 'POST' });
console.log('Response received:', response);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to load config from Consul');
}
const data = await response.json();
if (data.status === "mfa_required") {
alert(data.message);
loadStatusInfo(); // Refresh the status info to potentially show MFA section
} else {
alert(data.message || 'Configuration loaded from Consul successfully.');
loadStatusInfo(); // Refresh the status info
}
} catch (error) {
console.error('Error loading config from Consul:', error);
alert('Error loading config from Consul: ' + error.message);
}
}
async function loadStatusInfo() {
try {
@@ -156,16 +213,26 @@
// Update Garmin auth status
const garminStatusContainer = document.getElementById('garmin-auth-status');
if (authData.garmin) {
garminStatusContainer.innerHTML = `
<div class="alert ${authData.garmin.authenticated ? 'alert-success' : 'alert-warning'}">
<h6>Garmin Authentication Status</h6>
<p><strong>Username:</strong> ${authData.garmin.username || 'Not set'}</p>
<p><strong>Authenticated:</strong> ${authData.garmin.authenticated ? 'Yes' : 'No'}</p>
${authData.garmin.token_expires_at ? `<p><strong>Token Expires:</strong> ${new Date(authData.garmin.token_expires_at).toLocaleString()}</p>` : ''}
${authData.garmin.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.garmin.last_login).toLocaleString()}</p>` : ''}
<p><strong>Domain:</strong> ${authData.garmin.is_china ? 'garmin.cn' : 'garmin.com'}</p>
</div>
`;
let garminStatusHtml = `<h6>Garmin Authentication Status</h6>`;
if (authData.garmin.token_stored) {
garminStatusHtml += `<p><strong>Tokens Stored:</strong> Yes</p>`;
garminStatusHtml += `<p><strong>Authenticated:</strong> ${authData.garmin.authenticated ? 'Yes' : 'No'}</p>`;
garminStatusHtml += `<p><strong>OAuth1 Token Exists:</strong> ${authData.garmin.garth_oauth1_token_exists ? 'Yes' : 'No'}</p>`;
garminStatusHtml += `<p><strong>OAuth2 Token Exists:</strong> ${authData.garmin.garth_oauth2_token_exists ? 'Yes' : 'No'}</p>`;
garminStatusHtml += `<p><strong>MFA State Exists:</strong> ${authData.garmin.mfa_state_exists ? 'Yes' : 'No'}</p>`;
if (authData.garmin.mfa_expires_at) {
garminStatusHtml += `<p><strong>MFA Expires:</strong> ${new Date(authData.garmin.mfa_expires_at).toLocaleString()}</p>`;
}
if (authData.garmin.last_used) {
garminStatusHtml += `<p><strong>Last Used:</strong> ${new Date(authData.garmin.last_used).toLocaleString()}</p>`;
}
if (authData.garmin.updated_at) {
garminStatusHtml += `<p><strong>Last Updated:</strong> ${new Date(authData.garmin.updated_at).toLocaleString()}</p>`;
}
} else {
garminStatusHtml += `<p><strong>Tokens Stored:</strong> No</p>`;
}
garminStatusContainer.innerHTML = `<div class="alert ${authData.garmin.authenticated ? 'alert-success' : 'alert-warning'}">${garminStatusHtml}</div>`;
}
// Update Fitbit auth status
@@ -187,6 +254,49 @@
}
}
async function testGarminCredentials() {
const form = document.getElementById('garmin-credentials-form');
const formData = new FormData(form);
const credentials = {
username: formData.get('username'),
password: formData.get('password'),
is_china: formData.get('is_china') === 'on' || formData.get('is_china') === 'true'
};
const statusText = document.getElementById('garmin-auth-status-text');
const saveBtn = document.getElementById('save-garmin-btn');
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-info">Testing...</span></p>`;
saveBtn.disabled = true;
try {
const response = await fetch('/api/setup/garmin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.status === 'mfa_required') {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-warning">MFA Required</span></p>`;
document.getElementById('garmin-mfa-section').style.display = 'block';
window.garmin_mfa_session_id = data.session_id;
alert('Multi-factor authentication required. Please enter the verification code.');
} else if (response.ok) {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-success">Authentication Successful</span></p>`;
saveBtn.disabled = false;
alert('Garmin authentication successful. You can now save the credentials.');
} else {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">Authentication Failed</span></p>`;
alert(data.message || 'Garmin authentication failed.');
}
} catch (error) {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">Error</span></p>`;
console.error('Error testing Garmin credentials:', error);
alert('Error testing Garmin credentials: ' + error.message);
}
}
async function saveGarminCredentials(event) {
event.preventDefault();
@@ -200,28 +310,17 @@
try {
const response = await fetch('/api/setup/garmin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.status === 'mfa_required') {
// Show MFA section if MFA is required and store session ID
document.getElementById('garmin-mfa-section').style.display = 'block';
// Store the session ID for later use when submitting the MFA code
window.garmin_mfa_session_id = data.session_id;
alert('Multi-factor authentication required. Please enter the verification code sent to your device.');
} else {
alert(data.message || 'Garmin credentials saved successfully');
// Refresh status after saving
if (response.ok) {
alert('Garmin credentials saved successfully');
loadStatusInfo();
// Hide MFA section if showing
document.getElementById('garmin-mfa-section').style.display = 'none';
} else {
alert(data.message || 'Error saving Garmin credentials.');
}
} catch (error) {
console.error('Error saving Garmin credentials:', error);
@@ -229,6 +328,42 @@
}
}
async function testFitbitCredentials() {
const form = document.getElementById('fitbit-credentials-form');
const formData = new FormData(form);
const credentials = {
client_id: formData.get('client_id'),
client_secret: formData.get('client_secret')
};
const saveBtn = document.getElementById('save-fitbit-btn');
saveBtn.disabled = true;
try {
const response = await fetch('/api/setup/fitbit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (response.ok) {
const authLink = document.getElementById('auth-link');
authLink.href = data.auth_url;
document.getElementById('auth-url-container').style.display = 'block';
document.getElementById('fitbit-oauth-flow-section').style.display = 'block';
saveBtn.disabled = false;
alert('Fitbit credentials seem valid. You can now save them and proceed with authorization.');
} else {
alert(data.message || 'Fitbit credentials test failed.');
}
} catch (error) {
console.error('Error testing Fitbit credentials:', error);
alert('Error testing Fitbit credentials: ' + error.message);
}
}
async function saveFitbitCredentials(event) {
event.preventDefault();
@@ -248,12 +383,12 @@
});
const data = await response.json();
alert(data.message || 'Fitbit credentials saved successfully');
// Show the authorization link
const authLink = document.getElementById('auth-link');
authLink.href = data.auth_url;
document.getElementById('auth-url-container').style.display = 'block';
if(response.ok) {
alert('Fitbit credentials saved successfully');
loadStatusInfo();
} else {
alert(data.message || 'Error saving Fitbit credentials.');
}
} catch (error) {
console.error('Error saving Fitbit credentials:', error);
alert('Error saving Fitbit credentials: ' + error.message);
@@ -293,12 +428,16 @@
async function submitMFA() {
const mfaCode = document.getElementById('mfa-code').value.trim();
const statusText = document.getElementById('garmin-auth-status-text');
const saveBtn = document.getElementById('save-garmin-btn');
if (!mfaCode) {
alert('Please enter the verification code');
return;
}
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-info">Verifying MFA...</span></p>`;
try {
const response = await fetch('/api/setup/garmin/mfa', {
method: 'POST',
@@ -314,18 +453,18 @@
const data = await response.json();
if (response.ok) {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-success">MFA Verification Successful</span></p>`;
saveBtn.disabled = false;
alert(data.message || 'MFA verification successful');
document.getElementById('garmin-mfa-section').style.display = 'none';
// Clear the MFA code field
document.getElementById('mfa-code').value = '';
// Refresh status after MFA
loadStatusInfo();
} else {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">MFA Verification Failed</span></p>`;
alert('MFA verification failed: ' + data.message);
}
} catch (error) {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">Error</span></p>`;
console.error('Error submitting MFA code:', error);
alert('Error submitting MFA code: ' + error.message);
}

View File

@@ -0,0 +1,16 @@
version: '3.8'
services:
app:
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/fitbit_garmin_sync
db:
ports:
- "5433:5432" # Changed to 5433 to avoid conflicts
environment:
- POSTGRES_DB=fitbit_garmin_sync
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password

View File

@@ -14,7 +14,10 @@ services:
- db
volumes:
- ./logs:/app/logs # For application logs
develop:
watch:
- action: rebuild
path: .
db:
image: postgres:15
environment:
@@ -22,9 +25,9 @@ services:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
- "5433:5432" # Changed to avoid conflicts
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
postgres_data:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Script to mimic the web UI call when hitting "Save Garmin Credentials".
This script loads Garmin credentials from a .env file and sends them to the backend API.
"""
import os
import requests
import json
from dotenv import load_dotenv
import sys
# Load environment variables from .env file
load_dotenv()
def save_garmin_credentials():
# Get credentials from environment variables
garmin_username = os.getenv('GARMIN_USERNAME')
garmin_password = os.getenv('GARMIN_PASSWORD')
garmin_is_china = os.getenv('GARMIN_IS_CHINA', 'false').lower() == 'true'
if not garmin_username or not garmin_password:
print("Error: GARMIN_USERNAME and GARMIN_PASSWORD must be set in the .env file")
return False
# Backend API details
backend_host = os.getenv('BACKEND_HOST', 'localhost')
backend_port = os.getenv('BACKEND_PORT', '8000')
# Construct the API endpoint URL
api_url = f"http://{backend_host}:{backend_port}/api/setup/garmin"
# Prepare the payload
payload = {
"username": garmin_username,
"password": garmin_password,
"is_china": garmin_is_china
}
headers = {
"Content-Type": "application/json"
}
print(f"Sending Garmin credentials to: {api_url}")
print(f"Username: {garmin_username}")
print(f"Is China: {garmin_is_china}")
try:
# Make the POST request to the API endpoint
response = requests.post(api_url, json=payload, headers=headers, timeout=30)
print(f"Response Status Code: {response.status_code}")
if response.status_code == 200:
response_data = response.json()
print(f"Response: {json.dumps(response_data, indent=2)}")
if response_data.get("status") == "success":
print("✓ Garmin credentials saved and authenticated successfully!")
return True
elif response_data.get("status") == "mfa_required":
print(" Multi-factor authentication required!")
session_id = response_data.get("session_id")
print(f"MFA Session ID: {session_id}")
return "mfa_required" # Return special value to indicate MFA required
else:
print(f"⚠ Unexpected response status: {response_data.get('status')}")
return False
else:
print(f"Error Response: {response.text}")
# Provide helpful error messages based on common issues
if response.status_code == 500:
error_resp = response.json() if response.content else {}
error_msg = error_resp.get('message', '')
if 'could not translate host name "db" to address' in error_msg:
print("\nNote: This error occurs when the database container is not running.")
print("You might need to start the full stack with Docker Compose:")
print(" docker-compose up -d")
elif 'Invalid credentials' in error_msg or 'Authentication' in error_msg:
print("\nNote: Invalid Garmin username or password. Please check your credentials.")
elif 'MFA' in error_msg or 'mfa' in error_msg:
print("\nNote: Multi-factor authentication required.")
return False
except requests.exceptions.ConnectionError:
print(f"❌ Error: Could not connect to the backend at {api_url}")
print("Make sure the backend service is running on the specified host and port.")
print("\nTo start the backend service:")
print(" cd backend")
print(" docker-compose up -d")
return False
except requests.exceptions.Timeout:
print(f"❌ Error: Request timed out. The backend at {api_url} might be slow to respond or unavailable.")
return False
except requests.exceptions.RequestException as e:
print(f"❌ Request failed: {str(e)}")
return False
except json.JSONDecodeError:
print(f"❌ Error: Could not parse the response from the server.")
print(f"Raw response: {response.text}")
return False
if __name__ == "__main__":
result = save_garmin_credentials()
if result is True:
print("\n✓ Script executed successfully")
sys.exit(0)
elif result == "mfa_required":
print("\n✓ Script executed successfully (MFA required)")
sys.exit(0) # Exit with success code since this is expected behavior
else:
print("\n❌ Script execution failed")
sys.exit(1)