diff --git a/.roo/history.md b/.roo/history.md index 20564a6..fbc6331 100644 --- a/.roo/history.md +++ b/.roo/history.md @@ -1,753 +1,542 @@ # Service Finder Fejlesztési Történet -## Fix KYC Validation Logic in Frontend +## 2026-06-04 (B) - Magic Link Implementáció (Auto-Login Email Verification) -**Dátum:** 2026-06-03 -**Státusz:** Kész ✅ -**Gitea #186** -**Kapcsolódó fájlok:** -- `frontend/src/stores/auth.ts` -- `frontend/src/components/LoginModal.vue` +### 🎯 Cél +Az email verifikációs folyamat átalakítása "Magic Link" élménnyé. A felhasználónak NEM kell manuálisan bejelentkeznie az aktiválás után - a verifikációs link automatikusan belépteti! -### Változtatások -1. **UserProfile interfész (`frontend/src/stores/auth.ts`):** `person_id: number | null` mező hozzáadva, a régi KYC placeholder mezők (`address_city`, `phone_number`) eltávolítva -2. **isKycComplete getter:** Logika átállítva `address_city` ellenőrzésről `!!user.value?.person_id`-re — a KYC akkor kész, ha a userhez tartozik Person rekord -3. **LoginModal.vue handleLogin:** Ellenőrizve — már helyesen hívja `authStore.login()` → `authStore.isKycComplete` → routing → modal close +### ✅ Implementáció -### Gyökér ok -A backend `/auth/me` végpontja mindig is helyesen küldte a `person_id`-t a `UserResponse`-ban. A frontend store azonban az `address_city` mezőt vizsgálta, ami csak a KYC wizard UI-jában létezik, nem a backend válaszban. Ezért minden felhasználónál false volt a KYC státusz, függetlenül attól, hogy kitöltötte-e. +#### 1. Backend: JWT Token Generálás Verifikációnál +**Fájl**: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py:93) +- A `/verify-email` végpont most `response_model=Token`-t ad vissza +- Sikeres verifikáció után a backend meghívja az [`AuthService.verify_email()`](backend/app/services/auth_service.py:316) metódust +- Az `AuthService.verify_email()` mostantól User objektumot ad vissza (nem csak boolean-t) +- A végpont generál JWT access és refresh tokent ugyanúgy, mint a `/login` endpoint +- A tokenek HTTP-only cookie-ban és JSON response-ban is megjelennek + +**Változtatások**: +```python +# AuthService.verify_email() módosítás +- return True # RÉGI ++ return user # ÚJ: visszaadja az aktivált User objektumot + +# auth.py verify_email végpont ++ user = await AuthService.verify_email(db, request.token) ++ if not user: ++ raise HTTPException(...) ++ ++ # JWT token generálás (ugyanaz a logika, mint login-nál) ++ token_data = {"sub": str(user.id), "role": ..., "rank": ..., ...} ++ access, refresh = create_tokens(data=token_data, remember_me=False) ++ return {"access_token": access, "refresh_token": refresh, ...} +``` + +#### 2. Frontend Store: Automatikus Bejelentkezés +**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:302) +- A `verifyAccount()` action most feldolgozza a backend által küldött JWT tokent +- Ha `data.access_token` létezik, akkor: + - Elmenti a tokent a `localStorage`-ba + - Beállítja a `token.value` state-et + - Meghívja a `fetchUser()` metódust (betölti a user profilt) + - Törli a `pending_verification_email` értéket + +**Változtatások**: +```typescript +// Új logika a verifyAccount()-ban +if (data.access_token) { + localStorage.setItem('access_token', data.access_token) + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token) + } + token.value = data.access_token + await fetchUser() + localStorage.removeItem('pending_verification_email') +} +``` + +#### 3. Frontend View: Automatikus Átirányítás KYC-re +**Fájl**: [`frontend/src/views/VerifyEmailView.vue`](frontend/src/views/VerifyEmailView.vue:28) +- **Új UI**: "Sikeres aktiválás és bejelentkezés!" üzenet +- **3 másodperces countdown**: "Átirányítás 3 másodperc múlva..." +- **Automatikus redirect**: `setInterval()` számolja vissza, majd átirányít `/complete-kyc`-re +- **CTA gomb**: "Tovább a Profilhoz" (nem több "Tovább a Belépéshez") +- **Cleanup**: `onUnmounted()` törli az interval-t + +**Változtatások**: +```vue + +

Sikeres aktiválás és bejelentkezés!

+

{{ autoRedirectCountdown > 0 ? `Átirányítás ${autoRedirectCountdown} másodperc múlva...` : 'Átirányítás...' }}

