nordabiz/blueprints/api/routes_oauth.py
Maciej Pienczyn 70e40d133b
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
feat(oauth): Add OAuth integration UI, API clients, and audit enrichment (Phase 3)
- 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>
2026-02-08 15:55:02 +01:00

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()