feat(oauth): Add OAuth integration UI, API clients, and audit enrichment (Phase 3)
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>
This commit is contained in:
Maciej Pienczyn 2026-02-08 15:55:02 +01:00
parent edcba4b178
commit 70e40d133b
9 changed files with 1214 additions and 9 deletions

View File

@ -156,6 +156,25 @@ def _collect_seo_data(db, company) -> dict:
except Exception as e: except Exception as e:
logger.warning(f"CrUX error for {company.website}: {e}") logger.warning(f"CrUX error for {company.website}: {e}")
# Search Console data via OAuth (if available)
search_console_data = {}
try:
from oauth_service import OAuthService
from search_console_service import SearchConsoleService
oauth = OAuthService()
gsc_token = oauth.get_valid_token(db, company.id, 'google', 'search_console')
if gsc_token and company.website:
gsc = SearchConsoleService(gsc_token)
analytics = gsc.get_search_analytics(company.website, days=28)
if analytics:
search_console_data = analytics
logger.info(f"Search Console data for company {company.id}: {analytics.get('clicks', 0)} clicks")
except ImportError:
pass
except Exception as e:
logger.warning(f"Search Console data collection failed: {e}")
# Persist live-collected data to DB for dashboard display # Persist live-collected data to DB for dashboard display
try: try:
if security_headers: if security_headers:
@ -262,6 +281,13 @@ def _collect_seo_data(db, company) -> dict:
'crux_lcp_good_pct': crux_data.get('crux_lcp_ms_good_pct'), 'crux_lcp_good_pct': crux_data.get('crux_lcp_ms_good_pct'),
'crux_inp_good_pct': crux_data.get('crux_inp_ms_good_pct'), 'crux_inp_good_pct': crux_data.get('crux_inp_ms_good_pct'),
'crux_period_end': crux_data.get('crux_period_end'), 'crux_period_end': crux_data.get('crux_period_end'),
# Search Console (OAuth)
'gsc_clicks': search_console_data.get('clicks'),
'gsc_impressions': search_console_data.get('impressions'),
'gsc_ctr': search_console_data.get('ctr'),
'gsc_avg_position': search_console_data.get('position'),
'gsc_top_queries': search_console_data.get('top_queries', []),
'gsc_period_days': search_console_data.get('period_days', 28),
} }
@ -484,6 +510,13 @@ Dane strukturalne:
- Pola LocalBusiness Schema: {data.get('local_business_schema_fields', 'brak danych')} - Pola LocalBusiness Schema: {data.get('local_business_schema_fields', 'brak danych')}
- Język strony (html lang): {data.get('html_lang', 'brak')} - Język strony (html lang): {data.get('html_lang', 'brak')}
Search Console (dane z Google Search Console, ostatnie {data.get('gsc_period_days', 28)} dni):
- Kliknięcia: {data.get('gsc_clicks', 'brak danych (wymaga połączenia OAuth)')}
- Wyświetlenia: {data.get('gsc_impressions', 'brak danych')}
- CTR: {data.get('gsc_ctr', 'brak danych')}%
- Średnia pozycja: {data.get('gsc_avg_position', 'brak danych')}
- Top zapytania: {', '.join(q.get('query', '') for q in (data.get('gsc_top_queries') or [])[:5]) or 'brak danych'}
Social & Analytics: Social & Analytics:
- Open Graph: {'tak' if data.get('has_og_tags') else 'NIE'} - Open Graph: {'tak' if data.get('has_og_tags') else 'NIE'}
- Twitter Cards: {'tak' if data.get('has_twitter_cards') else 'NIE'} - Twitter Cards: {'tak' if data.get('has_twitter_cards') else 'NIE'}

View File

@ -599,3 +599,35 @@ def admin_companies_export():
) )
finally: finally:
db.close() db.close()
@bp.route('/companies/<int:company_id>/settings')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def company_settings(company_id):
"""Company settings page with OAuth integrations UI."""
db = SessionLocal()
try:
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
flash('Firma nie istnieje', 'error')
return redirect(url_for('admin.admin_companies'))
from oauth_service import OAuthService
oauth = OAuthService()
connections = oauth.get_connected_services(db, company_id)
# Check if OAuth credentials are configured
oauth_available = {
'google': bool(oauth.google_client_id),
'meta': bool(oauth.meta_app_id),
}
return render_template(
'admin/company_settings.html',
company=company,
connections=connections,
oauth_available=oauth_available,
)
finally:
db.close()