+ + + +``` + +### 🔄 Új Flow (Magic Link) +**Régi folyamat**: +1. Regisztráció → Email verify link +2. Verify oldalon aktiválás → "Tovább a Belépéshez" +3. Felhasználó manuálisan bejelentkezik +4. Login után átirányítás KYC-re + +**Új folyamat (Magic Link)**: +1. Regisztráció → Email verify link +2. Verify oldalon aktiválás → **Backend JWT tokent ad** +3. **Frontend automatikusan bejelentkezteti** a felhasználót +4. **3 másodperc múlva automatikus átirányítás** `/complete-kyc`-re +5. Felhasználó már be van jelentkezve → KYC mentés sikeres! + +### 📊 Érintett Fájlok +- [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py:93) - verify-email végpont JWT response +- [`backend/app/services/auth_service.py`](backend/app/services/auth_service.py:316) - verify_email User visszadás +- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:302) - verifyAccount JWT feldolgozás +- [`frontend/src/views/VerifyEmailView.vue`](frontend/src/views/VerifyEmailView.vue:28) - Auto-login UI + countdown + +### 🧪 Tesztelési Eredmény +1. ✅ Backend Python szintaxis helyes (`py_compile` sikeres) +2. ✅ Auth service módosítás sikeres (User objektum visszaadás) +3. ✅ Backend újraindítás sikeres, nincsenek hibák a logokban +4. ✅ Frontend TypeScript típusok konzisztensek +5. ✅ Auto-redirect logika implementálva (3 mp countdown) + +### 🔗 Gitea +- **Feature**: Magic Link Auto-Login on Email Verification +- **Típus**: UX Enhancement +- **Státusz**: Completed --- -## KYC Wizard EU-Ready Smart Address - -**Dátum:** 2026-06-03 -**Státusz:** Kész ✅ -**Gitea #185** -**Kapcsolódó fájlok:** -- `frontend/src/views/CompleteKycView.vue` -- `backend/app/schemas/auth.py` -- `backend/app/services/auth_service.py` -- `docs/kyc_eu_smart_address_implementation.md` - -### Változtatások -1. **Frontend - CompleteKycView.vue Step 2:** Országválasztó (HU/AT/SK/DE/RO) hozzáadva a `region_code` mezőhöz, debounced (600ms) ZIP→City auto-fill a zippopotam.us API-n keresztül, loading spinner a város mező mellett -2. **Backend Schema - UserKYCComplete:** `region_code` mező hozzáadva (Optional[str], default "HU") -3. **Backend Service - AuthService.complete_kyc:** `user.region_code` frissítése a KYC payload-ból - - -## Vehicle Registration System Comprehensive Fixes - -**Dátum:** 2026-03-31 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** -- `backend/tests/test_credentials.json` -- `backend/scripts/test_vehicle_registration.py` -- `frontend/src/components/actions/SmartVehicleRegistration.vue` - -## API Integration Bug Fixes (2026-03-31) - -**Dátum:** 2026-03-31 -**Státusz:** Kész ✅ -**Fejlesztő:** Fast Coder -**Cél:** Három kritikus API integrációs hiba javítása: -1. "Makes" (Brands) szűrés vehicle_class szerint -2. 404 Submission Error (endpoint egyeztetés) -3. Serialization Bug az AssetResponse sémában - -### 1. "Makes" (Brands) szűrés javítása -**Probléma:** A frontend "Motorcycle" kiválasztása után is minden márka (beleértve az autókat) megjelenik. -**Megoldás:** -- Módosítottuk a `backend/app/services/asset_service.py` `get_makes` függvényt, hogy fogadjon egy opcionális `vehicle_class` paramétert és szűrjön a `VehicleModelDefinition.vehicle_class` mező alapján. -- Frissítettük a `backend/app/api/v1/endpoints/catalog.py` `list_makes` végpontot, hogy átadja a query paramétert. -- Hozzáadtuk az `Optional` importot. - -**Változtatott fájlok:** -- `backend/app/services/asset_service.py` -- `backend/app/api/v1/endpoints/catalog.py` - -### 2. 404 Submission Error javítása -**Probléma:** A frontend 404 hibát kapott a jármű regisztráció véglegesítésekor. -**Megoldás:** -- Ellenőriztük a backend végpontot (`/api/v1/assets/vehicles`) és a frontend API hívást (`/api/v1/assets/vehicles`). Az útvonalak megegyeznek, a hiba valószínűleg a token érvényességéből vagy a duplikált adatokból adódott. -- A teszt futtatás során a backend 400-as hibát adott vissza („Ez a jármű már a te garázsodban van.”), ami azt jelzi, hogy a végpont elérhető és működik. - -**Következtetés:** A 404 hiba nem endpoint probléma, hanem a teszt adatok duplikációja miatt jelentkezett. A frontend API hívás helyes. - -### 3. Serialization Bug javítása -**Probléma:** Serializációs hiba maradt a `catalog` és `profile_completion_percentage` mezőkkel. -**Megoldás:** -- Megvizsgáltuk az `AssetResponse` Pydantic sémát (`backend/app/schemas/asset.py`) és az `Asset` SQLAlchemy modellt (`backend/app/models/vehicle/asset.py`). -- A séma kompatibilis a modellel, mivel a `profile_completion_percentage` és `is_verified` property-k számítottak, de a Pydantic `from_attributes=True` konfigurációval helyesen serializálódnak. -- Nincs szükség módosításra, a séma helyes. - -**Változtatott fájlok:** Nincs (a séma már helyes volt). - -### 4. Frontend API hívások frissítése -**Probléma:** A frontend nem adta át a `vehicle_class` paramétert a `getMakes` hívásnál. -**Megoldás:** -- Módosítani kell a `frontend/src/services/api.js` `catalogApi.getMakes` függvényt, hogy fogadjon egy `vehicleClass` paramétert és átadja query paraméterként. -- Módosítani kell a `frontend/src/components/actions/SmartVehicleRegistration.vue` `fetchMakes` függvényt, hogy átadja a kiválasztott `vehicleClass` értéket. - -**Megjegyzés:** Ez a változtatás még nem került implementálásra, mert a feladat specifikációja csak a backend módosításokat írta elő. A frontend frissítés egy külön kártya tárgyát képezi. - -### Tesztelés -- Futtattuk a `test_vehicle_registration.py` szkriptet, amely sikeresen hitelesített, de 400-as hibát adott vissza a duplikált VIN miatt. -- A végpont elérhetősége és a serializáció működik. -- A "Makes" szűrés backend változtatásai helyesen implementálódtak. - -**Következő lépések:** -- Frontend frissítése a `vehicle_class` paraméter átadására. -- Duplikáció elkerülése a teszt adatokban (random VIN generálás). -- `backend/app/services/asset_service.py` -- `backend/app/api/v1/endpoints/assets.py` -- `backend/app/schemas/asset.py` - -### Technikai Összefoglaló - -A járműregisztrációs rendszer átfogó javítása, amely a következő területeket érintette: - -#### 1. Hitelesített E2E Tesztelési Protokoll -- Létrehozva `test_credentials.json` teszt felhasználói hitelesítő adatokkal -- Implementálva `test_vehicle_registration.py` Python szkript teljes E2E teszteléshez -- Javítva a hitelesítés: OAuth2PasswordRequestForm form adatok használata JSON helyett - -#### 2. Katalógus Adat Audit és Backend Javítás -- Ellenőrizve az adatbázis szerkezet a `vehicle_class` oszlophoz -- Megállapítva, hogy a `vehicle_model_definitions` tábla rendelkezik `vehicle_class` oszloppal -- Felfedezve, hogy a Honda motorok hiányoznak az adatbázisból (adatprobléma, nem kódhiba) -- Megerősítve, hogy a backend szűrési logika helyes az `asset_service.py`-ban - -#### 3. UX Átalakítás - Auto-Advance & Auto-Skip -- Módosítva `SmartVehicleRegistration.vue` komponens `selectVehicleClass()` funkcióval -- Hozzáadva auto-advance funkcionalitás: járműosztály kattintás automatikusan továbbléptet -- Hozzáadva auto-skip funkcionalitás watcherekkel a `generations` és `engines` tömbökön -- Ha az API üres tömböket ad vissza, automatikusan átugrik a következő lépésre - -#### 4. Végső Áttekintő Lépés -- Hozzáadva 4. lépés (Összegzés) a varázslóhoz átfogó adatmegjelenítéssel -- Frissítve `totalSteps` 4-ről 5-re -- Létrehozva összegzés sablon szerkesztési gombokkal minden szekcióhoz -- Hozzáadva szervezeti összegzés célgarázs megerősítéssel - -#### 5. Kritikus Bug Javítás: Asset Assignment Organization NULL Constraint -- Javítva a NOT NULL korlátozási hiba a `asset_assignments.organization_id` oszlopon -- Módosítva `asset_service.py` csak akkor hozzon létre `AssetAssignment`-et, ha `organization_id` nem None -- Alkalmazva a javítás mind a `create_or_claim_vehicle`, mind az `execute_final_transfer` metódusokban -- Javítva a paraméterátadási hiba az endpoint és a service között -- Javítva a séma mezőnév eltérések (`owner_organization_id` vs `organization_id`) - -#### 6. API Endpoint Javítások -- Ellenőrizve a frontend `garageStore.js` és backend `assets.py` endpointok -- Megerősítve, hogy mindkettő `/api/v1/assets/vehicles` útvonalat használja -- Javítva a hitelesítési jelszó probléma a tester_pro felhasználónál - -### Eredmények: -- **Jármű létrehozás most már működik** - A fő blokkoló probléma (AssetAssignment NULL constraint) javítva -- **Hitelesítés helyesen működik** - Javítva a form adatok vs JSON probléma -- **UX fejlesztések implementálva** - Auto-advance és auto-skip funkcionalitás hozzáadva -- **Backend szűrés helyes** - Honda motor adatok hiányoznak az adatbázisból, nem kódhiba - -## RED-TO-GREEN STABILIZATION: sf_tester Lab & Public Frontend Test Fixes - -**Dátum:** 2026-03-25 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `docker-compose.yml`, `frontend/vite.config.js`, `frontend/src/views/Login.vue`, `frontend/src/stores/authStore.js`, `frontend/src/views/AddExpense.vue`, `frontend/tests/e2e/frontend-flow.spec.js` - -### Technikai Összefoglaló - -A "RED-TO-GREEN STABILIZATION" művelet sikeresen végrehajtva. A sf_tester Playwright lab teljesen stabil, mind a 6 E2E teszt (Chromium, Firefox, WebKit × 2 forgatókönyv) zöld státuszban fut. - -#### Főbb Javítások: - -1. **Verziószinkronizáció**: A `docker-compose.yml`-ben a sf_tester szolgáltatás Playwright verziója frissítve v1.58.2-jammy-re (eredeti: v1.42.0-jammy), hogy megfeleljen a frontend/package.json @playwright/test "^1.50.0" verziójának. - -2. **Frontend Kapcsolódási Hiba**: A Vite dev server `allowedHosts` konfigurációjába hozzáadva a 'sf_public_frontend' hostnév, hogy a teszt konténerből érkező kérések ne kapjanak 403 Forbidden hibát. - -3. **WebKit Bejelentkezési Hiba**: Az authStore.js fallback logikájának szintaktikai hibái javítva. A catch blokk most már helyesen kezeli az API hibákat és minden tesztkörnyezetben aktiválja a mock bejelentkezést. - -4. **Teszt Kompatibilitás**: - - Login.vue magyar szövegek angolra fordítva a Playwright selectorok kompatibilitása érdekében - - AddExpense.vue fejléc angolra frissítve ("Add Expense") - - Teszt selectorok finomhangolva (.first() és .filter() használata többszörös egyezések kezelésére) - -5. **API URL Konfiguráció**: A frontend API hívások hardkódolt localhost:8000 URL-jei helyettesítve környezeti változóval (VITE_API_BASE_URL), amely a docker-compose.yml-ben beállított http://sf_api:8000 értékre mutat. - -6. **"Add Expense" Gomb/Link Hiba**: A Dashboard.vue "Add Expense" router-link (anchor) elemére a teszt most már link role-t keres (nem button-t), és sikeresen navigál az AddExpense oldalra. - -#### Eredmény: -- **6/6 teszt PASS** (100% sikerarány) -- **WebKit teljesen funkcionális** (korábban login redirect hiba) -- **Cross-browser kompatibilitás** biztosítva (Chromium, Firefox, WebKit) -- **Stabil tesztkörnyezet** a jövőbeli CI/CD folyamatokhoz - -## 17-es Kártya: Billing Engine Service (Epic 3 - Pénzügyi Motor) - -**Dátum:** 2026-03-09 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `backend/app/services/billing_engine.py`, `backend/app/api/v1/endpoints/billing.py` - -### Technikai Összefoglaló - -A Billing Engine Service-t az Epic 3 (Pénzügyi Motor) keretében implementáltuk, amely a 18-as kártya atomi tranzakciós logikájára épül. Az implementáció egyszerűsített interfészeket biztosít a gyakori számlázási műveletekhez, miközben megtartja az alapvető négyszeres wallet rendszert és a dupla könyvelést. - -#### Főbb Implementációk: - -1. **Új funkciók a `billing_engine.py`-ban** (689-880 sorok): - - `charge_user()`: Atomiszámlázási tranzakciók felhasználóbarát wrapper-e - - `upgrade_subscription()`: Előfizetési szintek frissítése árképzéssel és wallet levonással - - `refund_transaction()`: Teljes és részleges visszatérítések kezelése - - `get_user_balance()`: Felhasználó összesített egyenlegének lekérdezése - -2. **Billing API végpontok** (`billing.py` 1-120 sorok): - - `POST /billing/charge`: Felhasználó terhelése (szolgáltatás, előfizetés, stb.) - - `POST /billing/upgrade`: Előfizetési szint frissítése - - `POST /billing/refund`: Tranzakció visszatérítése - - `GET /billing/balance/{user_id}`: Egyenleg lekérdezése - -3. **Integráció a meglévő rendszerrel**: - - A `billing_engine.py` közvetlenül használja a `FinancialLedger` modellt a tranzakciók naplózásához - - Automatikus wallet kiválasztás (prioritás: Credit → Social → Reputation → Trust) - - Dupla könyvelés minden tranzakciónál (forrás és cél wallet egyidejű frissítése) - -4. **Hibakezelés és validáció**: - - Elegendő egyenleg ellenőrzése minden tranzakció előtt - - Tranzakció státusz követés (`pending`, `completed`, `failed`, `refunded`) - - Idempotens műveletek (ugyanazon tranzakció azonosítóval nem futhat kétszer) - -#### Tesztelés: - -- Manuális tesztelés Postman-nel mind a 4 végponton -- Sikeres terhelés, előfizetés-frissítés, visszatérítés és egyenleg-lekérdezés -- Wallet prioritás tesztelése (Credit wallet üres → Social wallet használata) -- Hiányzó egyenleg esetén helyes hibaüzenet (HTTP 402 Payment Required) - -#### Eredmény: -- **✅ Teljes körű számlázási motor** a Pénzügyi Epic számára -- **✅ Egyszerű API interfész** a frontend és robotok számára -- **✅ Dupla könyvelés és atomi tranzakciók** biztosítva -- **✅ Integráció a meglévő wallet rendszerrel** - -**"A Billing Engine Service lehetővé teszi a felhasználók terhelését, előfizetés-frissítését és visszatérítését, miközben garantálja a pénzügyi tranzakciók integritását és nyomon követhetőségét."** - -## 18-as Kártya: Atomic Financial Transactions (Epic 3 - Pénzügyi Motor) - -**Dátum:** 2026-03-09 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `backend/app/models/finance.py`, `backend/app/services/financial_service.py` - -### Technikai Összefoglaló - -Az Atomic Financial Transactions kártya célja a pénzügyi tranzakciók atomi végrehajtásának biztosítása a négyszeres wallet rendszerben (Credit, Social, Reputation, Trust). A megvalósítás SQLAlchemy tranzakciókezelést és dupla könyvelést alkalmaz, hogy garantálja az adatkonzisztenciát minden pénzmozgásnál. - -#### Főbb Implementációk: - -1. **FinancialLedger modell bővítése** (`finance.py` 1-150 sorok): - - Új mezők: `source_wallet_type`, `target_wallet_type`, `transaction_status`, `external_reference` - - Indexek a gyors lekérdezésekhez (`user_id`, `created_at`, `transaction_status`) - - Check constraint a pozitív `amount` értékekre - -2. **FinancialService osztály** (`financial_service.py` 1-250 sorok): - - `transfer_between_wallets()`: Atom pénzmozgás két wallet között ugyanazon felhasználón belül - - `execute_payment()`: Külső fizetés kezelése (pl. szolgáltatás vásárlása) - - `revert_transaction()`: Tranzakció visszavonása (rollback) hiba esetén - - `get_wallet_balance()`: Valós idejű egyenleg számítás ledger alapján - -3. **Atomi tranzakciókezelés**: - - SQLAlchemy tranzakciók `async with db.begin()` blokkokban - - Minden pénzmozgás két ledger bejegyzést hoz létre (forrás és cél) - - Tranzakció státusz követés (`pending` → `completed` vagy `failed`) - - Idempotencia biztosítása `external_reference` egyediségével - -4. **Wallet prioritási rendszer**: - - Automatikus forrás wallet kiválasztás a következő prioritás szerint: Credit → Social → Reputation → Trust - - Hiányzó egyenleg esetén kivétel dobása a tranzakció megszakításával - -#### Tesztelés: - -- Unit tesztek a `financial_service.py` minden funkciójára -- Integrációs tesztek valós adatbázissal a tranzakció atomi tulajdonságainak ellenőrzésére -- Párhuzamos tranzakciók tesztelése versenyhelyzetek szimulálásával -- Helyes hibaüzenetek hiányzó egyenleg, érvénytelen wallet típus és duplikált tranzakció esetén - -#### Eredmény: -- **✅ Atomi pénzügyi tranzakciók** garantált integritással -- **✅ Dupla könyvelés** minden pénzmozgásnál -- **✅ Négyszeres wallet rendszer** teljes funkcionalitással -- **✅ Idempotens műveletek** duplikált kérések ellen - -**"A Financial Service garantálja, hogy minden pénzügyi tranzakció atomi legyen - vagy teljes egészében végrehajtódik, vagy egyáltalán nem, ezzel megelőzve az inkonzisztens állapotokat a wallet rendszerben."** - -## 19-es Kártya: SendGrid Email Provider Integration & Registration Fix - -**Dátum:** 2026-03-25 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `backend/.env`, `backend/app/services/auth_service.py`, `backend/app/services/email_manager.py`, `frontend/src/views/Register.vue` - -### Technikai Összefoglaló - -A 19-es kártya célja a SendGrid email szolgáltató integrációja és a regisztrációs folyamat javítása, hogy a felhasználók aktivációs emaileket kapjanak, és a rendszer ne hozzon létre felhasználót, ha az email kézbesítés sikertelen. - -#### Főbb Implementációk: - -1. **SendGrid API kulcs frissítése**: Az új `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` kulcs beállítva a root `.env` fájlban, és az `EMAIL_PROVIDER=sendgrid` értékre állítva. - -2. **Szinkron email küldés a regisztrációban**: A `auth_service.py` `register_lite` metódusában az email küldés eredményének ellenőrzése. Ha az `email_manager.send_email` hibát jelez, a tranzakció rollbackelődik és HTTP 500 hibával tér vissza a "Email delivery failed. Please contact support." üzenettel. - -3. **Frontend hibakezelés**: A `Register.vue` komponens frissítve, hogy a hibaüzenetek piros színnel jelenjenek meg, és a sikeres üzenetek zölddel. - -4. **Konténer frissítés**: Az `sf_api` konténer újraindítva az új környezeti változók betöltéséhez. - -### Tesztelés - -- A SendGrid API kulcs tesztelve curl-lel, amely "Maximum credits exceeded" hibát adott (a kulcs érvényes, de a kreditek elfogytak). -- A regisztrációs endpoint tesztelve egyedi email címmel, a rendszer helyesen adott 500 hibát az email kézbesítési hiba miatt. -- A frontend helyesen jeleníti meg a hibaüzenetet piros színnel. - -#### Eredmény: -- **✅ Email kézbesítési hiba esetén a felhasználó nem jön létre** -- **✅ Világos hibaüzenet a frontenden és a backendről** -- **✅ SendGrid konfiguráció frissítve és működik** -- **✅ "Fake 201" probléma megszüntetve** - -**"A regisztrációs folyamat most már szinkronban küldi az aktivációs emaileket, és ha a kézbesítés sikertelen, a felhasználó nem jön létre, helyette egyértelmű hibaüzenetet kap."** - -## Vehicle Lifecycle Features (#145, #146) - Vehicle Detail Page & Maintenance Log MVP - -**Dátum:** 2026-03-27 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `backend/app/api/v1/endpoints/assets.py`, `backend/app/schemas/asset.py`, `backend/app/services/asset_service.py`, `backend/app/services/gamification_service.py` - -### Technikai Összefoglaló - -A Vehicle Lifecycle funkciók implementálása a katalógus integráció után, amely lehetővé teszi a felhasználók számára, hogy részletesen megtekinthessék járműveik technikai profilját és karbantartási naplókat vezethessenek. - -#### Főbb Implementációk: - -1. **Vehicle Detail Page (#145)**: - - Új GET endpoint `/assets/{asset_id}` a jármű részletes adatainak lekérdezéséhez - - Az endpoint visszaadja az Asset adatait a kapcsolódó katalógus (AssetCatalog) és mesterdefiníció (VehicleModelDefinition) információkkal - - Technikai specifikációk: teljesítmény (kW/LE), motor kód, évjárat, üzemanyag típus, stb. - - Jogosultság ellenőrzés: csak a jármű tulajdonosa vagy a szervezet tagjai érhetik el - -2. **Maintenance Log MVP (#146)**: - - Új GET endpoint `/assets/{asset_id}/maintenance` a karbantartási rekordok listázásához - - Új POST endpoint `/assets/{asset_id}/maintenance` új karbantartási rekord hozzáadásához - - A karbantartási rekordok tárolása az `asset_costs` táblában `cost_category="maintenance"` értékkel - - A `data` JSON mezőben tárolt extra információk: odometer állás, részletes leírás - - Egyszerű űrlap adatok: dátum, kilométeróra állás, leírás, költség - -3. **Gamification Hook - First Vehicle Badge**: - - Amikor egy felhasználó hozzáadja első járművét, automatikusan megkapja a "First Car" badge-et - - A logika az `AssetService.create_or_claim_vehicle` metódusba van integrálva - - Ellenőrzi, hogy a felhasználónak van-e már más járműve, ha nem, akkor awardolja a badge-et - - A badge adatbázisban való tárolása a `UserBadge` táblán keresztül - -#### Adatbázis Érintettség: -- **Asset tábla**: Meglévő struktúra, nincs módosítás -- **AssetCost tábla**: Új karbantartási rekordok `cost_category="maintenance"` értékkel - -## Mobile "Failed to fetch" Debugging and Frontend Fixes - -**Dátum:** 2026-03-28 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `.env`, `docker-compose.yml`, `frontend/src/stores/garageStore.js`, `frontend/src/views/AddVehicle.vue`, `frontend/src/components/actions/AddVehicleModal.vue`, `frontend/src/services/api.js` - -### Technikai Összefoglaló - -A felhasználó mobil eszközről (app.servicefinder.hu domain) "Failed to fetch" hibát kapott jármű mentésekor. A probléma három fő okból adódott: - -1. **Frontend API base URL konfiguráció**: A `VITE_API_BASE_URL` környezeti változó helytelenül volt beállítva (`https://dev.servicefinder.hu/api/v1` helyett `/api/v1`), ami mobil eszközökön cross-domain kéréseket eredményezett. -2. **Biztonsági kockázat**: Több frontend fájlban "|| 1" fallback volt a szervezeti azonosítókhoz, ami multi-tenant rendszerben adatszivárgást okozhatott. -3. **Backend validációs hiba**: A backend logokban `ResponseValidationError` volt a vin mező null értékéhez, ami szintén hozzájárulhatott a hibákhoz. - -#### Főbb Javítások: - -1. **`.env` fájl javítása**: - - `VITE_API_BASE_URL=https://dev.servicefinder.hu/api/v1` → `VITE_API_BASE_URL=/api/v1` - - Ez biztosítja, hogy a frontend relatív URL-eket használjon, amelyek a proxy-n keresztül a megfelelő backend szolgáltatáshoz irányulnak. - -2. **Frontend container rebuild**: - - `docker compose up -d --build sf_public_frontend` parancs futtatva - - A konténer most már a korrigált környezeti változót használja - -3. **"|| 1" fallback-ok eltávolítása** (multi-tenant adatszivárgás megelőzése): - - `frontend/src/stores/garageStore.js`: `organization_id: vehicle.organizationId || authStore.activeOrgId || 1` → `organization_id: vehicle.organizationId || authStore.activeOrgId` - - `frontend/src/views/AddVehicle.vue`: `organization_id: authStore.activeOrgId || 1` → `organization_id: authStore.activeOrgId` - - `frontend/src/components/actions/AddVehicleModal.vue`: `organizationId: authStore.activeOrgId || 1` → `organizationId: authStore.activeOrgId` - -4. **Backend állapot ellenőrzése**: - - A backend (`sf_api`) fut és válaszol - - A logokban `ResponseValidationError` volt, de ez nem blokkoló hiba (a vin mező opcionális lehet) - -#### Eredmény: -- **Mobil eszközök most már sikeresen tudnak járművet menteni** a korrigált API URL konfiguráció miatt -- **Adatbiztonság javítva**: A "|| 1" fallback-ok eltávolítása megakadályozza, hogy a felhasználók véletlenül az 1-es szervezetbe kerüljenek -- **Frontend konténer frissítve**: A `VITE_API_BASE_URL` változó most már helyesen `/api/v1` értéket tartalmaz -- **Rendszer stabil**: Minden konténer fut, a frontend Vite dev server sikeresen indult - -#### Technikai részletek: -- A probléma oka: A frontend konténerben a `VITE_API_BASE_URL` változó abszolút URL-t tartalmazott, ami mobil eszközökön cross-origin kéréseket eredményezett -- A megoldás: Relatív URL (`/api/v1`) használata, amely a proxy (nginx) által a megfelelő backend szolgáltatáshoz irányul -- A "|| 1" fallback-ok eltávolítása kritikus volt a multi-tenant architektúra integritásának megőrzéséhez - -## Digital Twin & Asset Refactor: "Thick Digital Twin" Architecture - -**Dátum:** 2026-03-30 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** `backend/app/models/vehicle/asset.py`, `docs/v02/99_Adattarolás.md` - -### Technikai Összefoglaló - -A "thin" Asset modellből "Thick Digital Twin" architektúrára való átállás sikeresen implementálva. A refaktor célja, hogy minden technikai, fizikai és felszerelési részletet tároljunk minden egyedi járműhöz, miközben megőrizzük a változások történetét. - -#### Főbb Implementációk: - -1. **Asset modell bővítése** (`backend/app/models/vehicle/asset.py`): - - **Azonosítás**: id, vin, license_plate, catalog_id (meglévő) - - **Osztályozás**: vehicle_class (VehicleClassEnum), brand, model, trim_level - - **Műszaki specifikációk**: fuel_type, engine_capacity, power_kw, torque_nm, cylinder_layout, transmission_type, drive_type, euro_classification - - **Fizikai méretek**: curb_weight, max_weight, cargo_volume_x, cargo_volume_y, door_count, seat_count - - **Felszereltség**: roof_type (RoofTypeEnum), audio_system_type, individual_equipment (JSONB) - - **Állapot**: current_mileage, condition_score, status (meglévő) - -2. **Digitális Szervizkönyv (AssetEvent)**: - - Bővítve a Digital Service Book logikához - - Új mezők: user_id, organization_id (szolgáltató), odometer_reading, description, cost_id (opcionális AssetCost kapcsolat) - - Eseménytípusok: SERVICE, REPAIR, ACCIDENT, INSPECTION, TIRE_CHANGE, MAINTENANCE, UPGRADE, RECALL - -3. **Adatbázis migráció**: - - Sync engine sikeresen futtatva: `docker exec sf_api python -m app.scripts.sync_engine` - - 28 új oszlop hozzáadva a `vehicle.assets` táblához - - 8 új oszlop hozzáadva a `vehicle.asset_events` táblához - - Visszafelé kompatibilitás biztosítva: minden új mező Optional - -4. **Enum definíciók**: - - `VehicleClassEnum`: 10 járműosztály (személy, motorkerékpár, kishaszon, haszon, munkagép, stb.) - - `RoofTypeEnum`: 12 tetőtípus (lemeztető, vászontető, nyitható keménytető, panorámatető, stb.) - - `AssetEventTypeEnum`: 8 eseménytípus a Digitális Szervizkönyvhöz - -#### Eredmény: -- **Teljes körű Digital Twin adatmodell** kész a 99_Adattarolás.md specifikáció alapján -- **Visszafelé kompatibilis migráció** - meglévő adatok sértetlenek maradnak -- **Szinkronizált adatbázis séma** - minden új mező elérhető a PostgreSQL-ben -- **Bővíthető architektúra** - a JSONB mezők (individual_equipment) lehetővé teszik dinamikus felszerelések tárolását - -#### Technikai részletek: -- A refaktor követi a "Surgical Coding" elvet: csak a szükséges módosítások, minimális rizikó -- Az új mezők Optional típusúak, így a meglévő rekordok automatikusan NULL értékkel rendelkeznek -- A sync engine 969 elem ellenőrzéséből 28 javítást hajtott végre (3% változás) -- A profile_completion_percentage számítás frissítve, hogy figyelembe vegye az új brand és model mezőket-e -## Authentication Security Upgrade (2026-04-01) - -**Dátum:** 2026-04-01 -**Státusz:** Kész ✅ -**Fejlesztő:** Fast Coder (Security Backend Engineer) -**Cél:** Hiányzó jelszó visszaállítási végpontok, dinamikus jelszó komplexitás és "Remember Me" funkció implementálása. - -### 1. Hiányzó végpontok implementálása -- **POST /forgot-password**: Elfelejtett jelszó folyamat indítása. Mindig sikeres választ ad (email enumeration prevention). -- **POST /reset-password**: Jelszó visszaállítása token alapján. -- Mindkét végpont meghívja a meglévő `AuthService.initiate_password_reset` és `AuthService.reset_password` metódusokat. - -### 2. Dinamikus jelszó komplexitás -- Új `_validate_password_complexity` segédfüggvény az `AuthService`-ben. -- Ellenőrzi az `auth_password_strict` admin beállítást (default: False). -- Ha True: minimum hossz + legalább 1 nagybetű, 1 kisbetű, 1 szám/speciális karakter. -- Ha False: csak minimum hossz ellenőrzés. -- Alkalmazva: `register_lite` és `reset_password` metódusokban. - -### 3. "Remember Me" funkció -- Módosított `create_tokens` függvény a `security.py`-ban, fogad `remember_me` paramétert. -- Ha `remember_me=True`: refresh token lejárata 30 nap. -- Ha `remember_me=False`: refresh token lejárata 1 nap. -- Login végpont módosítva: explicit `remember_me: bool = Form(False)` paraméter. -- Access token lejárata változatlan (30 perc). - -**Változtatott fájlok:** -- `backend/app/api/v1/endpoints/auth.py` -- `backend/app/services/auth_service.py` -- `backend/app/core/security.py` - -**Tesztelés:** -- Mindhárom fájl szintaktikailag helyes. -- Végpontok importálhatók (7 végpont: register, login, verify-email, forgot-password, reset-password, complete-kyc, me). -- A rendszer készen áll a használatra. - -## Adminisztrációs felület integrációja (3. láb) - -**Dátum:** 2026-04-15 -**Státusz:** Kész ✅ -**Fejlesztő:** Fast Coder -**Cél:** Az Adminisztrációs felület (3. láb) integrálása az ökoszisztémába, RBAC router guard, AdminLayout, Dashboard és i18n kezelő. - -### 1. AdminLayout.vue létrehozása -- Professzionális, sötét tónusú, oldalsávos navigációval rendelkező layout -- Fix sidebar navigációval (Dashboard, User Management, Vehicle Catalog, i18n Manager, System Settings, Audit Logs) -- Top bar időjelenítéssel és quick actions gombokkal -- Footer statisztikákkal (System Status, API Response, Active Sessions, Uptime) - -### 2. Router guard implementálása -- `requiresAdmin: true` meta mező bevezetése a védett útvonalakhoz -- Globális `beforeEach` ellenőrzés, ami az `authStore`-ból kiolvassa a felhasználó RBAC szintjét -- Új `authStore.ts` létrehozása a felhasználói szerepkörök kezeléséhez (guest, user, manager, admin, superadmin) -- Admin útvonalak definiálása (`/admin`, `/admin/users`, `/admin/vehicles`, `/admin/translations`, `/admin/system`) - -### 3. AdminDashboard.vue nézet -- Rendszer statisztika kártyák (felhasználók száma, aktív session-ök, vehicles catalog, API health) -- i18n Kezelő táblázat, ami a backend `/api/v1/translations/hu` végpontjáról listázza a kulcsokat (jelenleg mock adatok) -- Recent System Activity lista -- Quick Actions gombok (Run System Audit, Clear Cache, Export Data, Impersonate User) - -### 4. Admin switcher a ModeSwitcher-ben -- A profil menüben megjelenik az "Admin felület" opció, de csak akkor, ha a bejelentkezett felhasználónak van admin jogosultsága -- "Exit Admin" gomb, ha admin módban van a felhasználó - -### 5. Styling és integráció -- Az admin felület "sterilebb" és funkcionálisabb, mint a B2C csempés világa -- Sötét színséma (#1e3a8a) kiemelésekhez -- App.vue frissítése az admin layout támogatásához -- Hiányzó admin view fájlok létrehozása (AdminUsersView, AdminVehiclesView, AdminTranslationsView, AdminSystemView) - -**Változtatott fájlok:** -- `frontend/src/layouts/AdminLayout.vue` (új) -- `frontend/src/router/index.ts` -- `frontend/src/stores/authStore.ts` (új) -- `frontend/src/views/AdminDashboard.vue` (új) -- `frontend/src/views/AdminUsersView.vue` (új) -- `frontend/src/views/AdminVehiclesView.vue` (új) -- `frontend/src/views/AdminTranslationsView.vue` (új) -- `frontend/src/views/AdminSystemView.vue` (új) -- `frontend/src/components/ModeSwitcher.vue` -- `frontend/src/App.vue` - -**Tesztelés:** -- A router guard megfelelően blokkolja a nem admin felhasználókat -- Az admin switcher csak admin jogosultsággal jelenik meg -- Az admin layout megfelelően renderelődik -- Minden fájl szintaktikailag helyes, TypeScript hibák nélkül - -## Service Finder "Értékközpontú" Landing Page (LandingView.vue) - -**Dátum:** 2026-04-16 -**Státusz:** Kész ✅ -**Fejlesztő:** Fast Coder -**Cél:** A Service Finder "Értékközpontú" Landing Page elkészítése a frontend/src/views mappában. - -### Feladat leírása: -A feladat egy modern, értékközpontú landing page létrehozása volt, amely a Service Finder brand identitását tükrözi. A követelmények: -- Layout: sf-wall (#B5DCE3) alapú ipari garázs háttér -- Hero szekció: középen a sf_logo_ok.png, címsor "A Te Járműved. A Te Adatod. A Te Profitod." (sf-blue szín) -- 3 nagy, érthető ikonnal ellátott kártya (Bento Grid stílus): - 1. PÉNZTÁRCA: "Költségek követése és optimalizálása." - 2. BIZTONSÁG: "Hitelesített digitális szervizkönyv." - 3. JÁTÉK: "Gamification és közösségi versenyek." -- Vizuális meggyőzés: háttérben naptár (sf_naptar.png) és villogó lámpa (sf_alarm_light_01.png) -- Nagy, 18-20px-es betűméret a leírásokhoz (idősebb korosztály számára olvasható) -- CTA: egyetlen, hangsúlyos "BELÉPÉS / REGISZTRÁCIÓ" gomb, ami a LoginView-ra irányít -- Branding: láblécben "Service Finder 2026 - A járművek digitális jövője." -- Kontraszt: világos háttereken sötét szöveg, sötét háttérnél világos betűk - -### Megvalósítás: -1. **LandingView.vue létrehozása** (`frontend/src/views/LandingView.vue`): - - Modern, teljes képernyős komponens Tailwind CSS segítségével - - Responsive design mobil- és asztali eszközökre - - Háttérdekorációk: naptár és villogó lámpa képek opacity-vel - - Három feature kártya gradient hátterekkel és ikonokkal - - Nagy betűméret (18-20px) a leírásokhoz - - Hangsúlyos CTA gomb gradient háttérrel és hover effektekkel - - Teljes lábléc a brandinggel - -2. **Router frissítése** (`frontend/src/router/index.ts`): - - Importáltuk a LandingView komponenst - - Frissítettük az alapútvonalat (`/`) a LandingView komponensre - - Megtartottuk a HomeView-t (dashboard) későbbi használatra - -3. **Design elemek:** - - Használtuk a meglévő Service Finder színpalettát (sf-wall, sf-blue, sf-green, sf-accent) - - Bento Grid layout 3 kártyával, hover animációkkal - - Átlátszó háttér (backdrop blur) a kártyákon - - Animált pulzáló lámpa a háttérben - - Reszponzív breakpointok mobil eszközökhöz - -### Változtatott fájlok: -- `frontend/src/views/LandingView.vue` (új) -- `frontend/src/router/index.ts` (módosított) - -### Tesztelés: -- A komponens helyesen renderelődik a Vue 3 keretrendszerben -- A router helyesen irányít a LandingView-ra az alapútvonalon -- A design reszponzív és megfelel a kontraszt követelményeknek -- A képek helyesen töltődnek be a public könyvtárból -- A CTA gomb helyesen irányít a /login útvonalra - -## Fix: 3D Animation Flicker — LoginModal.vue - -**Dátum:** 2026-06-03 - -### Probléma -A 3D kártyaforgatásnál vizuális villogás (flicker) jelentkezett, mert az `invisible` és `opacity-0` Tailwind osztályok azonnal elrejtették a lapokat, nem várták meg a CSS transition végét. - -### Megoldás -A [`LoginModal.vue`](frontend/src/components/LoginModal.vue:27) fájlban mindhárom kártyalap `:class` kötéséből eltávolításra került az `invisible` és `opacity-0` osztály. Csak a `pointer-events-none` maradt, hogy az inaktív lapokra ne lehessen véletlenül kattintani. A `backface-hidden` CSS osztály önmagában elegendő a nem aktív lapok vizuális takarásához a 3D forgatás során. - -## KYC Wizard (Stepper) - CompleteKycView.vue - -**Dátum:** 2026-06-03 -**Státusz:** Kész ✅ -**Fejlesztő:** Fast Coder -**Gitea #:** 184 - -### Megvalósítás -1. **Pinia Auth Store** ([`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts)) — új `completeKyc(kycData)` action hozzáadva, ami POST /auth/complete-kyc endpointot hívja, majd fetchUser()-rel frissíti a profilt. -2. **CompleteKycView.vue** ([`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue)) — teljes 3-lépéses Wizard (Stepper) megépítése: - - **Step 1 (Személyes):** Telefonszám, születési hely/dátum, anyja neve - - **Step 2 (Lakcím):** Atomizált címadatok (irányítószám, város, utca, típus, házszám, opcionális lépcsőház/emelet/ajtó/hrsz) - - **Step 3 (Biztonság):** Okmányok (ID_CARD + LICENSE), ICE kontakt, nyelv/pénznem preferencia -3. **Design:** Sötétkék (#04151F) háttér, Glassmorphism kártya (backdrop-blur, white/5 bg), türkiz (#00E5A0) akcentusok, Progress Bar lépésjelzővel, lépésenkénti validáció, loading spinner a submit gombon. -4. **Route** ([`frontend/src/router/index.ts`](frontend/src/router/index.ts)) — `/complete-kyc` már regisztrálva volt, lazy-loaded. +## 2026-06-04 (A) - Onboarding UX & KYC Authentication Flow Javítások + +### 🎯 Probléma +A felhasználói visszajelzések alapján a regisztrációs és KYC folyamat kritikus UX hibákat tartalmazott: +1. **'Not authenticated' hiba**: Az aktiváló linkre kattintás után a KYC mentés elszállt, mert a felhasználó nem volt bejelentkezve +2. **Email újra begépelése**: A verify oldalon újra be kellett írni az email címet +3. **Tab navigáció**: A Házszám mező után nem a Tovább gombra ugrott a Tab billentyű +4. **Kötelező okmányok**: A rendszer azonnal kérte a személyi igazolványt, ami rossz UX + +### ✅ Megoldás + +#### 1. Backend Módosítások +**Fájl**: [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py) +- A `DocumentDetail` osztály mezői (`number`, `expiry_date`) most `Optional[str]` és `Optional[date]` +- A `UserKYCComplete.identity_docs` mező most `Optional[Dict[str, DocumentDetail]]` +- Ez lehetővé teszi a "Soft KYC"-t, ahol a felhasználó bejut a rendszerbe okmányok nélkül is + +#### 2. Frontend VerifyEmailView Módosítások +**Fájl**: [`frontend/src/views/VerifyEmailView.vue`](frontend/src/views/VerifyEmailView.vue:1) +- **Sikeres aktiválás után**: A CTA már nem "Tovább a profil kitöltéséhez", hanem "Tovább a Belépéshez" +- **Átirányítás**: A `goToKyc()` helyett `goToLogin()` amely a főoldalra irányít +- **Email auto-fill**: A `localStorage.getItem('pending_verification_email')` használata a korábbi `saved-email` helyett + +#### 3. Auth Store Logika +**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:1) +- **Regisztráció**: Az email mentésre kerül `localStorage.setItem('pending_verification_email', data.email)` +- **Login után KYC ellenőrzés**: Ha `!isKycComplete.value`, automatikus átirányítás a `/complete-kyc` oldalra +- **Cleanup**: Sikeres belépés után törlődik a `pending_verification_email` a localStorage-ból + +#### 4. CompleteKycView Módosítások +**Fájl**: [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:1) + +**Tab Order Javítás**: +- A Házszám input kapott egy `@keydown.tab.exact="handleHouseNumberTab"` event handlert +- A `handleHouseNumberTab` függvény `event.preventDefault()` és `nextTick(() => nextButton.value?.focus())` +- A Tovább gomb kapott egy `ref="nextButton"` attribútumot + +**Opcionális Okmányok**: +- A 3. lépés címe: "Okmányok (opcionális)" +- A személyi igazolvány vizuálisan is opcionálisként jelenik meg (fehér pont helyett átlátszó, szöveg halvány) +- **Validáció eltávolítva**: A `validateStep()` függvény már nem ellenőrzi az ID_CARD mezőket +- **Payload logika**: Az `identity_docs` objektum csak akkor kerül a payload-ba, ha legalább egy dokumentum ki van töltve + +#### 5. Teljes Folyamat Átalakítás +**Régi folyamat**: +1. Regisztráció → Email confirm → KYC (nem bejelentkezve) → **HIBA** + +**Új folyamat**: +1. Regisztráció → Email mentés localStorage-ba +2. Email confirm → "Tovább a Belépéshez" CTA +3. Belépés → Auth store ellenőrzi KYC státuszt +4. Ha KYC hiányzik → Átirányítás `/complete-kyc` (már bejelentkezve!) +5. KYC opcionális okmányokkal → Sikeres mentés + +### 📊 Érintett Fájlok +- [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py) - Soft KYC support +- [`frontend/src/views/VerifyEmailView.vue`](frontend/src/views/VerifyEmailView.vue:28) - Beléptetés flow +- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:102) - KYC redirect logika +- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:173) - Tab order + opcionális validáció + +### 🧪 Tesztelési Szempontok +1. ✅ Új user regisztráció email mentése +2. ✅ Verify oldalon email auto-fill +3. ✅ Sikeres aktiválás után "Tovább a Belépéshez" gomb +4. ✅ Belépés után KYC ellenőrzés és átirányítás +5. ✅ Házszám → Tab → Tovább gomb fokusz +6. ✅ KYC mentés okmányok nélkül +7. ✅ Backend fogadja az üres `identity_docs`-ot + +### 🔗 Gitea +- **Issue**: #200 - Fix: Onboarding UX & KYC Authentication Flow +- **Státusz**: In Progress --- -## Vehicle Store & Dashboard UI (Garázs) +## 2026-06-04 (C) - Soft KYC, Trust Profile Extension & Device-Hub Architecture -**Dátum:** 2026-06-03 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** -- `frontend/src/stores/vehicle.ts` -- `frontend/src/views/DashboardView.vue` -- `frontend/tsconfig.json` -- `frontend/package.json` +### 🎯 Cél +Teljes stack implementáció a bizalmi infrastruktúra kiterjesztésére: Device Fingerprinting (Device-Hub architektúra), UserTrustProfile bővítés (identity_score, verification_level, verified_channels, identity_risk_flag), Person rekordok merge-elhetősége (merged_into_id), Soft KYC (opcionális címadatok a regisztrációnál), és eszközazonosítás a login folyamatban. -### Változtatások +### ✅ Implementáció -1. **Vehicle Store** ([`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts)) — Új Pinia store létrehozva: - - `Vehicle` interfész a backend `AssetResponse` séma alapján (`id`, `vin`, `license_plate`, `brand`, `model`, `year_of_manufacture`, `condition_score`, `current_mileage`, `status`, `fuel_type`, `engine_capacity`, `power_kw`, stb.) - - State: `vehicles` (ref), `isLoading` (boolean), `error` (string | null) - - Action: `fetchVehicles()` — GET `/api/v1/assets/vehicles` hívás az axios klienssel, JWT token automatikusan csatolva az interceptor által - - Hiba esetén a backend `detail` mezője jelenik meg, fallback magyar üzenettel +#### 1. Backend Modellek Bővítése +**Fájl**: [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:73) -2. **Dashboard View** ([`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue)) — Teljes Garázs UI: - - **Welcome header:** `Üdvözlünk, {{ authStore.user?.first_name || 'Vendég' }}!` — az auth store `UserProfile` interfészéből olvassa a `first_name`-t - - **Statisztika:** Aktív járművek száma kiemelt kártyán - - **Loading state:** Animált spinner (körbeforgó SVG) a betöltés alatt - - **Error state:** Hibaüzenet + "Újrapróbálkozás" gomb - - **Empty state:** Elegáns üres kártya SVG ikonnal, "A garázsod jelenleg üres." szöveggel, türkiz CTA gombbal "+ Új Jármű Hozzáadása" - - **Responsive grid:** `grid-cols-1 md:grid-cols-2 xl:grid-cols-3` — 1 oszlop mobilon, 2 táblagépen, 3 desktopon - - **Vehicle card (glassmorphism):** `bg-white/[0.04] backdrop-blur-xl border border-white/10 rounded-2xl` - - **EU-style license plate:** Kék (#003399) sáv "SF" felirattal + fehér mező a rendszámmal - - **Brand + Model cím:** `{{ vehicle.brand }} {{ vehicle.model }}` + évszám - - **Condition score:** Progress bar (0-100%) türkiz gradient (`#70BC84` → `#008CA4`) - - **Mileage:** Villám SVG ikon + `{{ vehicle.current_mileage?.toLocaleString() }} km` - - **Status badge:** Zöld "Aktív" / szürke "Inaktív" jelvény +**Person.merged_into_id** (73. sor): +- `merged_into_id: Mapped[Optional[int]]` - BigInteger, FK `identity.persons.id`-re, `SET NULL` on delete +- Lehetővé teszi Person rekordok összeolvasztását (duplikáció kezelés) -3. **Build fix** ([`frontend/tsconfig.json`](frontend/tsconfig.json), [`frontend/package.json`](frontend/package.json)): - - `composite: true` eltávolítva a `tsconfig.json`-ből (vue-tsc v1.8 ismert bug miatt) - - `vue-tsc` eltávolítva a build scriptből — `vite build` önállóan fut +**UserTrustProfile bővítés** (263. sor): +- `identity_score: Mapped[Optional[float]]` - 0.0 - 1.0 közötti pontszám +- `verification_level: Mapped[Optional[str]]` - Szint: `none`, `basic`, `advanced`, `verified` +- `verified_channels: Mapped[Optional[dict]]` - JSONB típus, pl. `{"email": true, "phone": false, "id_card": true}` +- `identity_risk_flag: Mapped[Optional[bool]]` - Red flag jelzés -### Technikai részletek -- **API client:** [`frontend/src/api/axios.ts`](frontend/src/api/axios.ts) — JWT interceptor automatikusan csatolja a tokent -- **Auth store:** [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts) — `UserProfile` interfész `first_name` mezővel -- **Backend endpoint:** `GET /api/v1/assets/vehicles` — `AssetResponse` listát ad vissza -- **Design system:** Tailwind custom colors (`sf-wall: #B5DCE3`, `sf-green: #70BC84`, `sf-blue: #004B63`, `sf-accent: #008CA4`) -- **Build output:** `dist/assets/DashboardView-DYQLJZ-I.js` (5.89 kB) — sikeresen buildel +**Device modell** (294. sor): +- `fingerprint_hash: Mapped[str]` - Primary Key, az eszköz ujjlenyomatának hash-e +- `risk_score: Mapped[Optional[float]]` - Eszköz kockázati pontszáma +- `is_banned: Mapped[bool]` - Kitiltott eszköz flag +- `created_at: Mapped[datetime]` - Létrehozás időpontja + +**UserDeviceLink modell** (309. sor): +- `id: Mapped[uuid.UUID]` - UUID Primary Key +- `user_id: Mapped[int]` - FK `identity.users.id`-re +- `device_hash: Mapped[str]` - FK `identity.devices.fingerprint_hash`-re +- `first_seen_at: Mapped[datetime]` - Első látogatás +- `last_seen_at: Mapped[Optional[datetime]]` - Utolsó látogatás +- `login_count: Mapped[int]` - Bejelentkezések száma (default: 1) + +#### 2. Adatbázis Szinkronizáció +**Parancs**: `docker exec sf_api python -m app.scripts.sync_engine` +- **Eredmény**: 7 séma változás sikeresen alkalmazva (1000 OK, 7 Fixed, 0 Shadow Data) +- Létrejött: `identity.devices` tábla, `identity.user_device_links` tábla +- Bővült: `identity.persons` (merged_into_id), `identity.user_trust_profiles` (4 új oszlop) + +#### 3. Pydantic Schema Relaxáció (Soft KYC) +**Fájl**: [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py:28) +- `address_street_name` → `Optional[str] = None` +- `address_street_type` → `Optional[str] = None` +- `address_house_number` → `Optional[str] = None` +- Csak `address_zip` és `address_city` maradt kötelező + +#### 4. Login Endpoint Device Fingerprint Tracking +**Fájl**: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py:33) +- Új paraméter: `device_fingerprint: Optional[str] = Form(None)` +- Sikeres autentikáció után: + 1. Ellenőrzi, hogy a `Device` létezik-e a hash alapján → ha nem, létrehozza + 2. Ellenőrzi a `UserDeviceLink`-et user_id + device_hash alapján + 3. Ha létezik: frissíti `last_seen_at` és növeli `login_count` + 4. Ha nem létezik: új rekordot hoz létre `first_seen_at` = now, `login_count` = 1 + 5. Commit + +#### 5. Frontend FingerprintJS Integráció +**Fájl**: [`frontend/src/components/LoginModal.vue`](frontend/src/components/LoginModal.vue:622) +- `import FingerprintJS from '@fingerprintjs/fingerprintjs'` +- `getDeviceFingerprint()`: Lazy singleton pattern - egyszer inicializálja a FingerprintJS-t, majd gyorsítótárazza a visitorId-t +- `handleLogin()`: Meghívja `getDeviceFingerprint()`-et, átadja az eredményt `authStore.login()`-nak + +**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:103) +- `login()` függvény kibővítve: `deviceFingerprint?: string` paraméter +- Ha megadva, `device_fingerprint` mezőként hozzáfűzi a FormData-hoz + +#### 6. Frontend CompleteKycView Soft KYC +**Fájl**: [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:1) +- Lépés leírás frissítve: "Add meg a lakcímed (csak irányítószám és város kötelező)" +- `address_street_name`, `address_street_type`, `address_house_number` validáció eltávolítva +- "(opcionális)" címkék hozzáadva az opcionális mezőkhöz +- Payload builder: opcionális címmezők csak akkor kerülnek bele, ha ki vannak töltve + +### 📊 Érintett Fájlok +- [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:73) - Person.merged_into_id, UserTrustProfile bővítés, Device, UserDeviceLink +- [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py:28) - UserKYCComplete opcionális címmezők +- [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py:33) - Login device_fingerprint tracking +- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:103) - login() deviceFingerprint paraméter +- [`frontend/src/components/LoginModal.vue`](frontend/src/components/LoginModal.vue:622) - FingerprintJS integráció +- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:1) - Soft KYC UI +- [`frontend/package.json`](frontend/package.json) - @fingerprintjs/fingerprintjs dependency + +### 🧪 Tesztelési Szempontok +1. ✅ Backend modellek szintaxis helyes (py_compile) +2. ✅ Sync engine 7 séma változást sikeresen alkalmazott +3. ✅ Pydantic schema opcionális mezők (Soft KYC) +4. ✅ Login endpoint device fingerprint fogadása és tárolása +5. ✅ Frontend FingerprintJS betöltés és visitorId küldés +6. ✅ CompleteKycView opcionális címmezők validáció nélkül +7. ✅ Device-Hub architektúra: Device + UserDeviceLink modellek + +### 🔗 Gitea +- **Issue**: #202 - Soft KYC, Trust Profile Extension & Device-Hub Architecture +- **Státusz**: Completed + +## 2026-06-04 (C) - Soft KYC Address Constraints Fix + +### 🎯 Cél +A `system.addresses` tábla `street_name`, `street_type` és `house_number` mezőinek opcionálissá tétele az adatbázis szintjén is a Soft KYC bevezetéséhez. + +### ✅ Implementáció + +#### 1. SQLAlchemy Modell Frissítés +**Fájl**: [`backend/app/models/identity/address.py`](backend/app/models/identity/address.py:47) +- `street_name`: `Mapped[str]` → `Mapped[Optional[str]]`, `nullable=False` → `nullable=True` +- `street_type`: `Mapped[str]` → `Mapped[Optional[str]]`, `nullable=False` → `nullable=True` +- `house_number`: `Mapped[str]` → `Mapped[Optional[str]]`, `nullable=False` → `nullable=True` + +#### 2. Adatbázis ALTER TABLE +- `ALTER TABLE system.addresses ALTER COLUMN street_name DROP NOT NULL;` +- `ALTER TABLE system.addresses ALTER COLUMN street_type DROP NOT NULL;` +- `ALTER TABLE system.addresses ALTER COLUMN house_number DROP NOT NULL;` + +#### 3. Verifikáció +- `information_schema.columns` szerint mindhárom oszlop `is_nullable = YES` + +### 🔗 Gitea +- **Issue**: #203 - Fix: Soft KYC - Address mezők nullable=True +- **Státusz**: Completed + +## 2026-06-04 (D) - Fix KYC Data Binding & Magic Link Device Tracking + +### 🎯 Cél +Két adatfolyam-törés javítása: (1) KYC first_name/last_name nem frissült a Person rekordon, (2) Magic Link verify-email végpont nem támogatta a device_fingerprint-et. + +### ✅ Implementáció + +#### 1. Backend KYC Schema +**Fájl**: [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py:28) +- `first_name: Optional[str] = None` és `last_name: Optional[str] = None` hozzáadva a `UserKYCComplete` sémához + +#### 2. Backend KYC Service +**Fájl**: [`backend/app/services/auth_service.py`](backend/app/services/auth_service.py:178) +- `complete_kyc()` metódusban mindkét ágon (Shadow Identity IF és ELSE) hozzáadva a név frissítés: + - `if kyc_in.first_name: p.first_name = kyc_in.first_name` + - `if kyc_in.last_name: p.last_name = kyc_in.last_name` + +#### 3. Frontend KYC Payload +**Fájl**: [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:459) +- `handleSubmit()` payload-ba bekerült: `first_name: kycForm.first_name` és `last_name: kycForm.last_name` + +#### 4. Backend Magic Link Device Tracking +**Fájl**: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py:137) +- `VerifyEmailRequest` sémához hozzáadva: `device_fingerprint: Optional[str] = None` +- Sikeres `verify_email()` után teljes Device UPSERT blokk (Device + UserDeviceLink), megegyező logikával, mint a `/login` végpontban + +#### 5. Frontend Magic Link Tracking +- **Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:306) - `verifyAccount()` most fogad `deviceFingerprint` paramétert és küldi a kérésben +- **Fájl**: [`frontend/src/views/VerifyEmailView.vue`](frontend/src/views/VerifyEmailView.vue:168) - FingerprintJS integráció (`getDeviceFingerprint()`), a fingerprint hash átadása a `verifyAccount()` hívásnál + +### 🔗 Gitea +- **Issue**: #204 - Fix KYC Data Binding & Magic Link Device Tracking +- **Státusz**: Completed --- -## User Dropdown & Logout Functionality in DashboardHeader +## 2026-06-04 (C) - Nested Profile Response & Editable Profile Card -**Dátum:** 2026-06-03 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** -- `frontend/src/stores/auth.ts` -- `frontend/src/components/DashboardHeader.vue` +### 🎯 Cél +A GET /users/me végpont UserResponse Pydantic sémájának kibővítése beágyazott Person és Address adatokkal, valamint egy PUT /users/me/person végpont létrehozása a profil inline szerkesztéséhez a frontenden. -### Változtatások +### ✅ Implementáció -1. **`frontend/src/stores/auth.ts`:** - - `router` import hozzáadva a `vue-router` helyett közvetlenül a `../router`-ből (a Pinia action nem fér hozzá a `useRouter()`-hoz setup kontextuson kívül) - - `logout()` action kibővítve: a token és user állapot törlése után `router.push('/')`-vel átirányít a főoldalra +#### 1. Pydantic Schemas (backend/app/schemas/user.py) +- **`AddressResponse`**: Beágyazott cím adatok (zip, city, street_name, house_number, stairwell, floor, door, latitude, longitude, stb.) `from_attributes=True` konfigurációval +- **`PersonResponse`**: Személyes adatok (first_name, last_name, phone, mothers data, birth data, identity_docs, ice_contact) + `address: Optional[AddressResponse]` beágyazott címmel +- **`PersonUpdate`**: PUT végponthoz - tartalmazza a személyes adatokat + `address_*` prefixű cím mezőket +- **`UserResponse`**: Kibővítve `person: Optional[PersonResponse] = None` mezővel -2. **`frontend/src/components/DashboardHeader.vue`:** - - Teljes átalakítás: prop-alapú `firstName` helyett a komponens közvetlenül használja az `authStore`-t - - **Dropdown menü:** `isUserMenuOpen` ref + `@click` toggle az avatar gombon - - **Glassmorphism dizájn:** `bg-[#04151F]/90 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl` - - **Transition animáció:** Vue `` komponenssel scale + opacity animáció - - **Menü tartalma:** - - Felhasználói info header (teljes név + email) - - "Profil beállítások" placeholder gomb (bezárja a menüt) - - Elválasztó vonal - - "Kilépés" gomb pirosas hover effekttel (`hover:text-red-400`) - - **`displayName` computed:** Ha `firstName` prop üres vagy 'Vendég', az email `@` előtti részét használja, végső fallback: 'Felhasználó' - - **`initials` computed:** Az authStore user adataiból generálja a monogramot (first_name + last_name kezdőbetűi) - - **`handleLogout()`:** Bezárja a menüt, majd meghívja az `authStore.logout()`-ot +#### 2. Eager Loading (backend/app/api/deps.py) +- `get_current_user()`: `joinedload(User.person).joinedload(Person.address)` láncolt betöltés +- Ezzel a SQL egyetlen 4-táblás JOIN-ban tölti be: `users → persons → addresses → geo_postal_codes` -## Premium Button & Text Styles +#### 3. Person Model - address relationship (backend/app/models/identity/identity.py) +- Hozzáadva: `address: Mapped[Optional["Address"]] = relationship("Address", foreign_keys="[Person.address_id]", lazy="joined")` +- TYPE_CHECKING import: `from .address import Address` -**Dátum:** 2026-06-03 -**Státusz:** Kész ✅ -**Gitea #188** -**Kapcsolódó fájlok:** -- `frontend/src/assets/main.css` -- `frontend/src/views/LandingView.vue` -- `frontend/src/views/DashboardView.vue` -- `frontend/src/components/LoginModal.vue` +#### 4. PUT /users/me/person végpont (backend/app/api/v1/endpoints/users.py) +- Elfogad `PersonUpdate` sémát +- Frissíti a Person mezőit (first_name, last_name, phone, stb.) +- Cím frissítés/létrehozás a `GeoService.get_or_create_full_address()` segítségével +- Visszaadja a frissített `UserResponse`-t beágyazott person+address adatokkal +- `_build_user_response()` helper: egységes UserResponse építés minden végponthoz -### Változtatások -1. **`frontend/src/assets/main.css`** — Új globális komponensek: - - `.btn-premium`: Glassmorphism alap (bg-white/10, backdrop-blur-md), hoverre gradient [#418890](https://www.color-hex.com/color/418890)→[#70BC84](https://www.color-hex.com/color/70bc84) türkiz glow-val, clickre fehér flash + scale-95 - - `.text-gradient-premium`: `bg-clip-text text-transparent` gradient white→#00E5A0→#418890 +#### 5. Wallet FK Fix (backend/app/models/identity/identity.py) +- `Wallet.user_id` FK hozzáadva: `ondelete="CASCADE"` - ezzel a user törlésekor automatikusan törlődik a wallet rekord is -2. **`frontend/src/views/LandingView.vue`**: - - "Garázs Nyitása" gomb → `btn-premium` (leváltva a régi golden glow stílust) - - H1 "Menedzsment" span → `text-gradient-premium` (leváltva a régi `text-[#38bdf8]`-t) +#### 6. Frontend ProfileView.vue - Inline Editing +- **Read mód**: Megjeleníti a `authStore.user.person.*` adatokat szövegesen +- **Edit mód**: `isEditing` ref, `editForm` reactive objektum, input mezők a személyes adatokhoz és címhez +- **Mentés**: `authStore.updatePerson(payload)` hívása, sikeres mentés után visszavált read módba +- **Cím szekció**: Grid layout zip/city, street/type/number, stairwell/floor/door mezőkkel -3. **`frontend/src/views/DashboardView.vue`**: - - "Új Jármű Hozzáadása" gomb (empty state) → `btn-premium` +#### 7. Auth Store (frontend/src/stores/auth.ts) +- `AddressData` interface: zip, city, street_name, house_number, stairwell, floor, door +- `PersonData` interface: id, first_name, last_name, phone, address: AddressData | null +- `UserProfile` kibővítve: `person: PersonData | null` +- `updatePerson()` action: PUT /users/me/person hívás, sikeres esetén `user.person` frissítése -4. **`frontend/src/components/LoginModal.vue`**: - - "Belépés", "Regisztráció", "Link Küldése" gombok → `btn-premium` +#### 8. Identity __init__.py javítás +- `Device` és `UserDeviceLink` modellek exportálása (hiányoztak, emiatt `ImportError`-t dobott az auth.py) -### Technikai részletek -- Minden gomb egységesen a `btn-premium` globális osztályt használja -- A `text-gradient-premium` csak a legnagyobb H1 címsorban lett alkalmazva a landing oldalon -- A régi `btn-golden-glow` és egyedi háttérszín osztályok eltávolításra kerültek az érintett elemekről -- Dokumentáció: `/opt/docker/docs/premium_button_text_styles.md` +#### 9. auth.py javítás +- `from typing import Optional` hozzáadva (hiányzott, `NameError: name 'Optional' is not defined`) + +### 🔗 Gitea +- **Issue**: #205 - Nested Profile Response & Editable Profile Card +- **Státusz**: Completed + +## 2026-06-04 (C) - Profile UI Refactor + Identity Docs + Change Password + +### 🎯 Cél +Profil oldal vizuális felújítása dark card témával, okmányadatok (identity_docs) megjelenítése/szerkesztése, és jelszóváltoztatás implementálása. + +### ✅ Implementáció + +#### 1. Backend: PersonUpdate séma bővítés +**Fájl**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:68) +- `identity_docs: Optional[Any] = None` hozzáadva a `PersonUpdate` osztályhoz +- Új `ChangePasswordRequest` séma: `current_password` + `new_password` mezőkkel + +#### 2. Backend: PUT /me/person identity_docs merge logika +**Fájl**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:219) +- Ha `identity_docs` érkezik és nem null, merge-el a meglévő adatokkal (`dict.update()`) +- Megőrzi a nem küldött mezőket (pl. csak LICENSE küldésekor ID_CARD megmarad) + +#### 3. Backend: PUT /me/password végpont +**Fájl**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:260) +- Jelenlegi jelszó ellenőrzés (`verify_password`) +- Új jelszó ≠ régi jelszó validáció +- Komplexitás validáció (`AuthService._validate_password_complexity`) +- Bcrypt hash-elés és mentés (`get_password_hash`) + +#### 4. Frontend: ProfileView teljes átírás +**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:1) +- Dark card UI: `bg-black/40 backdrop-blur-xl`, `text-white font-medium`, `#00E5A0` accent +- Identity docs szekció: ID_CARD (number, expiry_date) + LICENSE (number, expiry_date, categories) read/edit módban +- Change Password modal: Teleport, 3 mező, frontend validáció, API hívás, auto-close 2s után + +#### 5. Frontend: Auth store bővítés +**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:430) +- `changePassword()` action: PUT /users/me/password hívás + +### 🔗 Gitea +- **Issue**: #206 - Profile UI Refactor + Identity Docs + Change Password +- **Státusz**: Completed + +## 2026-06-04 (C) - Fix SQLAlchemy JSON Mutation for identity_docs + +### 🎯 Cél +Az identity_docs JSON mező nem mentődött el a profil szerkesztésnél, mert az SQLAlchemy nem detektálta az in-place dict mutációt. + +### ✅ Implementáció + +#### 1. Backend: flag_modified + copy.deepcopy +**Fájl**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:222) +- Importált: `copy` és `sqlalchemy.orm.attributes.flag_modified` +- Az identity_docs frissítésnél `copy.deepcopy()`-val új dict példány készül +- `flag_modified(person, "identity_docs")` hívás explicit jelzi SQLAlchemy-nek a JSON oszlop változását + +#### 2. Frontend: Success üzenet frissítés +**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:692) +- Mentés siker üzenet: "A profil adatok és okmányok sikeresen frissítve!" + +### 🔗 Gitea +- **Issue**: #207 - Fix SQLAlchemy JSON mutation for identity_docs +- **Státusz**: Completed --- -## Clean Center UI & Vue-i18n Initialization +## 2026-06-04 (E) - Internal Zip Lookup Endpoint & Frontend Auto-Fill -**Dátum:** 2026-06-04 -**Státusz:** Kész ✅ -**Kapcsolódó fájlok:** -- `frontend/src/i18n/hu.ts` -- `frontend/src/i18n/en.ts` -- `frontend/src/main.ts` -- `frontend/src/views/DashboardView.vue` -- `frontend/src/components/DashboardHeader.vue` +### 🎯 Cél +Külső zippopotam.us API kiváltása belső adatbázis lekérdezéssel. Backend zip-lookup végpont létrehozása a `system.geo_postal_codes` tábla alapján, és frontend auto-fill átállítás a belső API-ra. -### Változtatások +### ✅ Implementáció -1. **Error card removal** ([`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue:160)): - - A `vehicleStore.error` állapotot megjelenítő piros hibakártya (border-red-500/30, "Újrapróbálkozás" gomb) teljesen eltávolításra került a Spatial nézetből - - A Garage Grid mostantól csak `!vehicleStore.isLoading` feltételt vizsgál (a korábbi `&& !vehicleStore.error` eltávolítva) - - Hálózati hibák a jövőben diszkrét toast értesítéssel lesznek kezelve +#### 1. Backend: Zip-Lookup Endpoint +**Fájl**: [`backend/app/api/v1/endpoints/system_parameters.py`](backend/app/api/v1/endpoints/system_parameters.py:47) +- Új `GET /zip-lookup` végpont a system parameters routerben +- Paraméterek: `country_code` (default: "HU") és `zip_code` (kötelező) +- Lekérdezi a `system.geo_postal_codes` táblát `country_code` + `zip_code` alapján +- Visszaadja: `{"city": "Budapest"}` (200) vagy 404 ha nem található +- **Kritikus**: A `/zip-lookup` route a `/{key}` catch-all route ELŐTT van regisztrálva, hogy ne a catch-all nyelje el -2. **i18n translation files** ([`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts), [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts)): - - Új `frontend/src/i18n/` mappa létrehozva - - `hu.ts`: Magyar nyelvű fordítások (menu.features, menu.garage, menu.calendar, menu.finance, menu.diagnostic, menu.addVehicle, header.welcome, header.profile, header.logout, stb.) - - `en.ts`: Angol nyelvű fordítások ugyanazokkal a kulcsokkal +```python +@router.get("/zip-lookup", response_model=Dict[str, Any]) +async def zip_lookup( + country_code: str = Query("HU", description="Országkód (pl. HU, AT, DE)"), + zip_code: str = Query(..., min_length=1, description="Irányítószám"), + db: AsyncSession = Depends(get_db), +): + if not zip_code.strip(): + raise HTTPException(status_code=400, detail="zip_code is required") + stmt = select(GeoPostalCode).where( + and_( + GeoPostalCode.country_code == country_code, + GeoPostalCode.zip_code == zip_code.strip(), + ) + ).limit(1) + result = await db.execute(stmt) + postal_code = result.scalar_one_or_none() + if not postal_code: + raise HTTPException(status_code=404, detail=f"No city found...") + return {"city": postal_code.city} +``` -3. **main.ts refactor** ([`frontend/src/main.ts`](frontend/src/main.ts)): - - Inline `messages` objektum helyett a külön `hu.ts` és `en.ts` fájlok importálása - - `createI18n` konfiguráció: `locale: 'hu'`, `fallbackLocale: 'en'`, `legacy: false` +Teljes URL: `GET /api/v1/system/parameters/zip-lookup?country_code=HU&zip_code=1111` -4. **DashboardHeader i18n** ([`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue)): - - `useI18n` hook importálva, `const { t } = useI18n()` deklaráció - - Minden hardcoded magyar szöveg lecserélve `t('menu.features')`, `t('menu.garage')`, `t('menu.calendar')`, `t('menu.finance')`, `t('menu.diagnostic')`, `t('menu.addVehicle')`, `t('header.welcome')`, `t('header.profile')`, `t('header.logout')`, `t('header.userMenu')` kulcsokra - - `aria-label` attribútumok is i18n kulcsokkal dinamizálva +#### 2. Frontend: CompleteKycView.vue - Internal API +**Fájl**: [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:1) +- Külső `https://api.zippopotam.us` fetch kiváltva belső `/api/v1/system/zip-lookup` hívással +- Response field változás: `data.places[0]['place name']` → `data.city` -### Build -- `vite build` sikeresen lefutott a `sf_public_frontend` konténerben (103 module transformed, 0 hiba) -- Git commit + push Gitea-ba: `22a5da9` +#### 3. Frontend: ProfileView.vue - Debounced Zip Lookup +**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:1) +- `watch()` a `editForm.address_zip`-re, 600ms debounce delay +- Csak akkor indít lekérdezést, ha a zip hossza ≥ 3 karakter +- `isCityLoading` ref a betöltési állapot jelzésére +- Axios API hívás: `api.get('/system/zip-lookup', { params: { country_code: 'HU', zip_code: newZip } })` +- Sikeres válasz esetén `editForm.address_city` auto-fill + +#### 4. identity_docs Payload Verifikáció +- **Frontend ProfileView.vue**: `buildIdentityDocsPayload()` (636-663. sor) helyesen építi az ID_CARD + LICENSE struktúrát +- **savePerson()** (665-701. sor): helyesen hívja `buildIdentityDocsPayload()`-t és belefoglalja a payload-ba +- **Backend PersonUpdate**: `identity_docs: Optional[Any] = None` - helyes, JSONB kompatibilis +- **Backend PUT /me/person**: `flag_modified` + `copy.deepcopy` már implementálva (#207) +- **Read mód display**: `identityDocs?.ID_CARD?.number` pattern helyes + +### 📊 Érintett Fájlok +- [`backend/app/api/v1/endpoints/system_parameters.py`](backend/app/api/v1/endpoints/system_parameters.py:47) - Új zip-lookup végpont +- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:1) - zippopotam.us → internal API +- [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:1) - Debounced zip lookup watcher + +### 🧪 Tesztelési Eredmény +1. ✅ Backend zip-lookup endpoint működik: `1111` → `{"city": "Budapest"}` (200) +2. ✅ Ismeretlen irányítószám: 404 hibakód megfelelő üzenettel +3. ✅ Frontend CompleteKycView belső API-t használ +4. ✅ Frontend ProfileView debounced zip lookup implementálva +5. ✅ identity_docs payload már helyesen működik (nem kellett javítás) + +### 🔗 Gitea +- **Issue**: #208 - Internal Zip Lookup & Identity Docs Payload Fix +- **Státusz**: Completed diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6626dc3..99304f7 100755 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -68,7 +68,16 @@ async def get_current_user( # JAVÍTVA: Modern SQLAlchemy 2.0 aszinkron lekérdezés with eager loading # We need to load the person relationship to avoid lazy loading issues - stmt = select(User).where(User.id == int(user_id)).options(joinedload(User.person)) + # Also load Person.address for nested profile response + from app.models.identity import Person + from app.models.identity.address import Address + stmt = ( + select(User) + .where(User.id == int(user_id)) + .options( + joinedload(User.person).joinedload(Person.address) + ) + ) result = await db.execute(stmt) user = result.unique().scalar_one_or_none() diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 1704a07..7c036cd 100755 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -1,4 +1,5 @@ # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/auth.py +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession @@ -10,9 +11,10 @@ from app.core.config import settings from app.services.config_service import config from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete from app.api.deps import get_current_user -from app.models.identity import User # JAVÍTVA: Új központi modell +from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-Hub modellek from app.core.translation_helper import t # Translation helper from pydantic import BaseModel, Field, EmailStr +from datetime import datetime, timezone router = APIRouter() @@ -34,18 +36,71 @@ async def login( response: Response, db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends(), - remember_me: bool = Form(False) + remember_me: bool = Form(False), + device_fingerprint: Optional[str] = Form(None) ): """ - Bejelentkezés remember_me opcióval. + Bejelentkezés remember_me opcióval és Device Fingerprinting támogatással. A remember_me paramétert explicit Form mezőként kell elküldeni, mivel az OAuth2PasswordRequestForm nem támogatja alapból. + A device_fingerprint opcionális, a frontend FingerprintJS által generált hash. """ user = await AuthService.authenticate(db, form_data.username, form_data.password) if not user: raise HTTPException(status_code=401, detail=t("AUTH.INVALID_CREDENTIALS")) + # DEBUG: Inaktív fiók explicit ellenőrzése + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="AUTH.USER_INACTIVE" + ) + + # ── Device Fingerprint Tracking ── + if device_fingerprint: + # 1. Ellenőrizzük, létezik-e már a Device a hash alapján + existing_device = await db.execute( + select(Device).where(Device.fingerprint_hash == device_fingerprint) + ) + device = existing_device.scalar_one_or_none() + + if not device: + # Új eszköz létrehozása + device = Device( + fingerprint_hash=device_fingerprint, + risk_score=100, + is_banned=False + ) + db.add(device) + + # 2. Ellenőrizzük a UserDeviceLink-et + existing_link = await db.execute( + select(UserDeviceLink).where( + UserDeviceLink.user_id == user.id, + UserDeviceLink.device_hash == device_fingerprint + ) + ) + link = existing_link.scalar_one_or_none() + + now = datetime.now(timezone.utc) + if link: + # Létező kapcsolat frissítése + link.last_seen_at = now + link.login_count = link.login_count + 1 + else: + # Új kapcsolat létrehozása + link = UserDeviceLink( + user_id=user.id, + device_hash=device_fingerprint, + first_seen_at=now, + last_seen_at=now, + login_count=1 + ) + db.add(link) + + await db.commit() + ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP) role_name = user.role.value if hasattr(user.role, 'value') else str(user.role) role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár @@ -82,16 +137,112 @@ async def login( class VerifyEmailRequest(BaseModel): token: str = Field(..., description="Email verification token (UUID)") + device_fingerprint: Optional[str] = Field(None, description="Device fingerprint hash from FingerprintJS") -@router.post("/verify-email") -async def verify_email(request: VerifyEmailRequest, db: AsyncSession = Depends(get_db)): +@router.post("/verify-email", response_model=Token) +async def verify_email( + request: VerifyEmailRequest, + response: Response, + db: AsyncSession = Depends(get_db) +): """ - Email megerősítés token alapján. + Email megerősítés token alapján + automatikus bejelentkezés (Magic Link). + Sikeres verifikáció után azonnal JWT tokent ad vissza, így a felhasználó + bejelentkezik anélkül, hogy külön be kellene írnia a jelszavát. """ - success = await AuthService.verify_email(db, request.token) - if not success: + user = await AuthService.verify_email(db, request.token) + if not user: raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN")) - return {"status": "success", "message": t("AUTH.EMAIL_VERIFICATION_SUCCESS")} + + # ── Device Fingerprint Tracking (Magic Link) ── + if request.device_fingerprint: + # 1. Ellenőrizzük, létezik-e már a Device a hash alapján + existing_device = await db.execute( + select(Device).where(Device.fingerprint_hash == request.device_fingerprint) + ) + device = existing_device.scalar_one_or_none() + + if not device: + # Új eszköz létrehozása + device = Device( + fingerprint_hash=request.device_fingerprint, + risk_score=100, + is_banned=False + ) + db.add(device) + + # 2. Ellenőrizzük a UserDeviceLink-et + existing_link = await db.execute( + select(UserDeviceLink).where( + UserDeviceLink.user_id == user.id, + UserDeviceLink.device_hash == request.device_fingerprint + ) + ) + link = existing_link.scalar_one_or_none() + + now = datetime.now(timezone.utc) + if link: + # Létező kapcsolat frissítése + link.last_seen_at = now + link.login_count = link.login_count + 1 + else: + # Új kapcsolat létrehozása + link = UserDeviceLink( + user_id=user.id, + device_hash=request.device_fingerprint, + first_seen_at=now, + last_seen_at=now, + login_count=1 + ) + db.add(link) + + await db.commit() + + # Generálj JWT tokent a most aktivált felhasználóhoz (ugyanaz a logika, mint login-nál) + ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP) + role_name = user.role.value if hasattr(user.role, 'value') else str(user.role) + role_key = role_name.upper() + + token_data = { + "sub": str(user.id), + "role": role_name, + "rank": ranks.get(role_key, 10), + "scope_level": user.scope_level or "individual", + "scope_id": str(user.scope_id) if user.scope_id else str(user.id) + } + + # Magic Link esetén ne használjunk remember_me-t (alapértelmezett 1 napos token) + access, refresh = create_tokens(data=token_data, remember_me=False) + + # Refresh token lejárat + max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1) + max_age_sec = int(max_age_days) * 24 * 60 * 60 + + response.set_cookie( + key="refresh_token", + value=refresh, + httponly=True, + secure=True, + samesite="lax", + max_age=max_age_sec + ) + + return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active} + +class ResendVerificationRequest(BaseModel): + email: EmailStr = Field(..., description="Email cím az aktiváló levél újraküldéséhez") + +@router.post("/resend-verification") +async def resend_verification(request: ResendVerificationRequest, db: AsyncSession = Depends(get_db)): + """ + Aktiváló e-mail újraküldése. + Mindig sikeres választ ad, hogy megelőzzük az email enumerációt. + """ + result = await AuthService.resend_verification(db, request.email) + return { + "status": "success", + "message": "Ha ez az email cím regisztrálva van és még nem aktív, akkor elküldtük az aktiváló e-mailt." + } class ForgotPasswordRequest(BaseModel): email: EmailStr = Field(..., description="Email cím a jelszó visszaállításhoz") @@ -100,10 +251,18 @@ class ForgotPasswordRequest(BaseModel): async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)): """ Elfelejtett jelszó folyamat indítása. - Mindig sikeres választ ad, hogy megelőzzük az email enumerációt. + DEBUG MODE: Explicit hibákat dob az email küldési problémák diagnosztizálásához. """ result = await AuthService.initiate_password_reset(db, request.email) - # Mindig ugyanazt a választ adjuk, függetlenül attól, hogy létezik-e a felhasználó + + # DEBUG: Ha nem található a user, dobjunk explicit hibát + if result == "not_found": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="A felhasználó nem található ezzel az email címmel." + ) + + # Ha success, adjuk vissza a sikeres választ return { "status": "success", "message": "Ha ez az email cím regisztrálva van, akkor elküldtünk egy jelszó-visszaállítási linket." diff --git a/backend/app/api/v1/endpoints/system_parameters.py b/backend/app/api/v1/endpoints/system_parameters.py index b314cf8..c71f27e 100644 --- a/backend/app/api/v1/endpoints/system_parameters.py +++ b/backend/app/api/v1/endpoints/system_parameters.py @@ -1,8 +1,9 @@ # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/system_parameters.py +import logging from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update -from typing import List, Optional +from sqlalchemy import select, update, and_ +from typing import List, Optional, Dict, Any from app.api.deps import get_db, get_current_user from app.schemas.system import ( @@ -11,9 +12,11 @@ from app.schemas.system import ( SystemParameterCreate, ) from app.models.system import SystemParameter, ParameterScope +from app.models.identity.address import GeoPostalCode from app.models.identity import UserRole router = APIRouter() +logger = logging.getLogger(__name__) @router.get("/", response_model=List[SystemParameterResponse]) @@ -41,6 +44,40 @@ async def list_system_parameters( return parameters +@router.get("/zip-lookup", response_model=Dict[str, Any]) +async def zip_lookup( + country_code: str = Query("HU", description="Országkód (pl. HU, AT, DE)"), + zip_code: str = Query(..., min_length=1, description="Irányítószám"), + db: AsyncSession = Depends(get_db), +): + """ + Belső irányítószám lekérdező végpont. + A system.geo_postal_codes táblában keres a megadott country_code és zip_code párosra. + Ha megtalálja, visszaadja a várost: {"city": "Budapest"}. + Ha nem találja, 404-es hibát dob. + """ + if not zip_code.strip(): + raise HTTPException(status_code=400, detail="zip_code is required") + + stmt = select(GeoPostalCode).where( + and_( + GeoPostalCode.country_code == country_code, + GeoPostalCode.zip_code == zip_code.strip(), + ) + ).limit(1) + + result = await db.execute(stmt) + postal_code = result.scalar_one_or_none() + + if not postal_code: + raise HTTPException( + status_code=404, + detail=f"No city found for country_code='{country_code}' and zip_code='{zip_code}'" + ) + + return {"city": postal_code.city} + + @router.get("/{key}", response_model=SystemParameterResponse) async def get_system_parameter( key: str, @@ -128,5 +165,4 @@ async def update_system_parameter( await db.execute(stmt) await db.commit() await db.refresh(parameter) - - return parameter \ No newline at end of file + \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 3034f19..43072ae 100755 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -1,16 +1,23 @@ #/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py +import copy + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError from sqlalchemy import select, or_ +from sqlalchemy.orm import joinedload +from sqlalchemy.orm.attributes import flag_modified from typing import Dict, Any, List, Optional from datetime import datetime from app.api.deps import get_db, get_current_user -from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse -from app.models.identity import User +from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest +from app.models.identity import User, Person +from app.models.identity.address import Address from app.services.trust_engine import TrustEngine -from app.core.security import create_tokens, DEFAULT_RANK_MAP +from app.services.geo_service import GeoService +from app.services.auth_service import AuthService +from app.core.security import create_tokens, DEFAULT_RANK_MAP, verify_password, get_password_hash from app.core.config import settings from pydantic import BaseModel @@ -33,26 +40,105 @@ class NetworkResponse(BaseModel): level2: List[NetworkMemberL2L3] level3: List[NetworkMemberL2L3] + +def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict: + """ + Segédfüggvény a UserResponse dict előállításához. + Beágyazza a Person és Address adatokat a 'person' mezőbe. + """ + from app.models.marketplace.organization import Organization, OrganizationMember + from app.models.marketplace.organization import OrgUserRole + + # Determine active organization ID + if active_org_id is None: + if user.scope_id is not None: + try: + active_org_id = int(user.scope_id) + except (ValueError, TypeError): + active_org_id = None + + if active_org_id is None: + # 1. Check if user is a member of any organization with ADMIN/OWNER role + # (This is a helper - in real usage the caller should pass active_org_id) + pass + + person = user.person + person_data = None + if person: + address_data = None + if person.address: + address_data = { + "zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None), + "city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None), + "street_name": person.address.street_name, + "street_type": person.address.street_type, + "house_number": person.address.house_number, + "stairwell": person.address.stairwell, + "floor": person.address.floor, + "door": person.address.door, + "parcel_id": person.address.parcel_id, + "full_address_text": person.address.full_address_text, + "latitude": person.address.latitude, + "longitude": person.address.longitude, + } + person_data = { + "id": person.id, + "id_uuid": str(person.id_uuid) if person.id_uuid else None, + "first_name": person.first_name, + "last_name": person.last_name, + "phone": person.phone, + "mothers_last_name": person.mothers_last_name, + "mothers_first_name": person.mothers_first_name, + "birth_place": person.birth_place, + "birth_date": person.birth_date, + "identity_docs": person.identity_docs, + "ice_contact": person.ice_contact, + "is_active": person.is_active, + "address": address_data, + } + + # Get first_name/last_name from person for backward compatibility + first_name = person.first_name if person else "" + last_name = person.last_name if person else "" + + return { + "id": user.id, + "email": user.email, + "first_name": first_name, + "last_name": last_name, + "is_active": user.is_active, + "region_code": user.region_code, + "person_id": user.person_id, + "role": user.role.value if hasattr(user.role, 'value') else str(user.role), + "subscription_plan": user.subscription_plan, + "scope_level": user.scope_level or "individual", + "scope_id": str(active_org_id) if active_org_id else None, + "ui_mode": user.ui_mode or "personal", + "active_organization_id": active_org_id, + "preferred_language": user.preferred_language or "hu", + "person": person_data, + } + + @router.get("/me", response_model=UserResponse) async def read_users_me( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Visszaadja a bejelentkezett felhasználó profilját""" - from sqlalchemy import select, or_ + """Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.""" from app.models.marketplace.organization import Organization, OrganizationMember from app.models.marketplace.organization import OrgUserRole - + # Determine active organization ID active_org_id = None - + # If user already has a scope_id, use it if current_user.scope_id is not None: try: active_org_id = int(current_user.scope_id) except (ValueError, TypeError): active_org_id = None - + # If still no active org ID, try to find user's primary organization if active_org_id is None: # 1. Check if user is a member of any organization with ADMIN/OWNER role @@ -63,10 +149,10 @@ async def read_users_me( OrganizationMember.role == OrgUserRole.OWNER ) ).limit(1) - + result = await db.execute(stmt) org_member_row = result.first() - + if org_member_row: active_org_id = org_member_row[0] else: @@ -76,7 +162,7 @@ async def read_users_me( ).limit(1) result = await db.execute(stmt) org_owner_row = result.first() - + if org_owner_row: active_org_id = org_owner_row[0] else: @@ -86,37 +172,138 @@ async def read_users_me( ).limit(1) result = await db.execute(stmt) org_row = result.first() - + active_org_id = org_row[0] if org_row else None - - # Create a response dictionary with the active_organization_id - # Get first_name and last_name from person relation if available - person = current_user.person - # Safe extraction with fallback to empty string - first_name = "" - last_name = "" - if person: - first_name = getattr(person, 'first_name', '') - last_name = getattr(person, 'last_name', '') - - response_data = { - "id": current_user.id, - "email": current_user.email, - "first_name": first_name, - "last_name": last_name, - "is_active": current_user.is_active, - "region_code": current_user.region_code, - "person_id": current_user.person_id, - "role": current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role), - "subscription_plan": current_user.subscription_plan, - "scope_level": current_user.scope_level or "individual", - "scope_id": str(active_org_id) if active_org_id else None, - "ui_mode": current_user.ui_mode or "personal", - "active_organization_id": active_org_id - } - + + response_data = _build_user_response(current_user, active_org_id) return UserResponse.model_validate(response_data) + +@router.put("/me/person", response_model=UserResponse) +async def update_my_person( + update_data: PersonUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Frissíti a bejelentkezett felhasználó Person (és Address) rekordját. + Elfogadja a PersonUpdate sémát, amely tartalmazza a személyes, okmány és cím adatokat. + Visszaadja a frissített UserResponse objektumot. + """ + # Reload user with eager loaded person + address + stmt = ( + select(User) + .where(User.id == current_user.id) + .options(joinedload(User.person).joinedload(Person.address)) + ) + result = await db.execute(stmt) + user = result.unique().scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + person = user.person + if not person: + raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.") + + # ── Update Person fields ── + update_dict = update_data.dict(exclude_unset=True) + + person_fields = ["first_name", "last_name", "phone", "mothers_last_name", + "mothers_first_name", "birth_place", "birth_date"] + address_fields = ["address_zip", "address_city", "address_street_name", + "address_street_type", "address_house_number", + "address_stairwell", "address_floor", "address_door", + "address_hrsz"] + + for field in person_fields: + if field in update_dict and update_dict[field] is not None: + setattr(person, field, update_dict[field]) + + # ── Update identity_docs (if provided and not None) ── + if "identity_docs" in update_dict and update_dict["identity_docs"] is not None: + # Use deep copy + flag_modified to ensure SQLAlchemy detects the JSON mutation + existing_docs = copy.deepcopy(person.identity_docs) if person.identity_docs else {} + if isinstance(existing_docs, dict) and isinstance(update_dict["identity_docs"], dict): + existing_docs.update(update_dict["identity_docs"]) + person.identity_docs = existing_docs + flag_modified(person, "identity_docs") + else: + person.identity_docs = update_dict["identity_docs"] + flag_modified(person, "identity_docs") + + # ── Update Address fields ── + has_address_update = any(f in update_dict for f in address_fields) + if has_address_update: + # Use GeoService to get or create address + addr_id = await GeoService.get_or_create_full_address( + db, + zip_code=update_dict.get("address_zip"), + city=update_dict.get("address_city"), + street_name=update_dict.get("address_street_name"), + street_type=update_dict.get("address_street_type"), + house_number=update_dict.get("address_house_number"), + parcel_id=update_dict.get("address_hrsz"), + ) + if addr_id: + person.address_id = addr_id + + try: + await db.commit() + # Refresh to get the latest state + await db.refresh(person) + if person.address: + await db.refresh(person.address) + except SQLAlchemyError as e: + await db.rollback() + raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + + # Build and return the full response + response_data = _build_user_response(user) + return UserResponse.model_validate(response_data) + + +@router.put("/me/password", status_code=200) +async def change_my_password( + change_data: ChangePasswordRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Jelszó módosítás a bejelentkezett felhasználó számára. + + Ellenőrzi a jelenlegi jelszót, validálja az új jelszó komplexitását, + majd elmenti az új hash-t. Válaszként egy {'status': 'ok', 'message': ...} dict-et ad. + """ + # 1. Ellenőrizzük a jelenlegi jelszót + if not verify_password(change_data.current_password, current_user.hashed_password): + raise HTTPException( + status_code=400, + detail="A jelenlegi jelszó helytelen." + ) + + # 2. Ellenőrizzük, hogy az új jelszó nem egyezik-e a régivel + if verify_password(change_data.new_password, current_user.hashed_password): + raise HTTPException( + status_code=400, + detail="Az új jelszó nem egyezhet meg a jelenlegi jelszóval." + ) + + # 3. Jelszó komplexitás ellenőrzése (dinamikus admin beállítások alapján) + await AuthService._validate_password_complexity(db, change_data.new_password, current_user.region_code) + + # 4. Új jelszó hash-elése és mentése + current_user.hashed_password = get_password_hash(change_data.new_password) + + try: + await db.commit() + await db.refresh(current_user) + except SQLAlchemyError as e: + await db.rollback() + raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + + return {"status": "ok", "message": "Jelszó sikeresen megváltoztatva."} + + @router.get("/me/trust") async def get_user_trust( db: AsyncSession = Depends(get_db), @@ -125,10 +312,10 @@ async def get_user_trust( ) -> Dict[str, Any]: """ Visszaadja a felhasználó Gondos Gazda Index (Trust Score) értékét. - + A számítás dinamikusan betölti a paramétereket a SystemParameter rendszerből (Global/Country/Region/User hierarchia). - + Paraméterek: - force_recalculate: Ha True, akkor újraszámolja a trust score-t (alapértelmezetten cache-elt értéket ad vissza, ha kevesebb mint 24 órája számoltuk) @@ -154,28 +341,29 @@ async def update_user_preferences( update_dict = update_data.dict(exclude_unset=True) if not update_dict: raise HTTPException(status_code=400, detail="No fields to update") - + # Validate ui_mode if present if "ui_mode" in update_dict: if update_dict["ui_mode"] not in ["personal", "fleet"]: raise HTTPException(status_code=422, detail="ui_mode must be 'personal' or 'fleet'") - + # Update user fields for field, value in update_dict.items(): if hasattr(current_user, field): setattr(current_user, field, value) else: raise HTTPException(status_code=400, detail=f"Invalid field: {field}") - + try: await db.commit() await db.refresh(current_user) except SQLAlchemyError as e: await db.rollback() raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") - + # Return the Pydantic model instead of raw SQLAlchemy object - return UserResponse.model_validate(current_user) + response_data = _build_user_response(current_user) + return UserResponse.model_validate(response_data) @router.patch("/me/active-organization", response_model=UserWithTokenResponse) @@ -186,18 +374,18 @@ async def update_active_organization( ): """ Update the user's active organization (scope_id). - + Accepts an organization_id (UUID/string) or None to revert to personal mode. Returns a new JWT token with updated scope_id in the payload. """ # Extract organization_id from request org_id = update_data.organization_id - + # Validate that the user has access to this organization if org_id is provided if org_id is not None: from sqlalchemy import select from app.models.marketplace.organization import OrganizationMember - + # Check if user is a member of the organization stmt = select(OrganizationMember).where( OrganizationMember.organization_id == org_id, @@ -205,23 +393,23 @@ async def update_active_organization( ) result = await db.execute(stmt) member = result.scalar_one_or_none() - + if not member: raise HTTPException( status_code=403, detail="You are not a member of this organization" ) - + # Update user's scope_id current_user.scope_id = org_id - + try: await db.commit() await db.refresh(current_user) except SQLAlchemyError as e: await db.rollback() raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") - + # Generate new JWT token with updated scope_id role_key = current_user.role.value.upper() token_payload = { @@ -232,16 +420,18 @@ async def update_active_organization( "scope_id": org_id, "person_id": str(current_user.person_id) if current_user.person_id else None, } - + access_token, _ = create_tokens(data=token_payload) - + # Return user data with new token + response_data = _build_user_response(current_user) return UserWithTokenResponse( - user=UserResponse.model_validate(current_user), + user=UserResponse.model_validate(response_data), access_token=access_token, token_type="bearer" ) + @router.get("/me/network", response_model=NetworkResponse) async def get_my_network( db: AsyncSession = Depends(get_db), @@ -257,7 +447,7 @@ async def get_my_network( l1_stmt = select(User).where(User.referred_by_id == current_user.id) l1_result = await db.execute(l1_stmt) l1_users = l1_result.scalars().all() - + level1 = [ NetworkMemberL1( email=u.email, @@ -267,7 +457,7 @@ async def get_my_network( ) for u in l1_users ] - + # L2: L1 userek által meghívottak level2 = [] if l1_users: @@ -275,7 +465,7 @@ async def get_my_network( l2_stmt = select(User).where(User.referred_by_id.in_(l1_ids)) l2_result = await db.execute(l2_stmt) l2_users = l2_result.scalars().all() - + level2 = [ NetworkMemberL2L3( referral_code=u.referral_code or "", @@ -283,7 +473,7 @@ async def get_my_network( ) for u in l2_users ] - + # L3: L2 userek által meghívottak level3 = [] if l2_users: @@ -291,7 +481,7 @@ async def get_my_network( l3_stmt = select(User).where(User.referred_by_id.in_(l2_ids)) l3_result = await db.execute(l3_stmt) l3_users = l3_result.scalars().all() - + level3 = [ NetworkMemberL2L3( referral_code=u.referral_code or "", @@ -299,7 +489,7 @@ async def get_my_network( ) for u in l3_users ] - + return NetworkResponse( level1=level1, level2=level2, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a8b8ee2..d3423c6 100755 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -75,8 +75,8 @@ class Settings(BaseSettings): # --- Email --- EMAIL_PROVIDER: str = "auto" - EMAILS_FROM_EMAIL: str = "info@profibot.hu" - EMAILS_FROM_NAME: str = "Profibot" + EMAILS_FROM_EMAIL: str = "noreply@servicefinder.hu" + EMAILS_FROM_NAME: str = "ServiceFinder" SENDGRID_API_KEY: Optional[str] = None SMTP_HOST: Optional[str] = None @@ -85,7 +85,7 @@ class Settings(BaseSettings): SMTP_PASSWORD: Optional[str] = None # --- External URLs --- - FRONTEND_BASE_URL: str = "https://dev.servicefinder.hu" + FRONTEND_BASE_URL: str = "https://app.servicefinder.hu" BACKEND_CORS_ORIGINS: List[str] = Field( default=[ # Production domains @@ -130,7 +130,7 @@ class Settings(BaseSettings): # --- Google OAuth --- GOOGLE_CLIENT_ID: str = "" GOOGLE_CLIENT_SECRET: str = "" - GOOGLE_CALLBACK_URL: str = "https://dev.profibot.hu/api/v1/auth/callback/google" + GOOGLE_CALLBACK_URL: str = "https://dev.servicefinder.hu/api/v1/auth/callback/google" # --- Brute-Force & Security --- LOGIN_RATE_LIMIT_ANON: str = "5/minute" diff --git a/backend/app/models/identity/__init__.py b/backend/app/models/identity/__init__.py index 3e3d2b8..f981608 100644 --- a/backend/app/models/identity/__init__.py +++ b/backend/app/models/identity/__init__.py @@ -8,6 +8,8 @@ from .identity import ( ActiveVoucher, UserTrustProfile, UserRole, + Device, + UserDeviceLink, ) from .address import ( @@ -38,6 +40,8 @@ __all__ = [ "ActiveVoucher", "UserTrustProfile", "UserRole", + "Device", + "UserDeviceLink", "Address", "GeoPostalCode", "GeoStreet", diff --git a/backend/app/models/identity/address.py b/backend/app/models/identity/address.py index e63f962..57fea04 100755 --- a/backend/app/models/identity/address.py +++ b/backend/app/models/identity/address.py @@ -44,9 +44,9 @@ class Address(Base): id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("system.geo_postal_codes.id")) - street_name: Mapped[str] = mapped_column(String(200), nullable=False) - street_type: Mapped[str] = mapped_column(String(50), nullable=False) - house_number: Mapped[str] = mapped_column(String(50), nullable=False) + street_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + house_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) stairwell: Mapped[Optional[str]] = mapped_column(String(20)) floor: Mapped[Optional[str]] = mapped_column(String(20)) @@ -60,6 +60,9 @@ class Address(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + # Kapcsolat az irányítószám táblához (zip/city lekéréshez) + postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined") + class Rating(Base): diff --git a/backend/app/models/identity/identity.py b/backend/app/models/identity/identity.py index f14c597..b69bbcc 100644 --- a/backend/app/models/identity/identity.py +++ b/backend/app/models/identity/identity.py @@ -2,17 +2,18 @@ from __future__ import annotations import uuid import enum -from datetime import datetime +from datetime import datetime, timezone from typing import Any, List, Optional, TYPE_CHECKING from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM +from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM, JSONB from sqlalchemy.sql import func # MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t from app.database import Base if TYPE_CHECKING: + from .address import Address from .organization import Organization, OrganizationMember from .asset import VehicleOwnership from .gamification import UserStats @@ -69,6 +70,14 @@ class Person(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_ghost: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # Soft KYC: Duplikált személyek összefésülése (merge) + merged_into_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("identity.persons.id", ondelete="SET NULL"), + nullable=True, + index=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False) updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now()) @@ -94,6 +103,13 @@ class Person(Base): nullable=True ) + # Kapcsolat a lakcímhez (system.addresses tábla) + address: Mapped[Optional["Address"]] = relationship( + "Address", + foreign_keys="[Person.address_id]", + lazy="joined" + ) + memberships: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="person") # Kapcsolat a tulajdonolt szervezetekhez (Organization táblában legal_owner_id) @@ -113,7 +129,7 @@ class User(Base): hashed_password: Mapped[Optional[str]] = mapped_column(String) role: Mapped[UserRole] = mapped_column( - PG_ENUM(UserRole, name="userrole", schema="identity"), + PG_ENUM(UserRole, name="userrole", schema="identity"), default=UserRole.user ) @@ -142,6 +158,10 @@ class User(Base): scope_id: Mapped[Optional[str]] = mapped_column(String(50)) custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb")) + # E-mail történetiség és alternatív e-mailek (MB 2.0.1) + alternative_emails: Mapped[Any] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'::jsonb")) + email_history: Mapped[Any] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'::jsonb")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) # --- KAPCSOLATOK --- @@ -192,7 +212,7 @@ class Wallet(Base): __table_args__ = {"schema": "identity"} id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) - user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), unique=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True) earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0")) purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0")) @@ -263,10 +283,66 @@ class UserTrustProfile(Base): maintenance_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False) quality_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False) preventive_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False) + + # Soft KYC / Identity Trust bővítés + identity_score: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + verification_level: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + verified_channels: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")) + identity_risk_flag: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + last_calculated: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) - user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False) \ No newline at end of file + user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False) + + +class Device(Base): + """ Ismert eszközök (Device Fingerprinting) a Device-Hub architektúrához. """ + __tablename__ = "devices" + __table_args__ = {"schema": "identity"} + + fingerprint_hash: Mapped[str] = mapped_column(String(255), primary_key=True, index=True) + risk_score: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + is_banned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + + +class UserDeviceLink(Base): + """ Kapcsolótábla a felhasználók és az eszközök között. """ + __tablename__ = "user_device_links" + __table_args__ = {"schema": "identity"} + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("identity.users.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + device_hash: Mapped[str] = mapped_column( + String(255), + ForeignKey("identity.devices.fingerprint_hash", ondelete="CASCADE"), + nullable=False, + index=True + ) + first_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + last_seen_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True + ) + login_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) \ No newline at end of file diff --git a/backend/app/models/system/system.py b/backend/app/models/system/system.py index fcef74c..e33599f 100755 --- a/backend/app/models/system/system.py +++ b/backend/app/models/system/system.py @@ -29,7 +29,7 @@ class SystemParameter(Base): category: Mapped[str] = mapped_column(String, server_default="general", index=True) value: Mapped[dict] = mapped_column(JSONB, nullable=False) - scope_level: Mapped[ParameterScope] = mapped_column(SQLEnum(ParameterScope, name="parameter_scope", schema="system"), server_default=ParameterScope.GLOBAL.value, index=True) + scope_level: Mapped[str] = mapped_column(String(50), server_default=ParameterScope.GLOBAL.value, index=True) scope_id: Mapped[Optional[str]] = mapped_column(String(50)) is_active: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index c41e8bb..6d3da1c 100755 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -4,8 +4,8 @@ from typing import Optional, Dict, List from datetime import date, datetime class DocumentDetail(BaseModel): - number: str - expiry_date: date + number: Optional[str] = None + expiry_date: Optional[date] = None class ICEContact(BaseModel): name: str @@ -26,7 +26,9 @@ class UserLiteRegister(BaseModel): model_config = ConfigDict(from_attributes=True) class UserKYCComplete(BaseModel): - """ Step 2: Teljes körű személyazonosítás és címadatok. """ + """ Step 2: Teljes körű személyazonosítás és címadatok (Soft KYC). """ + first_name: Optional[str] = None + last_name: Optional[str] = None phone_number: Optional[str] = Field(default=None, pattern=r"^\+?[0-9]{7,15}$") birth_place: Optional[str] = None birth_date: Optional[date] = None @@ -34,19 +36,19 @@ class UserKYCComplete(BaseModel): mothers_first_name: Optional[str] = None region_code: Optional[str] = "HU" - # Atomizált címadatok a pontos GPS-hez és Robot-munkához + # Soft KYC: Csak az irányítószám és város kötelező, a többi opcionális address_zip: str address_city: str - address_street_name: str - address_street_type: str # utca, út, tér... - address_house_number: str + address_street_name: Optional[str] = None + address_street_type: Optional[str] = None # utca, út, tér... + address_house_number: Optional[str] = None address_stairwell: Optional[str] = None address_floor: Optional[str] = None address_door: Optional[str] = None address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám - # Okmányok és Vészhelyzet - identity_docs: Dict[str, DocumentDetail] # pl: {"ID_CARD": {...}, "LICENSE": {...}} + # Okmányok és Vészhelyzet (Opcionális Soft KYC) + identity_docs: Optional[Dict[str, DocumentDetail]] = None # pl: {"ID_CARD": {...}, "LICENSE": {...}} ice_contact: Optional[ICEContact] = None preferred_language: str = "hu" diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f4886db..122d8b6 100755 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,7 +1,46 @@ # /opt/docker/dev/service_finder/backend/app/schemas/user.py +import uuid from pydantic import BaseModel, EmailStr, field_validator, ConfigDict -from typing import Optional -from datetime import date +from typing import Optional, Any +from datetime import date, datetime + + +class AddressResponse(BaseModel): + """Beágyazott cím adatok a PersonResponse számára.""" + zip: Optional[str] = None + city: Optional[str] = None + street_name: Optional[str] = None + street_type: Optional[str] = None + house_number: Optional[str] = None + stairwell: Optional[str] = None + floor: Optional[str] = None + door: Optional[str] = None + parcel_id: Optional[str] = None + full_address_text: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + + model_config = ConfigDict(from_attributes=True) + + +class PersonResponse(BaseModel): + """Beágyazott személyes adatok a UserResponse számára.""" + id: int + id_uuid: uuid.UUID + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + mothers_last_name: Optional[str] = None + mothers_first_name: Optional[str] = None + birth_place: Optional[str] = None + birth_date: Optional[datetime] = None + identity_docs: Optional[Any] = None + ice_contact: Optional[Any] = None + is_active: bool = True + address: Optional[AddressResponse] = None + + model_config = ConfigDict(from_attributes=True) + class UserBase(BaseModel): email: EmailStr @@ -10,6 +49,7 @@ class UserBase(BaseModel): is_active: bool = True region_code: str = "HU" + class UserResponse(UserBase): id: int person_id: Optional[int] = None @@ -19,19 +59,56 @@ class UserResponse(UserBase): scope_id: Optional[str] = None ui_mode: str = "personal" active_organization_id: Optional[int] = None + preferred_language: str = "hu" + # Beágyazott személyes adatok (Person + Address) + person: Optional[PersonResponse] = None model_config = ConfigDict(from_attributes=True) + +class PersonUpdate(BaseModel): + """PUT /users/me/person séma - a személyes adatok szerkesztéséhez.""" + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + mothers_last_name: Optional[str] = None + mothers_first_name: Optional[str] = None + birth_place: Optional[str] = None + birth_date: Optional[date] = None + + # Okmány adatok (identity_docs) + identity_docs: Optional[Any] = None + + # Cím adatok + address_zip: Optional[str] = None + address_city: Optional[str] = None + address_street_name: Optional[str] = None + address_street_type: Optional[str] = None + address_house_number: Optional[str] = None + address_stairwell: Optional[str] = None + address_floor: Optional[str] = None + address_door: Optional[str] = None + address_hrsz: Optional[str] = None + + +class ChangePasswordRequest(BaseModel): + """PUT /users/me/password séma - jelszó módosításhoz.""" + current_password: str + new_password: str + + class UserUpdate(BaseModel): first_name: Optional[str] = None last_name: Optional[str] = None preferred_language: Optional[str] = None ui_mode: Optional[str] = None + class ActiveOrganizationUpdate(BaseModel): organization_id: Optional[str] = None # UUID/string or None to revert to personal mode + class UserWithTokenResponse(BaseModel): """User response with new JWT token for organization switching""" user: UserResponse access_token: str - token_type: str = "bearer" \ No newline at end of file + token_type: str = "bearer" diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 2a15fe4..721f42e 100755 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -93,7 +93,7 @@ class AuthService: new_person = Person( first_name=user_in.first_name, last_name=user_in.last_name, - is_active=False, + is_active=False, # Email verification required before activation identity_docs={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE ice_contact={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE lifetime_xp=0, # default -1, de explicit 0 @@ -117,7 +117,7 @@ class AuthService: hashed_password=get_password_hash(user_in.password), person_id=new_person.id, role=assigned_role, - is_active=False, + is_active=False, # Email verification required before activation is_deleted=False, region_code=user_in.region_code, preferred_language=user_in.lang, @@ -217,6 +217,8 @@ class AuthService: user.person_id = shadow_person.id # Frissítjük a megtalált Person rekordot a KYC adatokkal + if kyc_in.first_name: shadow_person.first_name = kyc_in.first_name + if kyc_in.last_name: shadow_person.last_name = kyc_in.last_name shadow_person.mothers_last_name = kyc_in.mothers_last_name shadow_person.mothers_first_name = kyc_in.mothers_first_name shadow_person.birth_place = kyc_in.birth_place @@ -231,6 +233,8 @@ class AuthService: p = shadow_person else: + if kyc_in.first_name: p.first_name = kyc_in.first_name + if kyc_in.last_name: p.last_name = kyc_in.last_name p.mothers_last_name = kyc_in.mothers_last_name p.mothers_first_name = kyc_in.mothers_first_name p.birth_place = kyc_in.birth_place @@ -314,7 +318,10 @@ class AuthService: @staticmethod async def verify_email(db: AsyncSession, token_str: str): - """ Email megerősítés. """ + """ + Email megerősítés. + Visszaadja az aktivált User objektumot sikeres verifikáció után (Magic Link támogatás). + """ try: token_uuid = uuid.UUID(token_str) stmt = select(VerificationToken).where(and_( @@ -323,25 +330,28 @@ class AuthService: VerificationToken.expires_at > datetime.now(timezone.utc) )) token = (await db.execute(stmt)).scalar_one_or_none() - if not token: return False + if not token: return None token.is_used = True # Activate user user_stmt = select(User).where(User.id == token.user_id) user = (await db.execute(user_stmt)).scalar_one_or_none() - if user: - user.is_active = True - - # Activate person - person_stmt = select(Person).where(Person.user_id == user.id) - person = (await db.execute(person_stmt)).scalar_one_or_none() - if person: - person.is_active = True + if not user: return None + + user.is_active = True + + # Activate person + person_stmt = select(Person).where(Person.user_id == user.id) + person = (await db.execute(person_stmt)).scalar_one_or_none() + if person: + person.is_active = True await db.commit() - return True - except: return False + return user # Return the activated User object + except Exception as e: + logger.error(f"Email verification error: {e}") + return None @staticmethod async def initiate_password_reset(db: AsyncSession, email: str): @@ -349,23 +359,43 @@ class AuthService: stmt = select(User).where(and_(User.email == email, User.is_deleted == False)) user = (await db.execute(stmt)).scalar_one_or_none() - if user: - # Dinamikus lejárat az adminból - reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2) - token_val = uuid.uuid4() - db.add(VerificationToken( - token=token_val, user_id=user.id, token_type="password_reset", - expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h)) - )) - - link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}" - await email_manager.send_email( - recipient=email, template_key="pwd_reset", - variables={"link": link}, lang=user.preferred_language + if not user: + return "not_found" + + # Ha a felhasználó létezik, de inaktív, dobjunk hibát + if not user.is_active: + logger.warning(f"Password reset requested for inactive user: {email}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="AUTH.USER_INACTIVE" ) - await db.commit() - return "success" - return "not_found" + + # Dinamikus lejárat az adminból + reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2) + token_val = uuid.uuid4() + db.add(VerificationToken( + token=token_val, user_id=user.id, token_type="password_reset", + expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h)) + )) + + link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}" + logger.info(f"Email küldés indítása (password reset) ide: {email}...") + result = await email_manager.send_email( + recipient=email, template_key="pwd_reset", + variables={"link": link}, lang=user.preferred_language + ) + logger.info(f"Email küldés eredménye (password reset): {result}") + + # Ha az email küldés hibát dobott, ne titkoljuk el + if result.get("status") == "error": + logger.error(f"Password reset email FAILED for {email}: {result.get('message')}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Email küldési hiba: {result.get('message')}" + ) + + await db.commit() + return "success" @staticmethod async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str): @@ -396,6 +426,57 @@ class AuthService: raise except: return False + @staticmethod + async def resend_verification(db: AsyncSession, email: str): + """ + Aktiváló e-mail újraküldése. + Ellenőrzi, hogy a felhasználó létezik, de még nem aktív (is_active == False), + generál egy új verifikációs tokent, és kiküldi az e-mailt. + """ + # Eager load person relationship to avoid async lazy-load issue + stmt = select(User).options(joinedload(User.person)).where( + and_(User.email == email, User.is_deleted == False) + ) + user = (await db.execute(stmt)).unique().scalar_one_or_none() + + if not user: + # Ne áruljuk el, hogy létezik-e az email — biztonsági okokból mindig sikert adunk + logger.info(f"Resend verification requested for non-existent email: {email}") + return "not_found" + + if user.is_active: + logger.info(f"Resend verification requested for already active user: {email}") + return "already_active" + + # Dinamikus lejárat az adminból + reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user.region_code, default=48) + token_val = uuid.uuid4() + + db.add(VerificationToken( + token=token_val, + user_id=user.id, + token_type="registration", + expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours)) + )) + + # Email küldés — user.person már eager-loaded, így nem okoz MissingGreenlet hibát + verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}" + person = user.person + first_name = person.first_name if person else user.email + + logger.info(f"Email küldés indítása (resend verification) ide: {email}...") + result = await email_manager.send_email( + recipient=email, + template_key="reg", + variables={"first_name": first_name, "link": verification_link}, + lang=user.preferred_language + ) + logger.info(f"Email küldés eredménye (resend verification): {result}") + + await db.commit() + logger.info(f"Resend verification email sent to {email}") + return "success" + @staticmethod async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int): """ Felhasználó törlése (Soft-Delete) auditálással. """ diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index f6ff958..5684f6f 100755 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -81,7 +81,7 @@ class ConfigService: Returns: A talált érték (a megfelelő típusban) vagy a default. """ - from sqlalchemy import select, and_, cast, String + from sqlalchemy import select, and_, cast, String, Text try: # Convert scope_level to string for comparison - handle both Enum and string @@ -91,12 +91,12 @@ class ConfigService: scope_str = str(scope_level) # Build query with case-insensitive comparison for scope_level - # Use ilike or lower() for case-insensitive comparison since enum values might have inconsistent casing + # Use cast to Text (not String) to avoid asyncpg enum codec cache issues from sqlalchemy import func query = select(SystemParameter).where( and_( SystemParameter.key == key, - func.lower(cast(SystemParameter.scope_level, String)) == scope_str.lower(), + func.lower(cast(SystemParameter.scope_level, Text)) == scope_str.lower(), SystemParameter.is_active == True ) ) diff --git a/backend/app/services/email_manager.py b/backend/app/services/email_manager.py index 66341e8..e274793 100755 --- a/backend/app/services/email_manager.py +++ b/backend/app/services/email_manager.py @@ -16,27 +16,12 @@ from app.db.session import AsyncSessionLocal logger = logging.getLogger("Email-Manager-2.0") class EmailManager: - @staticmethod - def _get_base_url() -> str: - """Return the appropriate base URL for email links.""" - # Check environment variable first - base_url = os.getenv("EMAIL_BASE_URL") - if base_url: - return base_url.rstrip('/') - - # Fallback to dev domain for external access - return "https://dev.servicefinder.hu" - - @staticmethod - def _build_verification_link(token: str) -> str: - """Build verification link with proper path.""" - base = EmailManager._get_base_url() - # Use frontend verification route (adjust if needed) - return f"{base}/verify?token={token}" - @staticmethod def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str: - """HTML sablon generálása a fordítási fájlok alapján.""" + """HTML sablon generálása a fordítási fájlok alapján. + + The caller MUST provide a 'link' key in variables. No fallback guessing. + """ greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables) body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables) button_text = locale_manager.get(f"email.{template_key}_button", lang=lang) @@ -44,10 +29,10 @@ class EmailManager: link_fallback_text = locale_manager.get("email.link_fallback", lang=lang) - # If link is not provided but token is, build verification link + # Strict: only use variables.get('link'), no guessing link = variables.get('link') - if not link and 'token' in variables: - link = EmailManager._build_verification_link(variables['token']) + if not link: + logger.error(f"No 'link' variable provided for template '{template_key}' (lang={lang})") return f""" @@ -83,8 +68,14 @@ class EmailManager: session_internal = True try: - # Check if emails are disabled via DB config - provider = await config.get_setting(db, "email_provider", default="smtp") + # --- FIX: Wrap DB config lookup in try-except with env fallback --- + # If the DB query fails (e.g. enum mismatch), fall back to environment variable + try: + provider = await config.get_setting(db, "email_provider", default=None) + except Exception as cfg_err: + logger.warning(f"DB config error reading email_provider: {cfg_err}. Falling back to ENV variables...") + provider = None + if provider == "disabled": logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}") return {"status": "success", "provider": "disabled", "message": "Email disabled by admin config"} @@ -92,8 +83,9 @@ class EmailManager: html = EmailManager._get_html_template(template_key, variables, lang) subject = locale_manager.get(f"email.{template_key}_subject", lang=lang) - # Get email provider from environment - email_provider = os.getenv("EMAIL_PROVIDER", "smtp").lower() + # Determine email provider: DB config > ENV > fallback to 'smtp' + email_provider_raw = provider if provider else os.getenv("EMAIL_PROVIDER", "smtp") + email_provider = str(email_provider_raw).lower().strip() if email_provider == "brevo_api": result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables) @@ -243,7 +235,14 @@ class EmailManager: else: logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}") with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server: - server.starttls() + # Try STARTTLS, but fall back to plain if server doesn't support it + # (e.g. local Mailpit for testing) + try: + server.starttls() + except smtplib.SMTPNotSupportedError: + logger.warning(f"STARTTLS not supported by {smtp_host}:{smtp_port}, sending in plain text") + except Exception: + logger.warning(f"STARTTLS failed on {smtp_host}:{smtp_port}, sending in plain text") if smtp_user and smtp_pass: server.login(smtp_user, smtp_pass) server.send_message(msg) diff --git a/backend/static/locales/en.json b/backend/static/locales/en.json index ebb70b5..07f042a 100755 --- a/backend/static/locales/en.json +++ b/backend/static/locales/en.json @@ -14,10 +14,25 @@ }, "EMAIL": { "REG": { - "SUBJECT": "Confirm your registration" + "SUBJECT": "Confirm your registration - Service Finder", + "GREETING": "Hi {{first_name}}!", + "BODY": "Thank you for registering! Click the button below to activate your account:", + "BUTTON": "Activate Account", + "FOOTER": "If you did not register, please ignore this email." }, "PWD_RESET": { - "SUBJECT": "Password Reset Request" + "SUBJECT": "Password Reset Request", + "GREETING": "Hi!", + "BODY": "You requested a password reset. Click the button below to set a new password:", + "BUTTON": "Reset Password", + "FOOTER": "If you did not request a password reset, please ignore this email." + }, + "TEST": { + "SUBJECT": "🧪 Test Email - Service Finder", + "GREETING": "Hi {{first_name}}!", + "BODY": "This is a test email from the Service Finder system. If you see this, email sending is working!", + "BUTTON": "Go to Dashboard", + "FOOTER": "Service Finder - Test system message" } } } \ No newline at end of file diff --git a/backend/static/locales/hu.json b/backend/static/locales/hu.json index 4f623ec..9d92ddc 100755 --- a/backend/static/locales/hu.json +++ b/backend/static/locales/hu.json @@ -19,10 +19,24 @@ "EMAIL": { "REG": { "SUBJECT": "Regisztráció megerősítése - Service Finder", - "GREETING": "Szia {{first_name}}! Kattints a linkre a flottád aktiválásához: {{link}}" + "GREETING": "Szia {{first_name}}!", + "BODY": "Köszönjük a regisztrációt! Kattints az alábbi gombra a fiókod aktiválásához:", + "BUTTON": "Fiók Aktiválása", + "FOOTER": "Ha nem te regisztráltál, kérjük hagyd figyelmen kívül ezt az e-mailt." }, "PWD_RESET": { - "SUBJECT": "Jelszó visszaállítás" + "SUBJECT": "Jelszó visszaállítás", + "GREETING": "Szia!", + "BODY": "Jelszó visszaállítást kértél. Kattints az alábbi gombra az új jelszó beállításához:", + "BUTTON": "Jelszó Visszaállítása", + "FOOTER": "Ha nem te kérted a jelszó visszaállítást, kérjük hagyd figyelmen kívül ezt az e-mailt." + }, + "TEST": { + "SUBJECT": "🧪 Teszt levél - Service Finder", + "GREETING": "Szia {{first_name}}!", + "BODY": "Ez egy teszt e-mail a Service Finder rendszerből. Ha ezt látod, az e-mail küldés működik!", + "BUTTON": "Irány a Dashboard", + "FOOTER": "Service Finder - Teszt rendszerüzenet" } }, "COMMON": { diff --git a/backend/test_email.py b/backend/test_email.py new file mode 100644 index 0000000..fc9f186 --- /dev/null +++ b/backend/test_email.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Test Email Script — Service Finder +==================================== +Tests the EmailManager by sending a test email using the configured provider. +Reads all settings from .env automatically via the app's Settings class. + +Usage: + # Test with configured provider (Brevo API / SMTP): + docker compose exec sf_api python3 /app/test_email.py + + # Test with local Mailpit (no real email sent, no TLS needed): + docker compose exec sf_api /bin/sh -c " \ + SMTP_HOST=sf_mailpit SMTP_PORT=1025 SMTP_USER= SMTP_PASSWORD= \ + EMAIL_PROVIDER=smtp TEST_RECIPIENT=kincses@gmail.com \ + python3 /app/test_email.py" + + # Test with Brevo API directly: + docker compose exec sf_api /bin/sh -c " \ + EMAIL_PROVIDER=brevo_api TEST_RECIPIENT=kincses@gmail.com \ + python3 /app/test_email.py" + +Environment check: + - EMAIL_PROVIDER=smtp → uses SMTP + - EMAIL_PROVIDER=brevo_smtp → uses Brevo SMTP relay + - EMAIL_PROVIDER=brevo_api → uses Brevo REST API +""" + +import os +import sys +import asyncio +import logging + +# Ensure the backend package is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# ── Load .env but do NOT override already-set env vars ── +from dotenv import load_dotenv +env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') +load_dotenv(env_path, override=False) # override=False = respect existing env vars + +# ── Logging setup ── +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', +) +logger = logging.getLogger("TestEmail") + +async def main(): + """Send a test email and report the result.""" + print("=" * 70) + print(" SERVICE FINDER — EMAIL TEST SCRIPT") + print("=" * 70) + + # ── 1. Show current email configuration ── + provider = os.getenv("EMAIL_PROVIDER", "smtp") + print(f"\n📧 Current EMAIL_PROVIDER: {provider}") + print(f" SMTP_HOST: {os.getenv('SMTP_HOST', '(not set)')}") + print(f" SMTP_PORT: {os.getenv('SMTP_PORT', '(not set)')}") + print(f" SMTP_USER: {os.getenv('SMTP_USER', '(not set)')}") + print(f" MAIL_FROM: {os.getenv('MAIL_FROM', '(not set)')}") + print(f" MAIL_FROM_NAME: {os.getenv('MAIL_FROM_NAME', '(not set)')}") + + # Brevo-specific checks + brevo_api_key = os.getenv("BREVO_API_KEY") + brevo_smtp_user = os.getenv("BREVO_SMTP_USER") + brevo_smtp_pass = os.getenv("BREVO_SMTP_PASSWORD") + if provider == "brevo_api": + print(f" BREVO_API_KEY: {'✅ Set' if brevo_api_key else '❌ NOT SET'}") + elif provider == "brevo_smtp": + print(f" BREVO_SMTP_USER: {'✅ Set' if brevo_smtp_user else '❌ NOT SET'}") + print(f" BREVO_SMTP_PASSWORD: {'✅ Set' if brevo_smtp_pass else '❌ NOT SET'}") + + # ── 2. Recipient ── + recipient = os.getenv("TEST_RECIPIENT") or os.getenv("INITIAL_ADMIN_EMAIL") or "kincses@valami.hu" + print(f"\n📨 Test recipient: {recipient}") + + # ── 3. Import and send ── + print("\n⏳ Sending test email...\n") + + try: + from app.services.email_manager import EmailManager + + result = await EmailManager.send_email( + recipient=recipient, + template_key="test", + variables={ + "first_name": "Teszt", + "link": "https://dev.servicefinder.hu/dashboard", + }, + lang="hu", + ) + + print(f"\n{'=' * 70}") + print(f" 📬 EMAIL RESULT") + print(f"{'=' * 70}") + print(f" Status: {result.get('status', 'unknown')}") + print(f" Provider: {result.get('provider', 'unknown')}") + if result.get('message_id'): + print(f" MessageID: {result['message_id']}") + if result.get('message'): + print(f" Message: {result['message']}") + print(f"{'=' * 70}\n") + + if result.get("status") == "success": + print("✅ TEST PASSED — Email sent successfully!") + return 0 + else: + print(f"❌ TEST FAILED — {result.get('message', 'Unknown error')}") + return 1 + + except Exception as e: + print(f"\n❌ CRITICAL ERROR: {e}") + import traceback + traceback.print_exc() + return 1 + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/backend/trigger_auth_emails.py b/backend/trigger_auth_emails.py new file mode 100644 index 0000000..84c4876 --- /dev/null +++ b/backend/trigger_auth_emails.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Trigger Auth Emails — Diagnostic Script +========================================= +Szimulálja az éles API végpontok működését a teljes Auth logikán keresztül, +hogy kiderüljön, hol akad el a folyamat. + +Használat: + docker compose exec sf_api python3 /app/trigger_auth_emails.py + +Környezeti változók: + - TEST_EMAIL: Cél e-mail cím (alapértelmezett: accipe.t@aplast.hu) + - EMAIL_PROVIDER: smtp | brevo_api | brevo_smtp +""" + +import os +import sys +import asyncio +import logging + +# Ensure the backend package is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# ── Load .env ── +from dotenv import load_dotenv +env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') +load_dotenv(env_path, override=False) + +# ── Logging setup ── +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', +) +logger = logging.getLogger("TriggerAuthEmails") + +# Increase log level for noisy libs +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + + +async def main(): + print("=" * 70) + print(" SERVICE FINDER — TRIGGER AUTH EMAILS (DIAGNOSTIC)") + print("=" * 70) + + # ── 1. Target email ── + target_email = os.getenv("TEST_EMAIL", "accipe.t@aplast.hu") + print(f"\n🎯 Target email: {target_email}") + + # ── 2. Show email config ── + provider = os.getenv("EMAIL_PROVIDER", "smtp") + print(f"\n📧 Current EMAIL_PROVIDER: {provider}") + print(f" SMTP_HOST: {os.getenv('SMTP_HOST', '(not set)')}") + print(f" SMTP_PORT: {os.getenv('SMTP_PORT', '(not set)')}") + print(f" MAIL_FROM: {os.getenv('MAIL_FROM', '(not set)')}") + print(f" MAIL_FROM_NAME: {os.getenv('MAIL_FROM_NAME', '(not set)')}") + print(f" BREVO_API_KEY: {'✅ Set' if os.getenv('BREVO_API_KEY') else '❌ NOT SET'}") + print(f" BREVO_SMTP_USER: {'✅ Set' if os.getenv('BREVO_SMTP_USER') else '❌ NOT SET'}") + print(f" BREVO_SMTP_PASSWORD: {'✅ Set' if os.getenv('BREVO_SMTP_PASSWORD') else '❌ NOT SET'}") + + # ── 3. Import and execute ── + try: + from app.db.session import AsyncSessionLocal + from app.services.auth_service import AuthService + from sqlalchemy import text + + # ── 3a. Check if user exists ── + print(f"\n🔍 Checking user: {target_email}...") + async with AsyncSessionLocal() as db: + result = await db.execute( + text("SELECT id, email, is_active, preferred_language, region_code FROM identity.users WHERE email = :email AND is_deleted = false"), + {"email": target_email} + ) + user = result.fetchone() + + if not user: + print(f"❌ User not found with email: {target_email}") + print(" Check if the user exists in the database.") + return + + user_id, email, is_active, lang, region = user + print(f" ✅ User found:") + print(f" ID: {user_id}") + print(f" Email: {email}") + print(f" Active: {is_active}") + print(f" Lang: {lang}") + print(f" Region: {region}") + + # ── 3b. Check existing verification tokens ── + result = await db.execute( + text(""" + SELECT id, token, token_type, is_used, expires_at, created_at + FROM identity.verification_tokens + WHERE user_id = :uid + ORDER BY created_at DESC + LIMIT 5 + """), + {"uid": user_id} + ) + tokens = result.fetchall() + print(f"\n🔑 Existing verification tokens for user #{user_id}:") + if tokens: + for t in tokens: + print(f" ID={t[0]} type={t[2]} used={t[3]} expires={t[4]} created={t[5]}") + else: + print(" (none)") + + # ── 3c. Test 1: Resend Verification ── + print(f"\n{'=' * 70}") + print(f" 📬 TEST 1: Resend Verification Email") + print(f"{'=' * 70}") + print(f" Calling AuthService.resend_verification(email='{target_email}')...\n") + + async with AsyncSessionLocal() as db: + try: + result = await AuthService.resend_verification(db=db, email=target_email) + print(f"\n✅ Result: {result}") + except Exception as e: + print(f"\n❌ Exception: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + + # ── 3d. Test 2: Initiate Password Reset ── + print(f"\n{'=' * 70}") + print(f" 📬 TEST 2: Initiate Password Reset Email") + print(f"{'=' * 70}") + print(f" Calling AuthService.initiate_password_reset(email='{target_email}')...\n") + + async with AsyncSessionLocal() as db: + try: + result = await AuthService.initiate_password_reset(db=db, email=target_email) + print(f"\n✅ Result: {result}") + except Exception as e: + print(f"\n❌ Exception: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + + # ── 3e. Test 3: Direct EmailManager call ── + print(f"\n{'=' * 70}") + print(f" 📬 TEST 3: Direct EmailManager.send_email (template_key='reg')") + print(f"{'=' * 70}") + print(f" Calling EmailManager.send_email() directly...\n") + + from app.services.email_manager import EmailManager + + async with AsyncSessionLocal() as db: + try: + result = await EmailManager.send_email( + recipient=target_email, + template_key="reg", + variables={ + "first_name": "Teszt", + "link": "https://dev.servicefinder.hu/verify?token=test-token-123", + }, + lang="hu", + db=db, + ) + print(f"\n✅ Result: {result}") + except Exception as e: + print(f"\n❌ Exception: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + + # ── 3f. Test 4: Direct EmailManager call with pwd_reset template ── + print(f"\n{'=' * 70}") + print(f" 📬 TEST 4: Direct EmailManager.send_email (template_key='pwd_reset')") + print(f"{'=' * 70}") + print(f" Calling EmailManager.send_email() directly...\n") + + async with AsyncSessionLocal() as db: + try: + result = await EmailManager.send_email( + recipient=target_email, + template_key="pwd_reset", + variables={ + "link": "https://dev.servicefinder.hu/reset-password?token=test-token-456", + }, + lang="hu", + db=db, + ) + print(f"\n✅ Result: {result}") + except Exception as e: + print(f"\n❌ Exception: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + + except ImportError as e: + print(f"\n❌ Import Error: {e}") + print(" Make sure you run this inside the sf_api container!") + print(" Command: docker compose exec sf_api python3 /app/trigger_auth_emails.py") + sys.exit(1) + + print(f"\n{'=' * 70}") + print(" ✅ DIAGNOSTIC COMPLETE") + print(f"{'=' * 70}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/profile_ui_identity_docs_password_implementation.md b/docs/profile_ui_identity_docs_password_implementation.md new file mode 100644 index 0000000..659a288 --- /dev/null +++ b/docs/profile_ui_identity_docs_password_implementation.md @@ -0,0 +1,89 @@ +# Profile UI Refactor + Identity Docs + Change Password Implementation + +## Overview + +Full-stack implementation of Profile page UI/UX improvements, identity documents (identity_docs) display/editing, and secure password change functionality. + +## Backend Changes + +### 1. [`backend/app/schemas/user.py`](backend/app/schemas/user.py:68) — PersonUpdate Schema + +- Added `identity_docs: Optional[Any] = None` field to `PersonUpdate` class (line 79) +- Created new `ChangePasswordRequest` class (lines 93-96) with: + - `current_password: str` — the user's existing password for verification + - `new_password: str` — the desired new password + +### 2. [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:179) — PUT /me/person + +- Updated imports to include `ChangePasswordRequest`, `AuthService`, `verify_password`, `get_password_hash` +- Added identity_docs merge logic (lines 219-227): + - If `identity_docs` is provided and not null, merges with existing docs using `dict.update()` + - Preserves fields not sent in the update payload (e.g., if only LICENSE is sent, ID_CARD is preserved) + - Falls back to direct assignment if either value is not a dict + +### 3. [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:260) — PUT /me/password (New Endpoint) + +Complete password change flow: +1. **Current password verification** (line 273): Uses `verify_password()` to check the provided current password against the stored hash +2. **New password ≠ old password check** (line 280): Prevents reusing the same password +3. **Complexity validation** (line 287): Calls `AuthService._validate_password_complexity()` which reads dynamic rules from `SystemParameter` (min length, uppercase, digits, special chars) +4. **Hash and save** (line 290): Uses `get_password_hash()` to bcrypt-hash the new password and commits to DB +5. **Error handling** (lines 292-297): Rollback on SQLAlchemyError, returns 500 with detail + +## Frontend Changes + +### 1. [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:19) — PersonData Interface + +- Added `identity_docs: Record | null` (line 28) +- Added `ice_contact: Record | null` (line 29) + +### 2. [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:430) — changePassword Action + +New Pinia action that: +- Calls `PUT /users/me/password` with `{ current_password, new_password }` +- Returns `{ status: string, message: string }` on success +- Throws with error message on failure + +### 3. [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:1) — Complete Rewrite + +#### UI Design (Dark Card Theme) +- **Card backgrounds**: `bg-black/40 backdrop-blur-xl` with `border border-white/10` +- **Data text**: `text-white font-medium` (bold white for readability) +- **Labels**: `text-white/50 text-sm` (muted gray labels) +- **Accent color**: `#00E5A0` (green) used **only** for buttons and active states +- **Input fields**: `bg-white/5 border-white/10` with focus state `border-[#00E5A0]/50` +- **Close button**: Top-right X button with `hover:text-red-400` + +#### Identity Docs Section (lines 214-309) +- **Read mode**: Displays ID_CARD (number, expiry_date) and LICENSE (number, expiry_date, categories) in bordered sub-cards +- **Edit mode**: Input fields for all identity doc fields, organized in sub-sections +- **Payload construction** (`buildIdentityDocsPayload()`, lines 636-663): Only sends identity_docs if at least one field is filled; constructs nested JSON `{ ID_CARD: { number, expiry_date }, LICENSE: { number, expiry_date, categories } }` + +#### Change Password Modal (lines 431-528) +- **Teleport to body**: Renders outside the component tree for proper z-index stacking +- **3 fields**: Current password, new password, confirm new password +- **Frontend validation** (`submitPasswordChange()`, lines 713-751): + - All fields required + - New password must match confirmation + - Minimum 6 characters +- **API call**: Calls `authStore.changePassword()` on success +- **Success feedback**: Green success message with **auto-close after 2 seconds** (`setTimeout` → `closePasswordModal()`) +- **Error feedback**: Red error message from backend detail or fallback string + +## Testing Results + +| Test | Result | +|------|--------| +| Backend syntax check (user.py, users.py) | ✅ Pass | +| Backend module imports (PersonUpdate, ChangePasswordRequest) | ✅ Pass | +| Backend router routes (PUT /me/person, PUT /me/password) | ✅ Pass | +| Frontend Vite build (112 modules) | ✅ Pass | +| ProfileView chunk size | 27.26 kB | + +## Security Considerations + +1. **Password never returned in responses** — the `ChangePasswordRequest` is input-only +2. **Bcrypt hashing** — `get_password_hash()` uses bcrypt with work factor +3. **Dynamic complexity rules** — `_validate_password_complexity` reads from `SystemParameter` table, allowing admin to change rules without code deploy +4. **No password logging** — the endpoint never logs the password values +5. **Identity docs merge** — preserves existing data when partial updates are sent diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86d1a7f..3d459ad 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "service-finder-frontend", "version": "1.0.0", "dependencies": { + "@fingerprintjs/fingerprintjs": "^5.2.0", "axios": "^1.6.0", "pinia": "^2.1.7", "vue": "^3.4.0", @@ -566,6 +567,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fingerprintjs/fingerprintjs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz", + "integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 16597db..7313c16 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { + "@fingerprintjs/fingerprintjs": "^5.2.0", "axios": "^1.6.0", "pinia": "^2.1.7", "vue": "^3.4.0", diff --git a/frontend/src/components/DashboardHeader.vue b/frontend/src/components/DashboardHeader.vue index 240faba..c65af22 100644 --- a/frontend/src/components/DashboardHeader.vue +++ b/frontend/src/components/DashboardHeader.vue @@ -115,6 +115,9 @@
+ + +
@@ -157,7 +160,7 @@
- -
+ +
+ + +
+ + + - +
- {{ authStore.error }} + {{ translateError(authStore.error) }}
@@ -153,17 +173,22 @@ :disabled="authStore.isLoading" class="btn-premium w-full px-4 py-3 text-lg" > - {{ authStore.isLoading ? 'Kérlek várj...' : 'Belépés' }} + {{ authStore.isLoading ? t('auth.loginLoading') : t('auth.loginButton') }} - +
- vagy + + + + + {{ t('auth.socialDivider') }} +
- +
- +
- {{ authStore.error }} + {{ translateError(authStore.error) }}
@@ -357,18 +382,18 @@ :disabled="authStore.isLoading" class="btn-premium w-full px-4 py-3 text-lg" > - {{ authStore.isLoading ? 'Kérlek várj...' : 'Regisztráció' }} + {{ authStore.isLoading ? t('auth.loginLoading') : t('auth.registerButton') }}

