working
This commit is contained in:
52
FitnessSync/SAVE_GARMIN_CREDS.md
Normal file
52
FitnessSync/SAVE_GARMIN_CREDS.md
Normal 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)
|
||||||
@@ -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"]
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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:
|
|
||||||
Binary file not shown.
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
16
FitnessSync/docker-compose.override.yml
Normal file
16
FitnessSync/docker-compose.override.yml
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
4766
FitnessSync/garth_reference.txt
Normal file
4766
FitnessSync/garth_reference.txt
Normal file
File diff suppressed because it is too large
Load Diff
117
FitnessSync/save_garmin_creds.py
Normal file
117
FitnessSync/save_garmin_creds.py
Normal 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)
|
||||||
Reference in New Issue
Block a user