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 typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import traceback import traceback
import httpx
import base64
import json
from ..services.postgresql_manager import PostgreSQLManager from ..services.postgresql_manager import PostgreSQLManager
from ..utils.config import config from ..utils.config import config
import garth
from ..services.garmin.client import GarminClient from ..services.garmin.client import GarminClient
router = APIRouter() router = APIRouter()
@@ -35,22 +39,117 @@ class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None garmin: Optional[dict] = None
fitbit: 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) @router.get("/setup/auth-status", response_model=AuthStatusResponse)
async def get_auth_status(db: Session = Depends(get_db)): async def get_auth_status(db: Session = Depends(get_db)):
return AuthStatusResponse( from ..models.api_token import APIToken
garmin={
"username": "example@example.com", garmin_status = {}
"authenticated": False, fitbit_status = {}
"token_expires_at": None,
"last_login": None, # Garmin Status
"is_china": False garmin_token = db.query(APIToken).filter_by(token_type='garmin').first()
}, if garmin_token:
fitbit={ garmin_status = {
"client_id": "example_client_id", "token_stored": True,
"authenticated": False, "authenticated": garmin_token.garth_oauth1_token is not None and garmin_token.garth_oauth2_token is not None,
"token_expires_at": None, "garth_oauth1_token_exists": garmin_token.garth_oauth1_token is not None,
"last_login": 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") @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) garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
logger.debug("GarminClient instance created successfully") logger.debug("GarminClient instance created successfully")
try: logger.debug("Attempting to log in to Garmin")
logger.debug("Attempting to log in to Garmin") # Check the status returned directly
garmin_client.login() status = garmin_client.login()
logger.info(f"Successfully authenticated Garmin user: {credentials.username}") if status == "mfa_required":
# Hardcode the session_id as 'garmin' since you use a single record in APIToken
return JSONResponse( return JSONResponse(
status_code=200, 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)}") return JSONResponse(
status_code=200,
error_message = str(e) content={"status": "success", "message": "Logged in!"}
)
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}"}
)
@router.post("/setup/garmin/mfa") @router.post("/setup/garmin/mfa")
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)): 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") @router.post("/setup/fitbit/callback")
async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)): async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)):
return {"status": "success", "message": "Fitbit OAuth flow completed successfully"} 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) id = Column(Integer, primary_key=True, index=True)
token_type = Column(String, nullable=False) # 'fitbit' or 'garmin' 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 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) scopes = Column(String, nullable=True)
garth_oauth1_token = Column(String, nullable=True) # OAuth1 token for garmin (JSON) garth_oauth1_token = Column(String, nullable=True) # OAuth1 token for garmin (JSON)
garth_oauth2_token = Column(String, nullable=True) # OAuth2 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 garth
import garminconnect
from garth.exc import GarthException
from datetime import datetime, timedelta
from uuid import uuid4
import json import json
import traceback from datetime import datetime, timedelta
from garth.exc import GarthException
from src.utils.helpers import setup_logger
from src.models.api_token import APIToken from src.models.api_token import APIToken
from src.services.postgresql_manager import PostgreSQLManager from src.services.postgresql_manager import PostgreSQLManager
from src.utils.config import config from src.utils.config import config
from src.utils.helpers import setup_logger
logger = setup_logger(__name__) logger = setup_logger(__name__)
class AuthMixin: class AuthMixin:
def login(self): def login(self):
"""Login to Garmin Connect, handling MFA.""" """Login to Garmin Connect, returning status instead of raising exceptions."""
logger.info(f"Starting login process for Garmin user: {self.username}") logger.info(f"Starting login for: {self.username}")
try: try:
# result1 is status, result2 is the mfa_state dict or tokens
result1, result2 = garth.login(self.username, self.password, return_on_mfa=True) result1, result2 = garth.login(self.username, self.password, return_on_mfa=True)
if result1 == "needs_mfa": if result1 == "needs_mfa":
logger.info("MFA required for Garmin authentication.") logger.info("MFA required for Garmin authentication.")
self.initiate_mfa(result2) self.initiate_mfa(result2) # Fixed below
raise Exception("MFA Required: Please provide verification code") return "mfa_required"
logger.info(f"Successfully logged in to Garmin Connect as {self.username}")
self.update_tokens(result1, result2) self.update_tokens(result1, result2)
self.is_connected = True self.is_connected = True
return "success"
except GarthException as e: except GarthException as e:
logger.error(f"GarthException during login for {self.username}: {e}") logger.error(f"Login failed: {e}")
raise Exception(f"Garmin authentication failed: {e}") return "error"
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
def update_tokens(self, oauth1, oauth2): def update_tokens(self, oauth1, oauth2):
"""Saves OAuth tokens to the database.""" """Saves the Garmin OAuth tokens to the database."""
logger.info(f"Updating tokens for user: {self.username}") logger.info(f"Updating Garmin tokens for user: {self.username}")
db_manager = PostgreSQLManager(config.DATABASE_URL) db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session: with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter_by(token_type='garmin').first() 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_oauth1_token = json.dumps(oauth1)
token_record.garth_oauth2_token = json.dumps(oauth2) 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() session.commit()
logger.info(f"Tokens successfully updated for user: {self.username}") logger.info("Garmin tokens updated successfully.")
def load_tokens(self): def initiate_mfa(self, mfa_state):
"""Load garth tokens to resume a session.""" """Saves ONLY serializable parts of the MFA state to the database."""
logger.info(f"Starting token loading process for user: {self.username}") logger.info(f"Initiating MFA process for user: {self.username}")
# ... (rest of the load_tokens method remains the same)
# 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: except:
self.is_connected = False self.is_connected = False
return 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"> <div class="container mt-5">
<h1>Fitbit-Garmin Sync - Setup</h1> <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 --> <!-- Current Status Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-12"> <div class="col-md-12">
@@ -32,19 +36,28 @@
<form id="garmin-credentials-form"> <form id="garmin-credentials-form">
<div class="mb-3"> <div class="mb-3">
<label for="garmin-username" class="form-label">Username</label> <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>
<div class="mb-3"> <div class="mb-3">
<label for="garmin-password" class="form-label">Password</label> <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>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="garmin-china" name="is_china"> <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> <label class="form-check-label" for="garmin-china">Use China domain (garmin.cn)</label>
</div> </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> </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 --> <!-- Garmin Authentication Status -->
<div id="garmin-auth-status" class="mt-3"> <div id="garmin-auth-status" class="mt-3">
<p>Loading Garmin authentication status...</p> <p>Loading Garmin authentication status...</p>
@@ -70,13 +83,14 @@
<form id="fitbit-credentials-form"> <form id="fitbit-credentials-form">
<div class="mb-3"> <div class="mb-3">
<label for="fitbit-client-id" class="form-label">Client ID</label> <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>
<div class="mb-3"> <div class="mb-3">
<label for="fitbit-client-secret" class="form-label">Client Secret</label> <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> </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> </form>
<div class="mt-3"> <div class="mt-3">
<div id="auth-url-container" style="display: none;"> <div id="auth-url-container" style="display: none;">
@@ -94,7 +108,7 @@
</div> </div>
</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="col-md-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -112,17 +126,60 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Load initial status information // Load initial status information
loadStatusInfo(); loadStatusInfo();
// Setup form event listeners // 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('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-credentials-form').addEventListener('submit', saveFitbitCredentials);
document.getElementById('fitbit-callback-form').addEventListener('submit', completeFitbitAuth); 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() { async function loadStatusInfo() {
try { try {
@@ -156,16 +213,26 @@
// Update Garmin auth status // Update Garmin auth status
const garminStatusContainer = document.getElementById('garmin-auth-status'); const garminStatusContainer = document.getElementById('garmin-auth-status');
if (authData.garmin) { if (authData.garmin) {
garminStatusContainer.innerHTML = ` let garminStatusHtml = `<h6>Garmin Authentication Status</h6>`;
<div class="alert ${authData.garmin.authenticated ? 'alert-success' : 'alert-warning'}"> if (authData.garmin.token_stored) {
<h6>Garmin Authentication Status</h6> garminStatusHtml += `<p><strong>Tokens Stored:</strong> Yes</p>`;
<p><strong>Username:</strong> ${authData.garmin.username || 'Not set'}</p> garminStatusHtml += `<p><strong>Authenticated:</strong> ${authData.garmin.authenticated ? 'Yes' : 'No'}</p>`;
<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>`;
${authData.garmin.token_expires_at ? `<p><strong>Token Expires:</strong> ${new Date(authData.garmin.token_expires_at).toLocaleString()}</p>` : ''} garminStatusHtml += `<p><strong>OAuth2 Token Exists:</strong> ${authData.garmin.garth_oauth2_token_exists ? 'Yes' : 'No'}</p>`;
${authData.garmin.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.garmin.last_login).toLocaleString()}</p>` : ''} garminStatusHtml += `<p><strong>MFA State Exists:</strong> ${authData.garmin.mfa_state_exists ? 'Yes' : 'No'}</p>`;
<p><strong>Domain:</strong> ${authData.garmin.is_china ? 'garmin.cn' : 'garmin.com'}</p> if (authData.garmin.mfa_expires_at) {
</div> 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 // 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) { async function saveGarminCredentials(event) {
event.preventDefault(); event.preventDefault();
@@ -200,28 +310,17 @@
try { try {
const response = await fetch('/api/setup/garmin', { const response = await fetch('/api/setup/garmin', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
}); });
const data = await response.json(); const data = await response.json();
if (data.status === 'mfa_required') { if (response.ok) {
// Show MFA section if MFA is required and store session ID alert('Garmin credentials saved successfully');
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
loadStatusInfo(); loadStatusInfo();
} else {
// Hide MFA section if showing alert(data.message || 'Error saving Garmin credentials.');
document.getElementById('garmin-mfa-section').style.display = 'none';
} }
} catch (error) { } catch (error) {
console.error('Error saving Garmin credentials:', 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) { async function saveFitbitCredentials(event) {
event.preventDefault(); event.preventDefault();
@@ -248,12 +383,12 @@
}); });
const data = await response.json(); const data = await response.json();
alert(data.message || 'Fitbit credentials saved successfully'); if(response.ok) {
alert('Fitbit credentials saved successfully');
// Show the authorization link loadStatusInfo();
const authLink = document.getElementById('auth-link'); } else {
authLink.href = data.auth_url; alert(data.message || 'Error saving Fitbit credentials.');
document.getElementById('auth-url-container').style.display = 'block'; }
} catch (error) { } catch (error) {
console.error('Error saving Fitbit credentials:', error); console.error('Error saving Fitbit credentials:', error);
alert('Error saving Fitbit credentials: ' + error.message); alert('Error saving Fitbit credentials: ' + error.message);
@@ -293,12 +428,16 @@
async function submitMFA() { async function submitMFA() {
const mfaCode = document.getElementById('mfa-code').value.trim(); 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) { if (!mfaCode) {
alert('Please enter the verification code'); alert('Please enter the verification code');
return; return;
} }
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-info">Verifying MFA...</span></p>`;
try { try {
const response = await fetch('/api/setup/garmin/mfa', { const response = await fetch('/api/setup/garmin/mfa', {
method: 'POST', method: 'POST',
@@ -314,18 +453,18 @@
const data = await response.json(); const data = await response.json();
if (response.ok) { 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'); alert(data.message || 'MFA verification successful');
document.getElementById('garmin-mfa-section').style.display = 'none'; document.getElementById('garmin-mfa-section').style.display = 'none';
// Clear the MFA code field
document.getElementById('mfa-code').value = ''; document.getElementById('mfa-code').value = '';
// Refresh status after MFA
loadStatusInfo(); loadStatusInfo();
} else { } else {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">MFA Verification Failed</span></p>`;
alert('MFA verification failed: ' + data.message); alert('MFA verification failed: ' + data.message);
} }
} catch (error) { } catch (error) {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">Error</span></p>`;
console.error('Error submitting MFA code:', error); console.error('Error submitting MFA code:', error);
alert('Error submitting MFA code: ' + error.message); 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 - db
volumes: volumes:
- ./logs:/app/logs # For application logs - ./logs:/app/logs # For application logs
develop:
watch:
- action: rebuild
path: .
db: db:
image: postgres:15 image: postgres:15
environment: environment:
@@ -22,9 +25,9 @@ services:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password - POSTGRES_PASSWORD=password
ports: ports:
- "5432:5432" - "5433:5432" # Changed to avoid conflicts
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
volumes: 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)