View File

@ -68,18 +68,33 @@ def oauth_callback(provider):
state = request.args.get('state') state = request.args.get('state')
error = request.args.get('error') 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: if error:
logger.warning(f"OAuth error from {provider}: {error}") logger.warning(f"OAuth error from {provider}: {error}")
return redirect(f'/admin/company?oauth_error={error}') return settings_redirect(f'oauth_error={error}')
if not code or not state: if not code or not state:
return redirect('/admin/company?oauth_error=missing_params') return settings_redirect('oauth_error=missing_params')
# Validate state # Validate state
saved_state = session.pop('oauth_state', None) saved_state = session.pop('oauth_state', None)
if not saved_state or saved_state != state: if not saved_state or saved_state != state:
logger.warning(f"OAuth state mismatch for {provider}") logger.warning(f"OAuth state mismatch for {provider}")
return redirect('/admin/company?oauth_error=invalid_state') return settings_redirect('oauth_error=invalid_state')
# Parse state: company_id:user_id:service:random # Parse state: company_id:user_id:service:random
try: try:
@ -88,18 +103,18 @@ def oauth_callback(provider):
user_id = int(parts[1]) user_id = int(parts[1])
service = parts[2] service = parts[2]
except (ValueError, IndexError): except (ValueError, IndexError):
return redirect('/admin/company?oauth_error=invalid_state_format') return settings_redirect('oauth_error=invalid_state_format')
# Verify user owns this company # Verify user owns this company
if current_user.id != user_id or current_user.company_id != company_id: if current_user.id != user_id or current_user.company_id != company_id:
return redirect('/admin/company?oauth_error=unauthorized') return redirect(f'/admin/companies/{company_id}/settings?oauth_error=unauthorized')
# Exchange code for token # Exchange code for token
oauth = OAuthService() oauth = OAuthService()
token_data = oauth.exchange_code(provider, code) token_data = oauth.exchange_code(provider, code)
if not token_data: if not token_data:
return redirect('/admin/company?oauth_error=token_exchange_failed') return redirect(f'/admin/companies/{company_id}/settings?oauth_error=token_exchange_failed')
# Save token # Save token
db = SessionLocal() db = SessionLocal()
@ -107,9 +122,9 @@ def oauth_callback(provider):
success = oauth.save_token(db, company_id, user_id, provider, service, token_data) success = oauth.save_token(db, company_id, user_id, provider, service, token_data)
if success: if success:
logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}") logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}")
return redirect(f'/admin/company?oauth_success={provider}/{service}') return redirect(f'/admin/companies/{company_id}/settings?oauth_success={provider}/{service}')
else: else:
return redirect('/admin/company?oauth_error=save_failed') return redirect(f'/admin/companies/{company_id}/settings?oauth_error=save_failed')
finally: finally:
db.close() db.close()
@ -182,3 +197,36 @@ def oauth_disconnect(provider, service):
return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404 return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404
finally: finally:
db.close() 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()

123
facebook_graph_service.py Normal file
View File

