diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 15e5354..7f99e56 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -108,6 +108,8 @@ jobs: BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }} MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }} MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }} + CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }} + CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} run: | helm upgrade --install myapp ./7project/charts/myapp-chart \ @@ -125,4 +127,6 @@ jobs: --set-string oauth.bankid.clientSecret="$BANKID_CLIENT_SECRET" \ --set-string oauth.mojeid.clientId="$MOJEID_CLIENT_ID" \ --set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \ + --set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \ + --set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \ --set-string sentry_dsn="$SENTRY_DSN" \ \ No newline at end of file diff --git a/7project/backend/alembic/versions/2025_10_21_1856-eabec90a94fe_add_config_to_user.py b/7project/backend/alembic/versions/2025_10_21_1856-eabec90a94fe_add_config_to_user.py new file mode 100644 index 0000000..529fe39 --- /dev/null +++ b/7project/backend/alembic/versions/2025_10_21_1856-eabec90a94fe_add_config_to_user.py @@ -0,0 +1,32 @@ +"""add config to user + +Revision ID: eabec90a94fe +Revises: 5ab2e654c96e +Create Date: 2025-10-21 18:56:42.085973 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'eabec90a94fe' +down_revision: Union[str, Sequence[str], None] = '5ab2e654c96e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('config', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'config') + # ### end Alembic commands ### diff --git a/7project/backend/app/api/csas.py b/7project/backend/app/api/csas.py new file mode 100644 index 0000000..08db896 --- /dev/null +++ b/7project/backend/app/api/csas.py @@ -0,0 +1,40 @@ +import json +import os + +from fastapi import APIRouter +from fastapi.params import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.oauth.csas import CSASOAuth +from app.services.db import get_async_session +from app.services.user_service import current_active_user + +router = APIRouter(prefix="/auth/csas", tags=["csas"]) + +CLIENT_ID = os.getenv("CSAS_CLIENT_ID") +CLIENT_SECRET = os.getenv("CSAS_CLIENT_SECRET") +CSAS_OAUTH = CSASOAuth(CLIENT_ID, CLIENT_SECRET) + + +@router.get("/authorize") +async def csas_authorize(): + return {"authorization_url": + await CSAS_OAUTH.get_authorization_url(os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/csas/callback")} + + +@router.get("/callback") +async def csas_callback(code: str, session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user)): + response = await CSAS_OAUTH.get_access_token(code, os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/csas/callback") + + if not user.config: + user.config = {} + + new_dict = user.config.copy() + new_dict["csas"] = json.dumps(response) + + user.config = new_dict + await session.commit() + + return "OK" diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 0cc6912..aebe333 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -6,17 +6,22 @@ from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.requests import Request -from app.models.user import User +from app.services import bank_scraper +from app.workers.celery_tasks import load_transactions, load_all_transactions +from app.models.user import User, OAuthAccount from app.services.user_service import current_active_verified_user from app.api.auth import router as auth_router +from app.api.csas import router as csas_router from app.api.categories import router as categories_router from app.api.transactions import router as transactions_router -from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider +from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, UserManager, get_jwt_strategy from fastapi import FastAPI import sentry_sdk +from fastapi_users.db import SQLAlchemyUserDatabase +from app.core.db import async_session_maker sentry_sdk.init( dsn=os.getenv("SENTRY_DSN"), @@ -70,6 +75,7 @@ fastApi.include_router( auth_backend, "SECRET", associate_by_email=True, + redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/mojeid/callback", ), prefix="/auth/mojeid", tags=["auth"], @@ -81,11 +87,13 @@ fastApi.include_router( auth_backend, "SECRET", associate_by_email=True, + redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/bankid/callback", ), prefix="/auth/bankid", tags=["auth"], ) +fastApi.include_router(csas_router) # Liveness/root endpoint @fastApi.get("/", include_in_schema=False) @@ -100,3 +108,17 @@ async def authenticated_route(user: User = Depends(current_active_verified_user) @fastApi.get("/sentry-debug") async def trigger_error(): division_by_zero = 1 / 0 + + +@fastApi.get("/debug/scrape/csas/all", tags=["debug"]) +async def debug_scrape_csas_all(): + logging.info("[Debug] Queueing CSAS scrape for all users via HTTP endpoint (Celery)") + task = load_all_transactions.delay() + return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)} + + +@fastApi.post("/debug/scrape/csas/{user_id}", tags=["debug"]) +async def debug_scrape_csas_user(user_id: str, user: User = Depends(current_active_verified_user)): + logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id) + task = load_transactions.delay(user_id) + return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id, "task_id": getattr(task, 'id', None)} diff --git a/7project/backend/app/models/user.py b/7project/backend/app/models/user.py index 3a47034..a51d12a 100644 --- a/7project/backend/app/models/user.py +++ b/7project/backend/app/models/user.py @@ -1,6 +1,8 @@ from sqlalchemy import Column, String from sqlalchemy.orm import relationship, mapped_column, Mapped from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID +from sqlalchemy.sql.sqltypes import JSON + from app.core.base import Base @@ -13,6 +15,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base): first_name = Column(String(length=100), nullable=True) last_name = Column(String(length=100), nullable=True) oauth_accounts = relationship("OAuthAccount", lazy="joined") + config = Column(JSON, default={}) # Relationship transactions = relationship("Transaction", back_populates="user") diff --git a/7project/backend/app/oauth/csas.py b/7project/backend/app/oauth/csas.py new file mode 100644 index 0000000..34e090e --- /dev/null +++ b/7project/backend/app/oauth/csas.py @@ -0,0 +1,33 @@ +import os +from os.path import dirname, join +from typing import Optional, Any + +import httpx +from httpx_oauth.exceptions import GetProfileError +from httpx_oauth.oauth2 import BaseOAuth2 + +import app.services.db + +BASE_DIR = dirname(__file__) +certs = ( + join(BASE_DIR, "public_key.pem"), + join(BASE_DIR, "private_key.key") +) + +class CSASOAuth(BaseOAuth2): + + def __init__(self, client_id: str, client_secret: str): + super().__init__( + client_id, + client_secret, + base_scopes=["aisp"], + authorize_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/auth", + access_token_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token", + refresh_token_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token" + ) + + + + + + diff --git a/7project/backend/app/oauth/moje_id.py b/7project/backend/app/oauth/moje_id.py index 7e1cd45..a56dc93 100644 --- a/7project/backend/app/oauth/moje_id.py +++ b/7project/backend/app/oauth/moje_id.py @@ -11,7 +11,7 @@ class MojeIDOAuth(CustomOpenID): super().__init__( client_id, client_secret, - "https://mojeid.regtest.nic.cz/.well-known/openid-configuration/", + "https://mojeid.cz/.well-known/openid-configuration/", "MojeID", base_scopes=["openid", "email", "profile"], ) diff --git a/7project/backend/app/oauth/private_key.key b/7project/backend/app/oauth/private_key.key new file mode 100644 index 0000000..c205cd8 --- /dev/null +++ b/7project/backend/app/oauth/private_key.key @@ -0,0 +1,28 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDcr/oxgV074ETd +DkP/0l8LFnRofru+m2wNNG/ttVCioTqwnvR4oYxwq3U9qIBsT0D+Rx/Ef7qcpzqf +/w9xt6Hosdv6I5jMHGaVQqLiPuV26/a7WvcmU+PpYuEBmbBHjGVJRBwgPtlUW1VL +M8Pht9YiaagEKvFa6SUidZLfPv+ECohqgH4mgMrEcG/BTnry0/5xQdadRC9o25cl +NtZIesS5GPeelhggFTkbh/FaxvMXhIAaRXT61cnxgxtfM71h5ObX5Lwle9z5a+Tw +xgQhSQq1jbHALYvTwsc4Q/NQGXpGNWy599sb7dg5AkPFSSF4ceXBo/2jOaZCqWrt +FVONZ+blAgMBAAECggEBAJwQbrRXsaFIRiq1jez5znC+3m+PQCHZM55a+NR3pqB7 +uE9y+ZvdUr3S4sRJxxfRLDsl/Rcu5L8nm9PNwhQ/MmamcNQCHGoro3fmed3ZcNia +og94ktMt/DztygUhtIHEjVQ0sFc1WufG9xiJcPrM0MfhRAo+fBQ4UCSAVO8/U98B +a4yukrPNeEA03hyjLB9W41pNQfyOtAHqzwDg9Q5XVaGMCLZT1bjCIquUcht5iMva +tiw3cwdiYIklLTzTCsPPK9A/AlWZyUXL8KxtN0mU0kkwlXqASoXZ2nqdkhjRye/V +3JXOmlDtDaJCqWDpH2gHLxMCl7OjfPvuD66bAT3H63kCgYEA5zxW/l6oI3gwYW7+ +j6rEjA2n8LikVnyW2e/PZ7pxBH3iBFe2DHx/imeqd/0IzixcM1zZT/V+PTFPQizG +lOU7stN6Zg/LuRdxneHPyLWCimJP7BBJCWyJkuxKy9psokyBhGSLR/phL3fP7UkB +o2I3vGmTFu5A0FzXcNH/cXPMdy8CgYEA9FJw3kyzXlInhJ6Cd63mckLPLYDArUsm +THBoeH2CVTBS5g0bCbl7N1ZxUoYwZPD4lg5V0nWhZALGf+85ULSjX03PMf1cc6WW +EIbZIo9hX+mGRa/FudDd+TlbtBnn0jucwABuLQi9mIepE55Hu9tw5/FT3cHeZVQc +cC0T6ulVvisCgYBCzFeFG+sOdAXl356B+h7VJozBKVWv9kXNp00O9fj4BzVnc78P +VFezr8a66snEZWQtIkFUq+JP4xK2VyD2mlHoktbk7OM5EOCtbzILFQQk3cmgtAOl +SUlkvAXPZcXEDL3NdQ4XOOkiQUY7kb97Z0AamZT4JtNqXaeO29si9wS12QKBgHYg +Hd3864Qg6GZgVOgUNiTsVErFw2KFwQCYIIqQ9CDH+myrzXTILuC0dJnXszI6p5W1 +XJ0irmMyTFKykN2KWKrNbe3Xd4mad5GKARWKiSPcPkUXFNwgNhI3PzU2iTTGCaVz +D9HKNhC3FnIbxsb29AHQViITh7kqD43U3ZpoMkJ9AoGAZ+sg+CPfuo3ZMpbcdb3B +ZX2UhAvNKxgHvNnHOjO+pvaM7HiH+BT0650brfBWQ0nTG1dt18mCevVk1UM/5hO9 +AtZw06vCLOJ3p3qpgkSlRZ1H7VokG9M8Od0zXqtJrmeLeBq7dfuDisYOuA+NUEbJ +UM/UHByieS6ywetruz0LpM0= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/7project/backend/app/oauth/public_key.pem b/7project/backend/app/oauth/public_key.pem new file mode 100644 index 0000000..11c0783 --- /dev/null +++ b/7project/backend/app/oauth/public_key.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFSTCCAzGgAwIBAgIEAQIDBDANBgkqhkiG9w0BAQsFADCBgDELMAkGA1UEBhMC +Q1oxDjAMBgNVBAcTBUN6ZWNoMRMwEQYDVQQKEwpFcnN0ZUdyb3VwMRUwEwYDVQQL +EwxFcnN0ZUh1YlRlYW0xETAPBgNVBAMTCEVyc3RlSHViMSIwIAYJKoZIhvcNAQkB +FhNpbmZvQGVyc3RlZ3JvdXAuY29tMB4XDTIyMTIxNDA4MDc1N1oXDTI2MDMxNDA4 +MDc1N1owUjEaMBgGA1UEYRMRUFNEQ1otQ05CLTEyMzQ1NjcxCzAJBgNVBAYTAkNa +MRYwFAYDVQQDEw1UUFAgVGVzdCBRV0FDMQ8wDQYDVQQKEwZNeSBUUFAwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDcr/oxgV074ETdDkP/0l8LFnRofru+ +m2wNNG/ttVCioTqwnvR4oYxwq3U9qIBsT0D+Rx/Ef7qcpzqf/w9xt6Hosdv6I5jM +HGaVQqLiPuV26/a7WvcmU+PpYuEBmbBHjGVJRBwgPtlUW1VLM8Pht9YiaagEKvFa +6SUidZLfPv+ECohqgH4mgMrEcG/BTnry0/5xQdadRC9o25clNtZIesS5GPeelhgg +FTkbh/FaxvMXhIAaRXT61cnxgxtfM71h5ObX5Lwle9z5a+TwxgQhSQq1jbHALYvT +wsc4Q/NQGXpGNWy599sb7dg5AkPFSSF4ceXBo/2jOaZCqWrtFVONZ+blAgMBAAGj +gfcwgfQwCwYDVR0PBAQDAgHGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD +AjCBrwYIKwYBBQUHAQMEgaIwgZ8wCAYGBACORgEBMAsGBgQAjkYBAwIBFDAIBgYE +AI5GAQQwEwYGBACORgEGMAkGBwQAjkYBBgMwZwYGBACBmCcCMF0wTDARBgcEAIGY +JwEBDAZQU1BfQVMwEQYHBACBmCcBAgwGUFNQX1BJMBEGBwQAgZgnAQMMBlBTUF9B +STARBgcEAIGYJwEEDAZQU1BfSUMMBUVyc3RlDAZBVC1FUlMwFAYDVR0RBA0wC4IJ +bXl0cHAuY29tMA0GCSqGSIb3DQEBCwUAA4ICAQBlTMPSwz46GMRBEPcy+25gV7xE +5aFS5N6sf3YQyFelRJgPxxPxTHo55WelcK4XmXRQKeQ4VoKf4FgP0Cj74+p0N0gw +wFJDWPGXH3SdjAXPRtG+FOiHwUSoyrmvbL4kk6Vbrd4cF+qe0BlzHzJ2Q6vFLwsk +NYvWzkY9YjoItB38nAnQhyYgl1yHUK/uDWyrwHVfZn1AeTws/hr/KufORuiQfaTU +kvAH1nzi7WSJ6AIQCd2exUEPx/O14Y+oCoJhTVd+RpA/9lkcqebceBijj47b2bvv +QbjymvyTXqHd3L224Y7zVmh95g+CaJ8PRpApdrImfjfDDRy8PaFWx2pd/v0UQgrQ +lgbO6jE7ah/tS0T5q5JtwnLAiOOqHPaKRvo5WB65jcZ2fvOH/0/oZ89noxp1Ihus +vvsjqc9k2h9Rvt2pEjVU40HtQZ6XCmWqgFwK3n9CHrKNV/GqgANIZRNcvXKMCUoB +VoJORVwi2DF4caKSFmyEWuK+5FyCEILtQ60SY/NHVGsUeOuN7OTjZjECARO6p4hz +Uw+GCIXrzmIjS6ydh/LRef+NK28+xTbjmLHu/wnHg9rrHEnTPd39is+byfS7eeLV +Dld/0Xrv88C0wxz63dcwAceiahjyz2mbQm765tOf9rK7EqsvT5M8EXFJ3dP4zwqS +6mNFoIa0XGbAUT3E1w== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/7project/backend/app/services/bank_scraper.py b/7project/backend/app/services/bank_scraper.py new file mode 100644 index 0000000..a0c8207 --- /dev/null +++ b/7project/backend/app/services/bank_scraper.py @@ -0,0 +1,121 @@ +import json +import logging +from os.path import dirname, join +from uuid import UUID + +import httpx +from sqlalchemy import select + +from app.core.db import async_session_maker +from app.models.user import User + +logger = logging.getLogger(__name__) + +# Reuse CSAS mTLS certs used by OAuth profile calls +OAUTH_DIR = join(dirname(__file__), "..", "oauth") +CERTS = ( + join(OAUTH_DIR, "public_key.pem"), + join(OAUTH_DIR, "private_key.key"), +) + + +async def aload_ceska_sporitelna_transactions(user_id: str) -> None: + """ + Async entry point to load Česká spořitelna transactions for a single user. + Validates the user_id and performs a minimal placeholder action. + """ + try: + uid = UUID(str(user_id)) + except Exception: + logger.error("Invalid user_id provided to bank_scraper (async): %r", user_id) + return + + await _aload_ceska_sporitelna_transactions(uid) + + +async def aload_all_ceska_sporitelna_transactions() -> None: + """ + Async entry point to load Česká spořitelna transactions for all users. + """ + async with async_session_maker() as session: + result = await session.execute(select(User)) + users = result.unique().scalars().all() + logger.info("[BankScraper] Starting CSAS scrape for all users | count=%d", len(users)) + + processed = 0 + for user in users: + try: + await _aload_ceska_sporitelna_transactions(user.id) + processed += 1 + except Exception: + logger.exception("[BankScraper] Error scraping for user id=%s email=%s", user.id, + getattr(user, 'email', None)) + logger.info("[BankScraper] Finished CSAS scrape for all users | processed=%d", processed) + + +async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None: + async with async_session_maker() as session: + result = await session.execute(select(User).where(User.id == user_id)) + user: User = result.unique().scalar_one_or_none() + if user is None: + logger.warning("User not found for id=%s", user_id) + return + + cfg = user.config or {} + if "csas" not in cfg: + return + + cfg = json.loads(cfg["csas"]) + if "access_token" not in cfg: + return + + accounts = [] + try: + async with httpx.AsyncClient(cert=CERTS, timeout=httpx.Timeout(20.0)) as client: + response = await client.get( + "https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts?size=10&page=0&sort=iban&order=desc", + headers={ + "Authorization": f"Bearer {cfg['access_token']}", + "WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3", + "user-involved": "false", + }, + ) + if response.status_code != httpx.codes.OK: + return + + for account in response.json()["accounts"]: + accounts.append(account) + + except (httpx.HTTPError,) as e: + logger.exception("[BankScraper] HTTP error during CSAS request | user_id=%s", user_id) + return + + for account in accounts: + id = account["id"] + + url = f"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts/{id}/transactions?size=100&page=0&sort=bookingdate&order=desc" + async with httpx.AsyncClient(cert=CERTS) as client: + response = await client.get( + url, + headers={ + "Authorization": f"Bearer {cfg['access_token']}", + "WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3", + "user-involved": "false", + }, + ) + if response.status_code != httpx.codes.OK: + continue + + # Placeholder: just print the account transactions + + transactions = response.json()["transactions"] + pass + + for transaction in transactions: + #parse and store transaction to database + #create Transaction object and save to DB + #obj = + + + pass + pass diff --git a/7project/backend/app/services/user_service.py b/7project/backend/app/services/user_service.py index afee558..f46b39c 100644 --- a/7project/backend/app/services/user_service.py +++ b/7project/backend/app/services/user_service.py @@ -14,6 +14,7 @@ from httpx_oauth.oauth2 import BaseOAuth2 from app.models.user import User from app.oauth.bank_id import BankID +from app.oauth.csas import CSASOAuth from app.oauth.custom_openid import CustomOpenID from app.oauth.moje_id import MojeIDOAuth from app.services.db import get_user_db @@ -32,7 +33,7 @@ providers = { "BankID": BankID( os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), - ) + ), } @@ -101,7 +102,7 @@ bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") def get_jwt_strategy() -> JWTStrategy: - return JWTStrategy(secret=SECRET, lifetime_seconds=3600) + return JWTStrategy(secret=SECRET, lifetime_seconds=604800) auth_backend = AuthenticationBackend( diff --git a/7project/backend/app/workers/celery_tasks.py b/7project/backend/app/workers/celery_tasks.py index 861004c..2c875d6 100644 --- a/7project/backend/app/workers/celery_tasks.py +++ b/7project/backend/app/workers/celery_tasks.py @@ -1,7 +1,10 @@ import logging +import asyncio from celery import shared_task +import app.services.bank_scraper + logger = logging.getLogger("celery_tasks") if not logger.handlers: _h = logging.StreamHandler() @@ -9,6 +12,72 @@ if not logger.handlers: logger.setLevel(logging.INFO) +def run_coro(coro) -> None: + """Run an async coroutine in a fresh event loop without using run_until_complete. + Primary strategy runs in a new loop in the current thread. If that fails due to + debugger patches (e.g., Bad file descriptor from pydevd_nest_asyncio), fall back + to running in a dedicated thread with its own event loop. + """ + import threading + + def _cleanup_loop(loop): + try: + pending = [t for t in asyncio.all_tasks(loop) if not t.done()] + for t in pending: + t.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + except Exception: + pass + finally: + try: + loop.close() + finally: + asyncio.set_event_loop(None) + + # First attempt: Run in current thread with a fresh event loop + try: + loop = asyncio.get_event_loop_policy().new_event_loop() + try: + asyncio.set_event_loop(loop) + task = loop.create_task(coro) + task.add_done_callback(lambda _t: loop.stop()) + loop.run_forever() + exc = task.exception() + if exc: + raise exc + return + finally: + _cleanup_loop(loop) + except OSError as e: + logger.warning("run_coro primary strategy failed (%s). Falling back to thread runner.", e) + except Exception: + # For any other unexpected errors, try thread fallback as well + logger.exception("run_coro primary strategy raised; attempting thread fallback") + + # Fallback: Run in a dedicated thread with its own event loop + error = {"exc": None} + + def _thread_target(): + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + task = loop.create_task(coro) + task.add_done_callback(lambda _t: loop.stop()) + loop.run_forever() + exc = task.exception() + if exc: + error["exc"] = exc + finally: + _cleanup_loop(loop) + + th = threading.Thread(target=_thread_target, name="celery-async-runner", daemon=True) + th.start() + th.join() + if error["exc"] is not None: + raise error["exc"] + + @shared_task(name="workers.send_email") def send_email(to: str, subject: str, body: str) -> None: if not (to and subject and body): @@ -17,3 +86,22 @@ def send_email(to: str, subject: str, body: str) -> None: # Placeholder for real email sending logic logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body)) + + +@shared_task(name="workers.load_transactions") +def load_transactions(user_id: str) -> None: + if not user_id: + logger.error("Load transactions task missing user_id.") + return + + run_coro(app.services.bank_scraper.aload_ceska_sporitelna_transactions(user_id)) + + # Placeholder for real transaction loading logic + logger.info("[Celery] Transactions loaded for user_id=%s", user_id) + + +@shared_task(name="workers.load_all_transactions") +def load_all_transactions() -> None: + logger.info("[Celery] Starting load_all_transactions") + run_coro(app.services.bank_scraper.aload_all_ceska_sporitelna_transactions()) + logger.info("[Celery] Finished load_all_transactions") diff --git a/7project/charts/myapp-chart/templates/app-deployment.yaml b/7project/charts/myapp-chart/templates/app-deployment.yaml index 074c8a6..dc85dbd 100644 --- a/7project/charts/myapp-chart/templates/app-deployment.yaml +++ b/7project/charts/myapp-chart/templates/app-deployment.yaml @@ -78,6 +78,16 @@ spec: secretKeyRef: name: prod key: BANKID_CLIENT_SECRET + - name: CSAS_CLIENT_ID + valueFrom: + secretKeyRef: + name: prod + key: CSAS_CLIENT_ID + - name: CSAS_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: prod + key: CSAS_CLIENT_SECRET - name: DOMAIN value: {{ required "Set .Values.domain" .Values.domain | quote }} - name: DOMAIN_SCHEME diff --git a/7project/charts/myapp-chart/templates/prod.yaml b/7project/charts/myapp-chart/templates/prod.yaml index 062f150..0b9442d 100644 --- a/7project/charts/myapp-chart/templates/prod.yaml +++ b/7project/charts/myapp-chart/templates/prod.yaml @@ -8,6 +8,8 @@ stringData: MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }} BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }} BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }} + CSAS_CLIENT_ID: {{ .Values.oauth.csas.clientId | quote }} + CSAS_CLIENT_SECRET: {{ .Values.oauth.csas.clientSecret | quote }} # Database credentials MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }} MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }} diff --git a/7project/charts/myapp-chart/templates/worker-deployment.yaml b/7project/charts/myapp-chart/templates/worker-deployment.yaml index 973628a..11227d3 100644 --- a/7project/charts/myapp-chart/templates/worker-deployment.yaml +++ b/7project/charts/myapp-chart/templates/worker-deployment.yaml @@ -70,3 +70,13 @@ spec: secretKeyRef: name: prod key: SENTRY_DSN + - name: CSAS_CLIENT_ID + valueFrom: + secretKeyRef: + name: prod + key: CSAS_CLIENT_ID + - name: CSAS_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: prod + key: CSAS_CLIENT_SECRET diff --git a/7project/charts/myapp-chart/values.yaml b/7project/charts/myapp-chart/values.yaml index 01eb8da..867728e 100644 --- a/7project/charts/myapp-chart/values.yaml +++ b/7project/charts/myapp-chart/values.yaml @@ -46,6 +46,9 @@ oauth: mojeid: clientId: "" clientSecret: "" + csas: + clientId: "" + clientSecret: "" rabbitmq: create: true diff --git a/7project/frontend/src/App.tsx b/7project/frontend/src/App.tsx index 3f3cec6..3751b2c 100644 --- a/7project/frontend/src/App.tsx +++ b/7project/frontend/src/App.tsx @@ -3,21 +3,54 @@ import './App.css'; import LoginRegisterPage from './pages/LoginRegisterPage'; import Dashboard from './pages/Dashboard'; import { logout } from './api'; +import { BACKEND_URL } from './config'; function App() { const [hasToken, setHasToken] = useState(!!localStorage.getItem('token')); + const [processingCallback, setProcessingCallback] = useState(false); useEffect(() => { - // Handle OAuth callback: /oauth-callback?access_token=...&token_type=... - if (window.location.pathname === '/oauth-callback') { - const params = new URLSearchParams(window.location.search); - const token = params.get('access_token'); - if (token) { - localStorage.setItem('token', token); - setHasToken(true); + const path = window.location.pathname; + + // Minimal handling for provider callbacks: /auth|/oauth/:provider/callback?code=...&state=... + const parts = path.split('/').filter(Boolean); + const isCallback = parts.length === 3 && (parts[0] === 'auth') && parts[2] === 'callback'; + + if (isCallback) { + // Guard against double invocation in React 18 StrictMode/dev + const w = window as any; + if (w.__oauthCallbackHandled) { + return; } - // Clean URL and redirect to home - window.history.replaceState({}, '', '/'); + w.__oauthCallbackHandled = true; + + setProcessingCallback(true); + + const provider = parts[1]; + const qs = window.location.search || ''; + const base = BACKEND_URL.replace(/\/$/, ''); + const url = `${base}/auth/${encodeURIComponent(provider)}/callback${qs}`; + (async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + let data: any = null; + try { + data = await res.json(); + } catch {} + if (provider !== 'csas' && res.ok && data?.access_token) { + localStorage.setItem('token', data?.access_token); + setHasToken(true); + } + } catch {} + // Clean URL and go home regardless of result + setProcessingCallback(false); + window.history.replaceState({}, '', '/'); + })(); } const onStorage = (e: StorageEvent) => { @@ -27,6 +60,24 @@ function App() { return () => window.removeEventListener('storage', onStorage); }, []); + if (processingCallback) { + return ( +
+
+
+ + + + + +
Finishing sign-in…
+
Please wait
+
+
+
+ ); + } + if (!hasToken) { return setHasToken(true)} />; } diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index 4f78f76..c66db24 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; import AccountPage from './AccountPage'; import AppearancePage from './AppearancePage'; +import { BACKEND_URL } from '../config'; function formatAmount(n: number) { return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); @@ -14,6 +15,27 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Start CSAS (George) OAuth after login + async function startOauthCsas() { + const base = BACKEND_URL.replace(/\/$/, ''); + const url = `${base}/auth/csas/authorize`; + try { + const token = localStorage.getItem('token'); + const res = await fetch(url, { + credentials: 'include', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + const data = await res.json(); + if (data && typeof data.authorization_url === 'string') { + window.location.assign(data.authorization_url); + } else { + alert('Cannot start CSAS OAuth.'); + } + } catch (e) { + alert('Cannot start CSAS OAuth.'); + } + } + // New transaction form state const [amount, setAmount] = useState(''); const [description, setDescription] = useState(''); @@ -97,6 +119,12 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
{current === 'home' && ( <> +
+

Bank connections

+

Connect your CSAS (George) account.

+ +
+

Add Transaction

diff --git a/7project/frontend/src/pages/LoginRegisterPage.tsx b/7project/frontend/src/pages/LoginRegisterPage.tsx index 50896fe..ea3b1e1 100644 --- a/7project/frontend/src/pages/LoginRegisterPage.tsx +++ b/7project/frontend/src/pages/LoginRegisterPage.tsx @@ -2,10 +2,21 @@ import { useState, useEffect } from 'react'; import { login, register } from '../api'; import { BACKEND_URL } from '../config'; -function oauthUrl(provider: 'mojeid' | 'bankid') { +// Minimal helper to start OAuth: fetch authorization_url and redirect +async function startOauth(provider: 'mojeid' | 'bankid') { const base = BACKEND_URL.replace(/\/$/, ''); - const redirect = encodeURIComponent(window.location.origin + '/oauth-callback'); - return `${base}/auth/${provider}/authorize?redirect_url=${redirect}`; + const url = `${base}/auth/${provider}/authorize`; + try { + const res = await fetch(url, { credentials: 'include' }); + const data = await res.json(); + if (data && typeof data.authorization_url === 'string') { + window.location.assign(data.authorization_url); + } else { + alert('Cannot start OAuth.'); + } + } catch (e) { + alert('Cannot start OAuth.'); + } } export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) { @@ -84,8 +95,8 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo
Or continue with
- MojeID - BankID + +