Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- Company settings page with 4 OAuth cards (GBP, Search Console, Facebook, Instagram) - 3 API service clients: GBP Management, Search Console, Facebook Graph - OAuth enrichment in GBP audit (owner responses, posts), social media (FB/IG Graph API), and SEO prompt (Search Console data) - Fix OAuth callback redirects to point to company settings page - All integrations have graceful fallback when no OAuth credentials configured Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
233 lines
8.1 KiB
Python
233 lines
8.1 KiB
Python
"""
|
|
OAuth API Routes
|
|
================
|
|
|
|
Endpoints for connecting external accounts (Google, Meta) via OAuth 2.0.
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from flask import jsonify, request, redirect, session
|
|
from flask_login import login_required, current_user
|
|
|
|
from . import bp
|
|
from database import SessionLocal, OAuthToken
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@bp.route('/oauth/connect/<provider>/<service>', methods=['POST'])
|
|
@login_required
|
|
def oauth_connect(provider, service):
|
|
"""Initiate OAuth flow for connecting an external account.
|
|
|
|
POST /api/oauth/connect/google/gbp
|
|
POST /api/oauth/connect/meta/facebook
|
|
"""
|
|
from oauth_service import OAuthService
|
|
|
|
# Validate provider/service
|
|
valid_combinations = {
|
|
('google', 'gbp'), ('google', 'search_console'),
|
|
('meta', 'facebook'), ('meta', 'instagram'),
|
|
}
|
|
if (provider, service) not in valid_combinations:
|
|
return jsonify({'success': False, 'error': f'Nieznany provider/service: {provider}/{service}'}), 400
|
|
|
|
# User must have a company
|
|
if not current_user.company_id:
|
|
return jsonify({'success': False, 'error': 'Musisz być przypisany do firmy'}), 403
|
|
|
|
# Generate CSRF state token
|
|
state = f"{current_user.company_id}:{current_user.id}:{service}:{secrets.token_urlsafe(16)}"
|
|
session['oauth_state'] = state
|
|
|
|
oauth = OAuthService()
|
|
auth_url = oauth.get_authorization_url(provider, service, state)
|
|
|
|
if not auth_url:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'OAuth nie skonfigurowany dla {provider}. Skontaktuj się z administratorem.'
|
|
}), 503
|
|
|
|
return jsonify({'success': True, 'auth_url': auth_url})
|
|
|
|
|
|
@bp.route('/oauth/callback/<provider>', methods=['GET'])
|
|
@login_required
|
|
def oauth_callback(provider):
|
|
"""Handle OAuth callback from provider.
|
|
|
|
GET /api/oauth/callback/google?code=...&state=...
|
|
GET /api/oauth/callback/meta?code=...&state=...
|
|
"""
|
|
from oauth_service import OAuthService
|
|
|
|
code = request.args.get('code')
|
|
state = request.args.get('state')
|
|
error = request.args.get('error')
|
|
|
|
# Parse state early to get company_id for redirects
|
|
# State format: company_id:user_id:service:random
|
|
state_company_id = None
|
|
if state:
|
|
try:
|
|
state_company_id = int(state.split(':')[0])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
def settings_redirect(params):
|
|
"""Redirect to company settings page with query params."""
|
|
if state_company_id:
|
|
return redirect(f'/admin/companies/{state_company_id}/settings?{params}')
|
|
return redirect(f'/admin/companies?{params}')
|
|
|
|
if error:
|
|
logger.warning(f"OAuth error from {provider}: {error}")
|
|
return settings_redirect(f'oauth_error={error}')
|
|
|
|
if not code or not state:
|
|
return settings_redirect('oauth_error=missing_params')
|
|
|
|
# Validate state
|
|
saved_state = session.pop('oauth_state', None)
|
|
if not saved_state or saved_state != state:
|
|
logger.warning(f"OAuth state mismatch for {provider}")
|
|
return settings_redirect('oauth_error=invalid_state')
|
|
|
|
# Parse state: company_id:user_id:service:random
|
|
try:
|
|
parts = state.split(':')
|
|
company_id = int(parts[0])
|
|
user_id = int(parts[1])
|
|
service = parts[2]
|
|
except (ValueError, IndexError):
|
|
return settings_redirect('oauth_error=invalid_state_format')
|
|
|
|
# Verify user owns this company
|
|
if current_user.id != user_id or current_user.company_id != company_id:
|
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_error=unauthorized')
|
|
|
|
# Exchange code for token
|
|
oauth = OAuthService()
|
|
token_data = oauth.exchange_code(provider, code)
|
|
|
|
if not token_data:
|
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_error=token_exchange_failed')
|
|
|
|
# Save token
|
|
db = SessionLocal()
|
|
try:
|
|
success = oauth.save_token(db, company_id, user_id, provider, service, token_data)
|
|
if success:
|
|
logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}")
|
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_success={provider}/{service}')
|
|
else:
|
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_error=save_failed')
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/oauth/status', methods=['GET'])
|
|
@login_required
|
|
def oauth_status():
|
|
"""Get connected OAuth services for current user's company.
|
|
|
|
GET /api/oauth/status
|
|
"""
|
|
from oauth_service import OAuthService
|
|
|
|
if not current_user.company_id:
|
|
return jsonify({'success': True, 'services': {}})
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
oauth = OAuthService()
|
|
services = oauth.get_connected_services(db, current_user.company_id)
|
|
|
|
# Add available (but not connected) services
|
|
all_services = {
|
|
'google/gbp': {'name': 'Google Business Profile', 'description': 'Pełne dane o wizytówce, opinie, insights'},
|
|
'google/search_console': {'name': 'Google Search Console', 'description': 'Zapytania, CTR, pozycje w wyszukiwaniu'},
|
|
'meta/facebook': {'name': 'Facebook', 'description': 'Reach, impressions, demographics, post insights'},
|
|
'meta/instagram': {'name': 'Instagram', 'description': 'Stories, reels, engagement metrics'},
|
|
}
|
|
|
|
result = {}
|
|
for key, info in all_services.items():
|
|
connected = services.get(key, {})
|
|
result[key] = {
|
|
**info,
|
|
'connected': bool(connected),
|
|
'account_name': connected.get('account_name'),
|
|
'expires_at': connected.get('expires_at'),
|
|
'is_expired': connected.get('is_expired', False),
|
|
}
|
|
|
|
return jsonify({'success': True, 'services': result})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/oauth/disconnect/<provider>/<service>', methods=['POST'])
|
|
@login_required
|
|
def oauth_disconnect(provider, service):
|
|
"""Disconnect an OAuth service.
|
|
|
|
POST /api/oauth/disconnect/google/gbp
|
|
"""
|
|
if not current_user.company_id:
|
|
return jsonify({'success': False, 'error': 'Brak przypisanej firmy'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
token = db.query(OAuthToken).filter(
|
|
OAuthToken.company_id == current_user.company_id,
|
|
OAuthToken.provider == provider,
|
|
OAuthToken.service == service,
|
|
).first()
|
|
|
|
if token:
|
|
token.is_active = False
|
|
db.commit()
|
|
logger.info(f"OAuth disconnected: {provider}/{service} for company {current_user.company_id}")
|
|
return jsonify({'success': True, 'message': f'{provider}/{service} rozłączony'})
|
|
else:
|
|
return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/oauth/google/discover-locations', methods=['POST'])
|
|
@login_required
|
|
def oauth_discover_gbp_locations():
|
|
"""Auto-discover GBP locations after OAuth connection."""
|
|
if not current_user.company_id:
|
|
return jsonify({'success': False, 'error': 'Brak firmy'}), 403
|
|
|
|
from oauth_service import OAuthService
|
|
oauth = OAuthService()
|
|
db = SessionLocal()
|
|
try:
|
|
token = oauth.get_valid_token(db, current_user.company_id, 'google', 'gbp')
|
|
if not token:
|
|
return jsonify({'success': False, 'error': 'Brak połączenia GBP'}), 404
|
|
|
|
try:
|
|
from gbp_management_service import GBPManagementService
|
|
gbp = GBPManagementService(token)
|
|
accounts = gbp.list_accounts()
|
|
locations = []
|
|
for acc in accounts:
|
|
acc_locations = gbp.list_locations(acc.get('name', ''))
|
|
locations.extend(acc_locations)
|
|
return jsonify({'success': True, 'locations': locations})
|
|
except ImportError:
|
|
return jsonify({'success': False, 'error': 'Serwis GBP Management niedostępny'}), 503
|
|
except Exception as e:
|
|
logger.error(f"GBP discover locations error: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas wyszukiwania lokalizacji'}), 500
|
|
finally:
|
|
db.close()
|