@ -0,0 +1,123 @@
"""
Facebook + Instagram Graph API Client
======================================
Uses OAuth 2.0 page tokens to access Facebook Page and Instagram Business data.
API docs: https://developers.facebook.com/docs/graph-api/
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
class FacebookGraphService:
"""Facebook + Instagram Graph API client."""
BASE_URL = "https://graph.facebook.com/v21.0"
def __init__(self, access_token: str):
self.access_token = access_token
self.session = requests.Session()
self.session.timeout = 15
def _get(self, endpoint: str, params: dict = None) -> Optional[Dict]:
"""Make authenticated GET request."""
params = params or {}
params['access_token'] = self.access_token
try:
resp = self.session.get(f"{self.BASE_URL}/{endpoint}", params=params)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.error(f"Facebook API {endpoint} failed: {e}")
return None
def get_managed_pages(self) -> List[Dict]:
"""Get Facebook pages managed by the authenticated user."""
data = self._get('me/accounts', {'fields': 'id,name,category,access_token,fan_count'})
return data.get('data', []) if data else []
def get_page_info(self, page_id: str) -> Optional[Dict]:
"""Get detailed page information."""
return self._get(page_id, {
'fields': 'id,name,fan_count,category,link,about,website,phone,single_line_address,followers_count'
})
def get_page_insights(self, page_id: str, days: int = 28) -> Dict:
"""Get page insights (impressions, engaged users, reactions).
Note: Requires page access token, not user access token.
The page token should be stored during OAuth connection.
"""
since = datetime.now() - timedelta(days=days)
until = datetime.now()
metrics = 'page_impressions,page_engaged_users,page_fans,page_views_total'
data = self._get(f'{page_id}/insights', {
'metric': metrics,
'period': 'day',
'since': int(since.timestamp()),
'until': int(until.timestamp()),
})
if not data:
return {}
result = {}
for metric in data.get('data', []):
name = metric.get('name', '')
values = metric.get('values', [])
if values:
total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float)))
result[name] = total
return result
def get_instagram_account(self, page_id: str) -> Optional[str]:
"""Get linked Instagram Business account ID from a Facebook Page."""
data = self._get(page_id, {'fields': 'instagram_business_account'})
if data and 'instagram_business_account' in data:
return data['instagram_business_account'].get('id')
return None
def get_ig_media_insights(self, ig_account_id: str, days: int = 28) -> Dict:
"""Get Instagram account insights.
Returns follower_count, media_count, and recent media engagement.
"""
result = {}
# Basic account info
account_data = self._get(ig_account_id, {
'fields': 'followers_count,media_count,username,biography'
})
if account_data:
result['followers_count'] = account_data.get('followers_count', 0)
result['media_count'] = account_data.get('media_count', 0)
result['username'] = account_data.get('username', '')
# Account insights (reach, impressions)
since = datetime.now() - timedelta(days=days)
until = datetime.now()
insights_data = self._get(f'{ig_account_id}/insights', {
'metric': 'impressions,reach,follower_count',
'period': 'day',
'since': int(since.timestamp()),
'until': int(until.timestamp()),
})
if insights_data:
for metric in insights_data.get('data', []):
name = metric.get('name', '')
values = metric.get('values', [])
if values:
total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float)))
result[f'ig_{name}_total'] = total
return result

View File

@ -23,7 +23,7 @@ from typing import Dict, List, Optional, Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal, OAuthToken
import gemini_service import gemini_service
try: try:
@ -1895,6 +1895,44 @@ def fetch_google_business_data(
details_msg.append(f'+{sum(len(v) for v in attributes.values() if isinstance(v, dict))} atrybutów') details_msg.append(f'+{sum(len(v) for v in attributes.values() if isinstance(v, dict))} atrybutów')
result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane' result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane'
# OAuth: Try GBP Management API for owner-specific data
try:
from oauth_service import OAuthService
from gbp_management_service import GBPManagementService
oauth = OAuthService()
gbp_token = oauth.get_valid_token(db, company_id, 'google', 'gbp')
if gbp_token:
token_record = db.query(OAuthToken).filter(
OAuthToken.company_id == company_id,
OAuthToken.provider == 'google',
OAuthToken.service == 'gbp',
OAuthToken.is_active == True,
).first()
location_name = None
if token_record and token_record.metadata_json:
location_name = token_record.metadata_json.get('location_name')
if location_name:
gbp_mgmt = GBPManagementService(gbp_token)
reviews = gbp_mgmt.get_reviews(location_name)
if reviews:
owner_responses = sum(1 for r in reviews if r.get('reviewReply'))
result['data']['google_owner_responses_count'] = owner_responses
result['data']['google_total_reviews_with_replies'] = len(reviews)
result['data']['google_review_response_rate'] = round(
owner_responses / len(reviews) * 100, 1
) if reviews else 0
posts = gbp_mgmt.get_local_posts(location_name)
if posts:
result['data']['google_posts_count'] = len(posts)
result['data']['google_posts_data'] = posts[:10]
logger.info(f"OAuth GBP enrichment: {len(reviews or [])} reviews, {len(posts or [])} posts for company {company_id}")
except Exception as e:
logger.warning(f"OAuth GBP enrichment failed for company {company_id}: {e}")
# Step 3: Save to database # Step 3: Save to database
result['steps'].append({ result['steps'].append({
'step': 'save_data', 'step': 'save_data',

115
gbp_management_service.py Normal file
View File

@ -0,0 +1,115 @@
"""
Google Business Profile Management API Client
==============================================
Uses OAuth 2.0 access tokens to access owner-specific GBP data
not available through the public Places API (e.g., owner responses to reviews, posts).
API docs: https://developers.google.com/my-business/reference/rest
"""
import logging
from typing import List, Dict, Optional
import requests
logger = logging.getLogger(__name__)
class GBPManagementService:
"""Google Business Profile Management API client."""
ACCOUNT_MGMT_URL = "https://mybusinessaccountmanagement.googleapis.com/v1"
BUSINESS_INFO_URL = "https://mybusinessbusinessinformation.googleapis.com/v1"
def __init__(self, access_token: str):
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
})
self.session.timeout = 15
def list_accounts(self) -> List[Dict]:
"""List all GBP accounts for the authenticated user."""
try:
resp = self.session.get(f"{self.ACCOUNT_MGMT_URL}/accounts")
resp.raise_for_status()
return resp.json().get('accounts', [])
except Exception as e:
logger.error(f"GBP list_accounts failed: {e}")
return []
def list_locations(self, account_name: str) -> List[Dict]:
"""List locations for a GBP account.
Args:
account_name: Full account resource name (e.g., 'accounts/123456')
"""
try:
resp = self.session.get(
f"{self.BUSINESS_INFO_URL}/{account_name}/locations",
params={'readMask': 'name,title,storefrontAddress,metadata'}
)
resp.raise_for_status()
return resp.json().get('locations', [])
except Exception as e:
logger.error(f"GBP list_locations failed for {account_name}: {e}")
return []
def get_reviews(self, location_name: str, max_results: int = 50) -> List[Dict]:
"""Get reviews INCLUDING owner responses (reviewReply).
This is the key advantage over Places API reviewReply contains
the business owner's response to each review.
Args:
location_name: Full location resource name (e.g., 'accounts/123/locations/456')
max_results: Max reviews to return
"""
try:
reviews = []
page_token = None
while len(reviews) < max_results:
params = {'pageSize': min(50, max_results - len(reviews))}
if page_token:
params['pageToken'] = page_token
resp = self.session.get(
f"{self.ACCOUNT_MGMT_URL}/{location_name}/reviews",
params=params
)
resp.raise_for_status()
data = resp.json()
reviews.extend(data.get('reviews', []))
page_token = data.get('nextPageToken')
if not page_token:
break
return reviews
except Exception as e:
logger.error(f"GBP get_reviews failed for {location_name}: {e}")
return []
def get_local_posts(self, location_name: str) -> List[Dict]:
"""Get Google Posts for a location."""
try:
resp = self.session.get(
f"{self.ACCOUNT_MGMT_URL}/{location_name}/localPosts",
params={'pageSize': 20}
)
resp.raise_for_status()
return resp.json().get('localPosts', [])
except Exception as e:
logger.error(f"GBP get_local_posts failed for {location_name}: {e}")
return []
def match_location_by_place_id(self, account_name: str, place_id: str) -> Optional[Dict]:
"""Find a location that matches a Google Place ID.
Iterates locations and checks metadata.placeId.
"""
locations = self.list_locations(account_name)
for loc in locations:
metadata = loc.get('metadata', {})
if metadata.get('placeId') == place_id:
return loc
return None

View File

@ -1238,6 +1238,56 @@ class SocialMediaAuditor:
result['social_media'] = website_social result['social_media'] = website_social
logger.info(f"Total social media profiles found: {len(website_social)} - {list(website_social.keys())}") logger.info(f"Total social media profiles found: {len(website_social)} - {list(website_social.keys())}")
# OAuth: Try Facebook/Instagram Graph API for authenticated data
try:
from oauth_service import OAuthService
from facebook_graph_service import FacebookGraphService
from database import SessionLocal as OAuthSessionLocal, OAuthToken
oauth = OAuthService()
company_id = company.get('id')
if company_id:
oauth_db = OAuthSessionLocal()
try:
fb_token = oauth.get_valid_token(oauth_db, company_id, 'meta', 'facebook')
if fb_token:
fb_service = FacebookGraphService(fb_token)
token_rec = oauth_db.query(OAuthToken).filter(
OAuthToken.company_id == company_id,
OAuthToken.provider == 'meta',
OAuthToken.service == 'facebook',
OAuthToken.is_active == True,
).first()
page_id = token_rec.account_id if token_rec else None
if page_id:
page_info = fb_service.get_page_info(page_id)
if page_info:
result['oauth_facebook'] = {
'fan_count': page_info.get('fan_count'),
'category': page_info.get('category'),
'data_source': 'oauth_api',
}
insights = fb_service.get_page_insights(page_id)
if insights:
result['oauth_facebook_insights'] = insights
ig_id = fb_service.get_instagram_account(page_id)
if ig_id:
ig_insights = fb_service.get_ig_media_insights(ig_id)
if ig_insights:
result['oauth_instagram'] = {
**ig_insights,
'data_source': 'oauth_api',
}
logger.info(f"OAuth Facebook/IG enrichment done for company {company_id}")
finally:
oauth_db.close()
except ImportError:
pass # Services not yet available
except Exception as e:
logger.warning(f"OAuth social media enrichment failed: {e}")
# 5. Enrich social media profiles with additional data # 5. Enrich social media profiles with additional data
enriched_profiles = {} enriched_profiles = {}
for platform, url in website_social.items(): for platform, url in website_social.items():

149
search_console_service.py Normal file
View File

@ -0,0 +1,149 @@
"""
Google Search Console API Client
=================================
Uses OAuth 2.0 to fetch search analytics data (clicks, impressions, CTR, positions).
API docs: https://developers.google.com/webmaster-tools/v3/searchanalytics
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
class SearchConsoleService:
"""Google Search Console API client."""
BASE_URL = "https://www.googleapis.com/webmasters/v3"
def __init__(self, access_token: str):
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
})
self.session.timeout = 15
def list_sites(self) -> List[Dict]:
"""List verified sites in Search Console."""
try:
resp = self.session.get(f"{self.BASE_URL}/sites")
resp.raise_for_status()
return resp.json().get('siteEntry', [])
except Exception as e:
logger.error(f"Search Console list_sites failed: {e}")
return []
def _normalize_site_url(self, url: str) -> Optional[str]:
"""Try to find matching site URL in Search Console.
Search Console uses exact URL format with/without trailing slash,
http/https, www/no-www. Try common variants.
"""
sites = self.list_sites()
site_urls = [s.get('siteUrl', '') for s in sites]
# Direct match
if url in site_urls:
return url
# Try variants
variants = [url]
if not url.endswith('/'):
variants.append(url + '/')
if url.startswith('https://'):
variants.append(url.replace('https://', 'http://'))
if url.startswith('http://'):
variants.append(url.replace('http://', 'https://'))
# www variants
for v in list(variants):
if '://www.' in v:
variants.append(v.replace('://www.', '://'))
else:
variants.append(v.replace('://', '://www.'))
for v in variants:
if v in site_urls:
return v
return None
def get_search_analytics(self, site_url: str, days: int = 28) -> Dict:
"""Get search analytics for a site.
Returns:
Dict with keys: clicks, impressions, ctr, position,
top_queries (list), top_pages (list), period_days
"""
normalized = self._normalize_site_url(site_url)
if not normalized:
logger.warning(f"Site {site_url} not found in Search Console")
return {}
end_date = datetime.now() - timedelta(days=3) # SC data has ~3 day delay
start_date = end_date - timedelta(days=days)
try:
# Totals
resp = self.session.post(
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
json={
'startDate': start_date.strftime('%Y-%m-%d'),
'endDate': end_date.strftime('%Y-%m-%d'),
'dimensions': [],
}
)
resp.raise_for_status()
rows = resp.json().get('rows', [])
totals = rows[0] if rows else {}
result = {
'clicks': totals.get('clicks', 0),
'impressions': totals.get('impressions', 0),
'ctr': round(totals.get('ctr', 0) * 100, 2),
'position': round(totals.get('position', 0), 1),
'period_days': days,
}
# Top queries
resp_q = self.session.post(
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
json={
'startDate': start_date.strftime('%Y-%m-%d'),
'endDate': end_date.strftime('%Y-%m-%d'),
'dimensions': ['query'],
'rowLimit': 10,
}
)
if resp_q.status_code == 200:
result['top_queries'] = [
{'query': r['keys'][0], 'clicks': r.get('clicks', 0), 'impressions': r.get('impressions', 0)}
for r in resp_q.json().get('rows', [])
]
# Top pages
resp_p = self.session.post(
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
json={
'startDate': start_date.strftime('%Y-%m-%d'),
'endDate': end_date.strftime('%Y-%m-%d'),
'dimensions': ['page'],
'rowLimit': 10,
}
)
if resp_p.status_code == 200:
result['top_pages'] = [
{'page': r['keys'][0], 'clicks': r.get('clicks', 0), 'impressions': r.get('impressions', 0)}
for r in resp_p.json().get('rows', [])
]
return result
except Exception as e:
logger.error(f"Search Console analytics failed for {site_url}: {e}")
return {}