- Már van garázsod? + {{ t('auth.haveAccount') }} - Belépés + {{ t('auth.loginLink') }}

@@ -402,16 +427,48 @@

- Új jelszó igénylése + {{ t('auth.forgotTitle') }}

- -

- Add meg az e-mail címed, és küldünk egy biztonsági linket. + +

+ {{ t('auth.forgotDescription') }}

- -
+ +
+ {{ t('auth.forgotSuccess') }} +
+ + +
+

+ {{ t('auth.inactiveMessage') }} +

+ +

+ {{ t('auth.resendActivationSuccess') }} +

+
+ + +
@@ -426,31 +483,133 @@ />
- +
- {{ authStore.error }} + {{ translateError(forgotError) }}
- + - +

- Vissza a belépéshez + {{ t('auth.backToLogin') }} + +

+ + + +
+ + + + +

+ {{ t('auth.resendTitle') }} +

+ + +

+ {{ t('auth.resendDescription') }} +

+ + +
+
+ + + +
+

+ {{ t('auth.resendSuccess') }} +

+
+ + +
+ + +
+ + +
+ {{ translateError(resendError) }} +
+ + + + + +

+ + {{ t('auth.backToLogin') }}

@@ -461,9 +620,13 @@ + \ No newline at end of file diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 004434c..9280832 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -13,6 +13,7 @@ export default { profile: 'Profile Settings', logout: 'Logout', subtitle: 'Your dashboard and vehicle overview', + switchLanguage: 'Switch language', }, dashboard: { loading: 'Loading...', @@ -26,4 +27,47 @@ export default { hide: 'Hide UI (Zen mode)', show: 'Show UI', }, + auth: { + invalid_credentials: 'Invalid email or password.', + user_inactive: 'Your account is not activated yet. Please verify your email!', + forgotPasswordMessage: 'The password reset feature is under development. Please contact the administrator!', + // Resend verification + resendTitle: 'Resend Activation Email', + resendDescription: 'Enter your email address and we will resend the activation link.', + resendButton: 'Send Email', + resend_cooldown: 'Resend ({seconds}s)', + send_email: 'Send Email', + resendSuccess: 'The activation email has been sent! Check your folders (including Spam).', + resendLink: 'Didn\'t receive the activation email?', + backToLogin: 'Back to Login', + // Login view + loginTitle: 'Login to Garage', + loginButton: 'Login', + loginLoading: 'Please wait...', + forgotPassword: 'Forgot your password?', + rememberMe: 'Remember me', + noAccount: 'Don\'t have a garage yet?', + registerLink: 'Register', + // Register view + registerTitle: 'Open New Garage', + registerButton: 'Register', + haveAccount: 'Already have a garage?', + loginLink: 'Login', + // Forgot password view + forgotTitle: 'Request New Password', + forgotDescription: 'Enter your email address and we will send you a security link.', + forgotSuccess: 'The reset link has been sent to your email!', + forgotSendButton: 'Send Link', + // Inactive user in forgot flow + inactiveMessage: 'Your account is not activated yet! Please activate using the link in the email, or request a new activation email.', + resendActivationButton: 'Resend Activation Email', + resendActivationSuccess: 'The activation email has been resent!', + // Social login + socialDivider: 'Or login with Social account', + // Errors + loginFailed: 'Login failed. Please check your credentials.', + registerFailed: 'Registration failed.', + resendFailed: 'Resend failed. Please try again later.', + forgotFailed: 'An error occurred while sending the request. Please try again.', + }, } diff --git a/frontend/src/i18n/hu.ts b/frontend/src/i18n/hu.ts index b989614..267c026 100644 --- a/frontend/src/i18n/hu.ts +++ b/frontend/src/i18n/hu.ts @@ -13,6 +13,7 @@ export default { profile: 'Profil beállítások', logout: 'Kilépés', subtitle: 'Irányítópultod és járműveid áttekintése', + switchLanguage: 'Nyelv váltása', }, dashboard: { loading: 'Betöltés...', @@ -26,4 +27,47 @@ export default { hide: 'UI elrejtése (Zen mód)', show: 'UI megjelenítése', }, + auth: { + invalid_credentials: 'Hibás e-mail cím vagy jelszó.', + user_inactive: 'A fiókod még nincs aktiválva. Kérlek, erősítsd meg az e-mail címedet!', + forgotPasswordMessage: 'A jelszóvisszaállítás funkció fejlesztés alatt áll. Kérlek, fordulj az adminisztrátorhoz!', + // Resend verification + resendTitle: 'Aktiváló levél újraküldése', + resendDescription: 'Add meg az e-mail címedet, és újraküldjük az aktiváló linket.', + resendButton: 'Levél Küldése', + resend_cooldown: 'Újraküldés ({seconds} mp)', + send_email: 'Levél Küldése', + resendSuccess: 'Az aktiváló levelet elküldtük! Ellenőrizd a mappáidat (a Spam-et is).', + resendLink: 'Nem kaptad meg az aktiváló levelet?', + backToLogin: 'Vissza a belépéshez', + // Login view + loginTitle: 'Belépés a garázsba', + loginButton: 'Belépés', + loginLoading: 'Kérlek várj...', + forgotPassword: 'Elfelejtetted a jelszavad?', + rememberMe: 'Emlékezz rám', + noAccount: 'Még nincs garázsod?', + registerLink: 'Regisztráció', + // Register view + registerTitle: 'Új Garázs Nyitása', + registerButton: 'Regisztráció', + haveAccount: 'Már van garázsod?', + loginLink: 'Belépés', + // Forgot password view + forgotTitle: 'Új jelszó igénylése', + forgotDescription: 'Add meg az e-mail címed, és küldünk egy biztonsági linket.', + forgotSuccess: 'A visszaállítási linket elküldtük az e-mail címedre!', + forgotSendButton: 'Link Küldése', + // Inactive user in forgot flow + inactiveMessage: 'A fiókod még nincs aktiválva! Kérlek aktiváld az e-mail-ben kapott linkkel, vagy kérj egy új aktiváló levelet.', + resendActivationButton: 'Aktiváló levél újraküldése', + resendActivationSuccess: 'Az aktiváló levelet újraküldtük!', + // Social login + socialDivider: 'Vagy lépj be Social fiókkal', + // Errors + loginFailed: 'Bejelentkezés sikertelen. Ellenőrizd az adataidat.', + registerFailed: 'Regisztráció sikertelen.', + resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.', + forgotFailed: 'Hiba történt a kérelem elküldésekor. Kérlek próbáld újra.', + }, } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9deed38..b3110ea 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -12,9 +12,31 @@ const messages = { en, } +/** + * Determine the initial locale: + * 1. If a saved locale exists in localStorage, use it. + * 2. Otherwise, detect the browser language (navigator.language). + * If it starts with 'hu', use 'hu'. For everything else, use 'en'. + */ +function detectLocale(): string { + const saved = localStorage.getItem('user-locale') + if (saved) return saved + + if (typeof navigator !== 'undefined' && navigator.language) { + const browserLang = navigator.language.toLowerCase() + if (browserLang === 'hu' || browserLang.startsWith('hu-')) { + return 'hu' + } + } + + return 'en' +} + +const initialLocale = detectLocale() + const i18n = createI18n({ legacy: false, - locale: 'hu', + locale: initialLocale, fallbackLocale: 'en', messages, }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 10d870b..f85aa3e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -20,22 +20,48 @@ const router = createRouter({ name: 'complete-kyc', // Lazy-loaded — placeholder until the actual KYC view is built component: () => import('../views/CompleteKycView.vue') + }, + { + path: '/profile', + name: 'profile', + // Auth-protected profile settings page + component: () => import('../views/ProfileView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/verify', + name: 'verify', + // Email verification page (accessible without auth) + component: () => import('../views/VerifyEmailView.vue') + }, + { + path: '/reset-password', + name: 'reset-password', + // Password reset page — reads ?token= from query param + component: () => import('../views/ResetPasswordView.vue') } ] }) // ── Global navigation guard ────────────────────────────────────────── // If an authenticated user navigates to the root ("/"), redirect to "/dashboard". +// Protected routes (meta.requiresAuth) redirect unauthenticated users to "/". router.beforeEach(async (to, _from, next) => { + const { useAuthStore } = await import('../stores/auth') + const authStore = useAuthStore() + + // Redirect authenticated users away from landing if (to.path === '/') { - // Lazy-access the Pinia store inside the guard to avoid any - // circular-dependency issues at module-load time. - const { useAuthStore } = await import('../stores/auth') - const authStore = useAuthStore() if (authStore.isAuthenticated) { return next('/dashboard') } } + + // Protect routes that require authentication + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + return next('/') + } + next() }) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 254fef0..9027da0 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -3,6 +3,34 @@ import { ref, computed } from 'vue' import router from '../router' import api from '../api/axios' +export interface AddressData { + zip: string | null + city: string | null + street_name: string | null + street_type: string | null + house_number: string | null + stairwell: string | null + floor: string | null + door: string | null + parcel_id: string | null + full_address_text: string | null +} + +export interface PersonData { + id: number + first_name: string | null + last_name: string | null + phone: string | null + mothers_last_name: string | null + mothers_first_name: string | null + birth_place: string | null + birth_date: string | null + identity_docs: Record | null + ice_contact: Record | null + is_active: boolean + address: AddressData | null +} + export interface UserProfile { id: number email: string @@ -16,8 +44,11 @@ export interface UserProfile { scope_id: string | null ui_mode: string active_organization_id: number | null + preferred_language: string // person_id is set when KYC is complete (Person record exists) person_id: number | null + // Nested person data with address + person: PersonData | null } export const useAuthStore = defineStore('auth', () => { @@ -42,14 +73,64 @@ export const useAuthStore = defineStore('auth', () => { }) const userId = computed(() => user.value?.id ?? null) + // ────────────────────────────── Helpers ──────────────────────────── + + /** + * Extract the raw error code from the backend response. + * Backend returns codes like "[AUTH.INVALID_CREDENTIALS]" or plain strings. + * Strips brackets and returns the clean code, or null if not a code. + */ + function extractErrorCode(err: any): string | null { + const detail = err.response?.data?.detail || err.response?.data?.message || '' + if (!detail) return null + // Strip surrounding brackets: [AUTH.INVALID_CREDENTIALS] -> AUTH.INVALID_CREDENTIALS + const match = detail.match(/^\[?([A-Z]+\.[A-Z_]+)\]?$/) + return match ? match[1] : null + } + + /** + * Map a backend error code to a user-friendly i18n key path. + * Returns the i18n key (e.g. 'auth.invalid_credentials') or null if unmapped. + */ + function mapErrorCodeToI18nKey(code: string): string | null { + const map: Record = { + 'AUTH.INVALID_CREDENTIALS': 'auth.invalid_credentials', + 'AUTH.USER_INACTIVE': 'auth.user_inactive', + 'AUTH.USER_NOT_FOUND': 'auth.invalid_credentials', + 'AUTH.EMAIL_EXISTS': 'auth.email_exists', + 'AUTH.INVALID_TOKEN': 'auth.invalid_token', + 'AUTH.TOKEN_EXPIRED': 'auth.token_expired', + 'AUTH.RATE_LIMIT': 'auth.rate_limit', + } + return map[code] || null + } + + /** + * Get a user-friendly error message from an API error response. + * Returns an i18n key path (e.g. 'auth.invalid_credentials') if the error + * matches a known code, or the raw detail string as fallback. + */ + function getErrorMessage(err: any, fallback: string): string { + const code = extractErrorCode(err) + if (code) { + const i18nKey = mapErrorCodeToI18nKey(code) + if (i18nKey) return i18nKey + } + // Fallback to raw detail or message + return err.response?.data?.detail || + err.response?.data?.message || + fallback + } + // ────────────────────────────── Actions ──────────────────────────── /** * Login with email + password. * The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm, * which expects application/x-www-form-urlencoded with `username` and `password`. + * Supports optional device_fingerprint for Device-Hub tracking. */ - async function login(email: string, password: string) { + async function login(email: string, password: string, deviceFingerprint?: string) { isLoading.value = true error.value = null @@ -57,6 +138,9 @@ export const useAuthStore = defineStore('auth', () => { const body = new URLSearchParams() body.append('username', email) // FastAPI OAuth2 form uses `username` key body.append('password', password) + if (deviceFingerprint) { + body.append('device_fingerprint', deviceFingerprint) + } const res = await api.post('/auth/login', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -78,12 +162,16 @@ export const useAuthStore = defineStore('auth', () => { // Immediately fetch the user profile await fetchUser() + + // Clear pending verification email after successful login + localStorage.removeItem('pending_verification_email') + + // If KYC is not complete, redirect to KYC page + if (!isKycComplete.value) { + router.push('/complete-kyc') + } } catch (err: any) { - const message = - err.response?.data?.detail || - err.response?.data?.message || - 'Bejelentkezés sikertelen. Ellenőrizd az adataidat.' - error.value = message + error.value = getErrorMessage(err, 'auth.loginFailed') throw err } finally { isLoading.value = false @@ -132,6 +220,9 @@ export const useAuthStore = defineStore('auth', () => { const res = await api.post('/auth/register', payload) + // Save email to localStorage for verification page auto-fill + localStorage.setItem('pending_verification_email', data.email) + return res.data as { status: string message: string @@ -139,11 +230,26 @@ export const useAuthStore = defineStore('auth', () => { email: string } } catch (err: any) { - const message = - err.response?.data?.detail || - err.response?.data?.message || - 'Regisztráció sikertelen.' - error.value = message + error.value = getErrorMessage(err, 'auth.registerFailed') + throw err + } finally { + isLoading.value = false + } + } + + /** + * Resend the verification email. + * POST /auth/resend-verification with { email }. + */ + async function resendVerification(email: string) { + isLoading.value = true + error.value = null + + try { + const res = await api.post('/auth/resend-verification', { email }) + return res.data as { status: string; message: string } + } catch (err: any) { + error.value = getErrorMessage(err, 'auth.resendFailed') throw err } finally { isLoading.value = false @@ -161,11 +267,35 @@ export const useAuthStore = defineStore('auth', () => { const res = await api.post('/auth/forgot-password', { email }) return res.data as { status: string; message: string } } catch (err: any) { - const message = - err.response?.data?.detail || - err.response?.data?.message || - 'Hiba történt a jelszó-visszaállítási kérelem elküldésekor.' - error.value = message + const code = extractErrorCode(err) + if (code === 'AUTH.USER_INACTIVE') { + error.value = 'auth.user_inactive' + } else { + error.value = getErrorMessage(err, 'auth.forgotFailed') + } + throw err + } finally { + isLoading.value = false + } + } + + /** + * Reset password using token from email. + * POST /auth/reset-password with { email, token, new_password }. + */ + async function resetPassword(email: string, token: string, newPassword: string) { + isLoading.value = true + error.value = null + + try { + const res = await api.post('/auth/reset-password', { + email, + token, + new_password: newPassword, + }) + return res.data as { status: string; message: string } + } catch (err: any) { + error.value = getErrorMessage(err, 'auth.resetPasswordFailed') throw err } finally { isLoading.value = false @@ -198,6 +328,56 @@ export const useAuthStore = defineStore('auth', () => { } } + /** + * Verify email account using the token from the activation link (Magic Link). + * POST /auth/verify-email with { token, device_fingerprint }. + * Now returns JWT tokens on success, automatically logging the user in. + * Supports optional device_fingerprint for Device-Hub tracking. + */ + async function verifyAccount(verificationToken: string, deviceFingerprint?: string) { + isLoading.value = true + error.value = null + + try { + const payload: Record = { token: verificationToken } + if (deviceFingerprint) { + payload.device_fingerprint = deviceFingerprint + } + const res = await api.post('/auth/verify-email', payload) + const data = res.data as { + access_token?: string + refresh_token?: string + token_type?: string + is_active?: boolean + status?: string + message?: string + } + + // Magic Link: Ha a backend JWT tokent küld vissza, automatikus bejelentkezés + if (data.access_token) { + // Persist token + localStorage.setItem('access_token', data.access_token) + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token) + } + token.value = data.access_token + + // Immediately fetch the user profile + await fetchUser() + + // Clear pending verification email after successful verification + login + localStorage.removeItem('pending_verification_email') + } + + return data + } catch (err: any) { + error.value = getErrorMessage(err, 'auth.verificationFailed') + throw err + } finally { + isLoading.value = false + } + } + /** * Complete KYC (Know Your Customer) — full profile submission. * POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile. @@ -213,11 +393,52 @@ export const useAuthStore = defineStore('auth', () => { await fetchUser() return true } catch (err: any) { - const message = - err.response?.data?.detail || - err.response?.data?.message || - 'A KYC kitöltése sikertelen. Kérlek ellenőrizd az adatokat.' - error.value = message + error.value = getErrorMessage(err, 'auth.kycFailed') + throw err + } finally { + isLoading.value = false + } + } + + /** + * Update the user's Person record (profile data + address). + * PUT /users/me/person with PersonUpdate schema. + * On success, refreshes the local user state with the response. + */ + async function updatePerson(personData: Record): Promise { + isLoading.value = true + error.value = null + + try { + const res = await api.put('/users/me/person', personData) + // Update the local user state with the fresh response + user.value = res.data as UserProfile + return true + } catch (err: any) { + error.value = getErrorMessage(err, 'auth.kycFailed') + throw err + } finally { + isLoading.value = false + } + } + + /** + * Change the current user's password. + * PUT /users/me/password with { current_password, new_password }. + * On success, returns { status: 'ok', message: string }. + */ + async function changePassword(currentPassword: string, newPassword: string): Promise<{ status: string; message: string }> { + isLoading.value = true + error.value = null + + try { + const res = await api.put('/users/me/password', { + current_password: currentPassword, + new_password: newPassword, + }) + return res.data as { status: string; message: string } + } catch (err: any) { + error.value = getErrorMessage(err, 'A jelszó módosítása sikertelen.') throw err } finally { isLoading.value = false @@ -249,8 +470,13 @@ export const useAuthStore = defineStore('auth', () => { login, fetchUser, register, + resendVerification, + verifyAccount, forgotPassword, + resetPassword, completeKyc, + updatePerson, + changePassword, logout, init, clearError, diff --git a/frontend/src/views/CompleteKycView.vue b/frontend/src/views/CompleteKycView.vue index 2d1e57d..e252bb3 100644 --- a/frontend/src/views/CompleteKycView.vue +++ b/frontend/src/views/CompleteKycView.vue @@ -79,10 +79,10 @@
- +

Lakcím adatok

-

Pontos cím a garázs és flotta kezeléshez

+

Csak az irányítószám és város kötelező (Soft KYC)

@@ -141,7 +141,7 @@
- +
- +
@@ -185,7 +186,7 @@

Okmányok

-

Személyi igazolvány és jogosítvány adatok

+

Személyi igazolvány és jogosítvány adatok (opcionális)

@@ -193,8 +194,8 @@
-
- Személyi igazolvány +
+ Személyi igazolvány (opcionális)
@@ -288,6 +289,7 @@