View File

@ -0,0 +1,617 @@
{% extends "base.html" %}
{% block title %}Ustawienia firmy - {{ company.name }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
.settings-header {
margin-bottom: var(--spacing-xl);
}
.settings-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin: 0;
}
.settings-header .breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.settings-header .breadcrumb a {
color: var(--primary);
text-decoration: none;
}
.settings-header .breadcrumb a:hover {
text-decoration: underline;
}
.settings-header .breadcrumb svg {
width: 14px;
height: 14px;
color: var(--text-muted);
}
.settings-subtitle {
margin: var(--spacing-xs) 0 0 0;
color: var(--text-secondary);
}
/* OAuth Cards Grid */
.oauth-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.oauth-card {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--spacing-lg);
border: 1px solid var(--border);
transition: var(--transition);
}
.oauth-card:hover {
box-shadow: var(--shadow);
}
.oauth-card-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.oauth-card-icon {
width: 44px;
height: 44px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.oauth-card-icon.google {
background: #f0f7ff;
color: #4285F4;
}
.oauth-card-icon.meta {
background: #f0f0ff;
color: #1877F2;
}
.oauth-card-icon svg {
width: 24px;
height: 24px;
}
.oauth-card-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.oauth-card-desc {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0 0 var(--spacing-md) 0;
line-height: 1.5;
}
/* Status badges */
.oauth-status {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
margin-bottom: var(--spacing-md);
}
.oauth-status.connected {
background: var(--success-light, #dcfce7);
color: var(--success, #16a34a);
}
.oauth-status.unavailable {
background: var(--surface);
color: var(--text-muted);
}
.oauth-status.expired {
background: var(--warning-light, #fef3c7);
color: var(--warning, #d97706);
}
.oauth-status svg {
width: 14px;
height: 14px;
}
.oauth-account-name {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.oauth-card-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.btn-oauth {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
border: none;
text-decoration: none;
}
.btn-oauth.connect {
background: var(--primary);
color: white;
}
.btn-oauth.connect:hover {
opacity: 0.9;
}
.btn-oauth.disconnect {
background: transparent;
color: var(--error, #dc2626);
border: 1px solid var(--error, #dc2626);
}
.btn-oauth.disconnect:hover {
background: var(--error, #dc2626);
color: white;
}
.btn-oauth.discover {
background: transparent;
color: var(--primary);
border: 1px solid var(--primary);
}
.btn-oauth.discover:hover {
background: var(--primary);
color: white;
}
.btn-oauth:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-oauth svg {
width: 16px;
height: 16px;
}
/* Toast notification */
.toast {
position: fixed;
top: var(--spacing-lg);
right: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
z-index: 9999;
animation: toastIn 0.3s ease-out;
box-shadow: var(--shadow-lg);
}
.toast.success {
background: var(--success, #16a34a);
color: white;
}
.toast.error {
background: var(--error, #dc2626);
color: white;
}
@keyframes toastIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
/* Section title */
.section-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--spacing-md) 0;
}
.section-desc {
color: var(--text-secondary);
margin: 0 0 var(--spacing-lg) 0;
font-size: var(--font-size-sm);
}
/* Back link */
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--primary);
text-decoration: none;
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.back-link:hover {
text-decoration: underline;
}
.back-link svg {
width: 16px;
height: 16px;
}
{% endblock %}
{% block content %}
<div class="container">
<!-- Breadcrumb -->
<div class="settings-header">
<div class="breadcrumb">
<a href="{{ url_for('admin.admin_companies') }}">Firmy</a>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<a href="{{ url_for('admin.admin_company_get', company_id=company.id) }}">{{ company.name }}</a>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<span>Ustawienia</span>
</div>
<h1>Ustawienia firmy</h1>
<p class="settings-subtitle">{{ company.name }} — integracje z zewnętrznymi serwisami</p>
</div>
<!-- OAuth Integrations Section -->
<h2 class="section-title">Połączenia OAuth</h2>
<p class="section-desc">Połącz konta zewnętrznych serwisów, aby wzbogacić audyty o dodatkowe dane. Każdy serwis wymaga osobnej autoryzacji.</p>
<div class="oauth-cards">
<!-- Google Business Profile -->
{% set gbp_conn = connections.get('google/gbp', {}) %}
<div class="oauth-card" data-provider="google" data-service="gbp">
<div class="oauth-card-header">
<div class="oauth-card-icon google">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
</div>
<h3 class="oauth-card-title">Google Business Profile</h3>
</div>
<p class="oauth-card-desc">Opinie z odpowiedziami właściciela, posty, zdjęcia, Q&A i statystyki widoczności wizytówki Google.</p>
{% if gbp_conn.get('connected') %}
{% if gbp_conn.get('is_expired') %}
<div class="oauth-status expired">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Token wygasł — wymagane ponowne połączenie
</div>
{% else %}
<div class="oauth-status connected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Połączono
</div>
{% endif %}
{% if gbp_conn.get('account_name') %}
<p class="oauth-account-name">Konto: {{ gbp_conn.account_name }}</p>
{% endif %}
<div class="oauth-card-actions">
<button class="btn-oauth discover" onclick="discoverGBPLocations()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Wykryj lokalizacje
</button>
<button class="btn-oauth disconnect" onclick="disconnectOAuth('google', 'gbp')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
Rozłącz
</button>
</div>
{% elif oauth_available.get('google') %}
<div class="oauth-card-actions">
<button class="btn-oauth connect" onclick="connectOAuth('google', 'gbp')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Połącz konto
</button>
</div>
{% else %}
<div class="oauth-status unavailable">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Niedostępne — wymaga konfiguracji administratora
</div>
{% endif %}
</div>
<!-- Google Search Console -->
{% set sc_conn = connections.get('google/search_console', {}) %}
<div class="oauth-card" data-provider="google" data-service="search_console">
<div class="oauth-card-header">
<div class="oauth-card-icon google">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
</div>
<h3 class="oauth-card-title">Google Search Console</h3>
</div>
<p class="oauth-card-desc">Zapytania wyszukiwania, CTR, średnia pozycja, indeksowanie stron i dane o wydajności w Google.</p>
{% if sc_conn.get('connected') %}
{% if sc_conn.get('is_expired') %}
<div class="oauth-status expired">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Token wygasł — wymagane ponowne połączenie
</div>
{% else %}
<div class="oauth-status connected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Połączono
</div>
{% endif %}
{% if sc_conn.get('account_name') %}
<p class="oauth-account-name">Konto: {{ sc_conn.account_name }}</p>
{% endif %}
<div class="oauth-card-actions">
<button class="btn-oauth disconnect" onclick="disconnectOAuth('google', 'search_console')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
Rozłącz
</button>
</div>
{% elif oauth_available.get('google') %}
<div class="oauth-card-actions">
<button class="btn-oauth connect" onclick="connectOAuth('google', 'search_console')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Połącz konto
</button>
</div>
{% else %}
<div class="oauth-status unavailable">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Niedostępne — wymaga konfiguracji administratora
</div>
{% endif %}
</div>
<!-- Facebook -->
{% set fb_conn = connections.get('meta/facebook', {}) %}
<div class="oauth-card" data-provider="meta" data-service="facebook">
<div class="oauth-card-header">
<div class="oauth-card-icon meta">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</div>
<h3 class="oauth-card-title">Facebook</h3>
</div>
<p class="oauth-card-desc">Zasięg strony, impressions, engagement, dane demograficzne odbiorców i statystyki postów.</p>
{% if fb_conn.get('connected') %}
{% if fb_conn.get('is_expired') %}
<div class="oauth-status expired">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Token wygasł — wymagane ponowne połączenie
</div>
{% else %}
<div class="oauth-status connected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Połączono
</div>
{% endif %}
{% if fb_conn.get('account_name') %}
<p class="oauth-account-name">Strona: {{ fb_conn.account_name }}</p>
{% endif %}
<div class="oauth-card-actions">
<button class="btn-oauth disconnect" onclick="disconnectOAuth('meta', 'facebook')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
Rozłącz
</button>
</div>
{% elif oauth_available.get('meta') %}
<div class="oauth-card-actions">
<button class="btn-oauth connect" onclick="connectOAuth('meta', 'facebook')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Połącz konto
</button>
</div>
{% else %}
<div class="oauth-status unavailable">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Niedostępne — wymaga konfiguracji administratora
</div>
{% endif %}
</div>
<!-- Instagram -->
{% set ig_conn = connections.get('meta/instagram', {}) %}
<div class="oauth-card" data-provider="meta" data-service="instagram">
<div class="oauth-card-header">
<div class="oauth-card-icon meta">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
</div>
<h3 class="oauth-card-title">Instagram</h3>
</div>
<p class="oauth-card-desc">Stories, reels, engagement, zasięg postów i dane demograficzne obserwujących konto firmowe.</p>
{% if ig_conn.get('connected') %}
{% if ig_conn.get('is_expired') %}
<div class="oauth-status expired">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Token wygasł — wymagane ponowne połączenie
</div>
{% else %}
<div class="oauth-status connected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Połączono
</div>
{% endif %}
{% if ig_conn.get('account_name') %}
<p class="oauth-account-name">Konto: {{ ig_conn.account_name }}</p>
{% endif %}
<div class="oauth-card-actions">
<button class="btn-oauth disconnect" onclick="disconnectOAuth('meta', 'instagram')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
Rozłącz
</button>
</div>
{% elif oauth_available.get('meta') %}
<div class="oauth-card-actions">
<button class="btn-oauth connect" onclick="connectOAuth('meta', 'instagram')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Połącz konto
</button>
</div>
{% else %}
<div class="oauth-status unavailable">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Niedostępne — wymaga konfiguracji administratora
</div>
{% endif %}
</div>
</div>
<a href="{{ url_for('admin.admin_company_get', company_id=company.id) }}" class="back-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Powrót do szczegółów firmy
</a>
</div>
{% endblock %}
{% block extra_js %}
// OAuth Settings JS
var csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
function showToast(message, type) {
var existing = document.querySelector('.toast');
if (existing) existing.remove();
var toast = document.createElement('div');
toast.className = 'toast ' + (type || 'success');
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function() { toast.remove(); }, 300);
}, 4000);
}
function connectOAuth(provider, service) {
fetch('/api/oauth/connect/' + provider + '/' + service, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.auth_url) {
window.location.href = data.auth_url;
} else {
showToast(data.error || 'Nie udało się rozpocząć autoryzacji', 'error');
}
})
.catch(function(err) {
showToast('Błąd połączenia: ' + err.message, 'error');
});
}
function disconnectOAuth(provider, service) {
if (!confirm('Czy na pewno chcesz rozłączyć ten serwis? Audyty stracą dostęp do rozszerzonych danych.')) return;
fetch('/api/oauth/disconnect/' + provider + '/' + service, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Serwis rozłączony pomyślnie');
setTimeout(function() { location.reload(); }, 1000);
} else {
showToast(data.error || 'Nie udało się rozłączyć', 'error');
}
})
.catch(function(err) {
showToast('Błąd: ' + err.message, 'error');
});
}
function discoverGBPLocations() {
var btn = document.querySelector('.btn-oauth.discover');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;animation:spin 1s linear infinite"><circle cx="12" cy="12" r="10" opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10"/></svg> Szukam...';
}
fetch('/api/oauth/google/discover-locations', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
var count = data.locations ? data.locations.length : 0;
showToast('Znaleziono ' + count + ' lokalizacji GBP');
} else {
showToast(data.error || 'Nie udało się wyszukać lokalizacji', 'error');
}
if (btn) {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Wykryj lokalizacje';
}
})
.catch(function(err) {
showToast('Błąd: ' + err.message, 'error');
if (btn) {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Wykryj lokalizacje';
}
});
}
// Handle URL params for OAuth callback results
(function() {
var params = new URLSearchParams(window.location.search);
if (params.get('oauth_success')) {
showToast('Pomyślnie połączono: ' + params.get('oauth_success'));
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
}
if (params.get('oauth_error')) {
var errorMap = {
'missing_params': 'Brak wymaganych parametrów',
'invalid_state': 'Nieprawidłowy token sesji',
'invalid_state_format': 'Nieprawidłowy format tokenu',
'unauthorized': 'Brak uprawnień',
'token_exchange_failed': 'Wymiana tokenu nie powiodła się',
'save_failed': 'Nie udało się zapisać tokenu'
};
var error = params.get('oauth_error');
showToast(errorMap[error] || 'Błąd OAuth: ' + error, 'error');
window.history.replaceState({}, document.title, window.location.pathname);
}
})();
{% endblock %}