2026.06.05 frontend javítgatás és új belépési logika megvalósítva

This commit is contained in:
Roo
2026-06-05 05:46:04 +00:00
parent 59a30ac428
commit 18524a08f2
38 changed files with 3967 additions and 1028 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -68,7 +68,16 @@ async def get_current_user(
# JAVÍTVA: Modern SQLAlchemy 2.0 aszinkron lekérdezés with eager loading # 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 # 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) result = await db.execute(stmt)
user = result.unique().scalar_one_or_none() user = result.unique().scalar_one_or_none()

View File

@@ -1,4 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/auth.py # /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 import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession 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.services.config_service import config
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
from app.api.deps import get_current_user 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 app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone
router = APIRouter() router = APIRouter()
@@ -34,18 +36,71 @@ async def login(
response: Response, response: Response,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(), 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 A remember_me paramétert explicit Form mezőként kell elküldeni, mivel az
OAuth2PasswordRequestForm nem támogatja alapból. 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) user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user: if not user:
raise HTTPException(status_code=401, detail=t("AUTH.INVALID_CREDENTIALS")) 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) 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_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 role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
@@ -82,16 +137,112 @@ async def login(
class VerifyEmailRequest(BaseModel): class VerifyEmailRequest(BaseModel):
token: str = Field(..., description="Email verification token (UUID)") token: str = Field(..., description="Email verification token (UUID)")
device_fingerprint: Optional[str] = Field(None, description="Device fingerprint hash from FingerprintJS")
@router.post("/verify-email") @router.post("/verify-email", response_model=Token)
async def verify_email(request: VerifyEmailRequest, db: AsyncSession = Depends(get_db)): 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) user = await AuthService.verify_email(db, request.token)
if not success: if not user:
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN")) 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): class ForgotPasswordRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím a jelszó visszaállításhoz") 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)): async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
""" """
Elfelejtett jelszó folyamat indítása. 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) 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 { return {
"status": "success", "status": "success",
"message": "Ha ez az email cím regisztrálva van, akkor elküldtünk egy jelszó-visszaállítási linket." "message": "Ha ez az email cím regisztrálva van, akkor elküldtünk egy jelszó-visszaállítási linket."

View File

@@ -1,8 +1,9 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/system_parameters.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/system_parameters.py
import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update from sqlalchemy import select, update, and_
from typing import List, Optional from typing import List, Optional, Dict, Any
from app.api.deps import get_db, get_current_user from app.api.deps import get_db, get_current_user
from app.schemas.system import ( from app.schemas.system import (
@@ -11,9 +12,11 @@ from app.schemas.system import (
SystemParameterCreate, SystemParameterCreate,
) )
from app.models.system import SystemParameter, ParameterScope from app.models.system import SystemParameter, ParameterScope
from app.models.identity.address import GeoPostalCode
from app.models.identity import UserRole from app.models.identity import UserRole
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=List[SystemParameterResponse]) @router.get("/", response_model=List[SystemParameterResponse])
@@ -41,6 +44,40 @@ async def list_system_parameters(
return 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) @router.get("/{key}", response_model=SystemParameterResponse)
async def get_system_parameter( async def get_system_parameter(
key: str, key: str,
@@ -129,4 +166,3 @@ async def update_system_parameter(
await db.commit() await db.commit()
await db.refresh(parameter) await db.refresh(parameter)
return parameter

View File

@@ -1,16 +1,23 @@
#/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py #/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py
import copy
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select, or_ 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 typing import Dict, Any, List, Optional
from datetime import datetime from datetime import datetime
from app.api.deps import get_db, get_current_user from app.api.deps import get_db, get_current_user
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
from app.models.identity import User from app.models.identity import User, Person
from app.models.identity.address import Address
from app.services.trust_engine import TrustEngine 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 app.core.config import settings
from pydantic import BaseModel from pydantic import BaseModel
@@ -33,13 +40,92 @@ class NetworkResponse(BaseModel):
level2: List[NetworkMemberL2L3] level2: List[NetworkMemberL2L3]
level3: 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) @router.get("/me", response_model=UserResponse)
async def read_users_me( async def read_users_me(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Visszaadja a bejelentkezett felhasználó profilját""" """Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
from sqlalchemy import select, or_
from app.models.marketplace.organization import Organization, OrganizationMember from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole from app.models.marketplace.organization import OrgUserRole
@@ -89,34 +175,135 @@ async def read_users_me(
active_org_id = org_row[0] if org_row else None active_org_id = org_row[0] if org_row else None
# Create a response dictionary with the active_organization_id response_data = _build_user_response(current_user, active_org_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
}
return UserResponse.model_validate(response_data) 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") @router.get("/me/trust")
async def get_user_trust( async def get_user_trust(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -175,7 +362,8 @@ async def update_user_preferences(
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Return the Pydantic model instead of raw SQLAlchemy object # 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) @router.patch("/me/active-organization", response_model=UserWithTokenResponse)
@@ -236,12 +424,14 @@ async def update_active_organization(
access_token, _ = create_tokens(data=token_payload) access_token, _ = create_tokens(data=token_payload)
# Return user data with new token # Return user data with new token
response_data = _build_user_response(current_user)
return UserWithTokenResponse( return UserWithTokenResponse(
user=UserResponse.model_validate(current_user), user=UserResponse.model_validate(response_data),
access_token=access_token, access_token=access_token,
token_type="bearer" token_type="bearer"
) )
@router.get("/me/network", response_model=NetworkResponse) @router.get("/me/network", response_model=NetworkResponse)
async def get_my_network( async def get_my_network(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),

View File

@@ -75,8 +75,8 @@ class Settings(BaseSettings):
# --- Email --- # --- Email ---
EMAIL_PROVIDER: str = "auto" EMAIL_PROVIDER: str = "auto"
EMAILS_FROM_EMAIL: str = "info@profibot.hu" EMAILS_FROM_EMAIL: str = "noreply@servicefinder.hu"
EMAILS_FROM_NAME: str = "Profibot" EMAILS_FROM_NAME: str = "ServiceFinder"
SENDGRID_API_KEY: Optional[str] = None SENDGRID_API_KEY: Optional[str] = None
SMTP_HOST: Optional[str] = None SMTP_HOST: Optional[str] = None
@@ -85,7 +85,7 @@ class Settings(BaseSettings):
SMTP_PASSWORD: Optional[str] = None SMTP_PASSWORD: Optional[str] = None
# --- External URLs --- # --- External URLs ---
FRONTEND_BASE_URL: str = "https://dev.servicefinder.hu" FRONTEND_BASE_URL: str = "https://app.servicefinder.hu"
BACKEND_CORS_ORIGINS: List[str] = Field( BACKEND_CORS_ORIGINS: List[str] = Field(
default=[ default=[
# Production domains # Production domains
@@ -130,7 +130,7 @@ class Settings(BaseSettings):
# --- Google OAuth --- # --- Google OAuth ---
GOOGLE_CLIENT_ID: str = "" GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: 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 --- # --- Brute-Force & Security ---
LOGIN_RATE_LIMIT_ANON: str = "5/minute" LOGIN_RATE_LIMIT_ANON: str = "5/minute"

View File

@@ -8,6 +8,8 @@ from .identity import (
ActiveVoucher, ActiveVoucher,
UserTrustProfile, UserTrustProfile,
UserRole, UserRole,
Device,
UserDeviceLink,
) )
from .address import ( from .address import (
@@ -38,6 +40,8 @@ __all__ = [
"ActiveVoucher", "ActiveVoucher",
"UserTrustProfile", "UserTrustProfile",
"UserRole", "UserRole",
"Device",
"UserDeviceLink",
"Address", "Address",
"GeoPostalCode", "GeoPostalCode",
"GeoStreet", "GeoStreet",

View File

@@ -44,9 +44,9 @@ class Address(Base):
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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")) 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_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
street_type: Mapped[str] = mapped_column(String(50), nullable=False) street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
house_number: Mapped[str] = mapped_column(String(50), nullable=False) house_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
stairwell: Mapped[Optional[str]] = mapped_column(String(20)) stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: 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()) 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): class Rating(Base):

View File

@@ -2,17 +2,18 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
import enum import enum
from datetime import datetime from datetime import datetime, timezone
from typing import Any, List, Optional, TYPE_CHECKING from typing import Any, List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t # MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base from app.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from .address import Address
from .organization import Organization, OrganizationMember from .organization import Organization, OrganizationMember
from .asset import VehicleOwnership from .asset import VehicleOwnership
from .gamification import UserStats from .gamification import UserStats
@@ -69,6 +70,14 @@ class Person(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_ghost: Mapped[bool] = mapped_column(Boolean, default=False, 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) 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()) updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -94,6 +103,13 @@ class Person(Base):
nullable=True 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") memberships: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="person")
# Kapcsolat a tulajdonolt szervezetekhez (Organization táblában legal_owner_id) # Kapcsolat a tulajdonolt szervezetekhez (Organization táblában legal_owner_id)
@@ -142,6 +158,10 @@ class User(Base):
scope_id: Mapped[Optional[str]] = mapped_column(String(50)) scope_id: Mapped[Optional[str]] = mapped_column(String(50))
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb")) 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# --- KAPCSOLATOK --- # --- KAPCSOLATOK ---
@@ -192,7 +212,7 @@ class Wallet(Base):
__table_args__ = {"schema": "identity"} __table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 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")) 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")) purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
@@ -263,6 +283,13 @@ class UserTrustProfile(Base):
maintenance_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False) 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) 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) 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( last_calculated: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),
@@ -270,3 +297,52 @@ class UserTrustProfile(Base):
) )
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False) 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)

View File

@@ -29,7 +29,7 @@ class SystemParameter(Base):
category: Mapped[str] = mapped_column(String, server_default="general", index=True) category: Mapped[str] = mapped_column(String, server_default="general", index=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False) 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)) scope_id: Mapped[Optional[str]] = mapped_column(String(50))
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -4,8 +4,8 @@ from typing import Optional, Dict, List
from datetime import date, datetime from datetime import date, datetime
class DocumentDetail(BaseModel): class DocumentDetail(BaseModel):
number: str number: Optional[str] = None
expiry_date: date expiry_date: Optional[date] = None
class ICEContact(BaseModel): class ICEContact(BaseModel):
name: str name: str
@@ -26,7 +26,9 @@ class UserLiteRegister(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class UserKYCComplete(BaseModel): 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}$") phone_number: Optional[str] = Field(default=None, pattern=r"^\+?[0-9]{7,15}$")
birth_place: Optional[str] = None birth_place: Optional[str] = None
birth_date: Optional[date] = None birth_date: Optional[date] = None
@@ -34,19 +36,19 @@ class UserKYCComplete(BaseModel):
mothers_first_name: Optional[str] = None mothers_first_name: Optional[str] = None
region_code: Optional[str] = "HU" 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_zip: str
address_city: str address_city: str
address_street_name: str address_street_name: Optional[str] = None
address_street_type: str # utca, út, tér... address_street_type: Optional[str] = None # utca, út, tér...
address_house_number: str address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None address_stairwell: Optional[str] = None
address_floor: Optional[str] = None address_floor: Optional[str] = None
address_door: Optional[str] = None address_door: Optional[str] = None
address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám
# Okmányok és Vészhelyzet # Okmányok és Vészhelyzet (Opcionális Soft KYC)
identity_docs: Dict[str, DocumentDetail] # pl: {"ID_CARD": {...}, "LICENSE": {...}} identity_docs: Optional[Dict[str, DocumentDetail]] = None # pl: {"ID_CARD": {...}, "LICENSE": {...}}
ice_contact: Optional[ICEContact] = None ice_contact: Optional[ICEContact] = None
preferred_language: str = "hu" preferred_language: str = "hu"

View File

@@ -1,7 +1,46 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py # /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional from typing import Optional, Any
from datetime import date 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): class UserBase(BaseModel):
email: EmailStr email: EmailStr
@@ -10,6 +49,7 @@ class UserBase(BaseModel):
is_active: bool = True is_active: bool = True
region_code: str = "HU" region_code: str = "HU"
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
person_id: Optional[int] = None person_id: Optional[int] = None
@@ -19,17 +59,54 @@ class UserResponse(UserBase):
scope_id: Optional[str] = None scope_id: Optional[str] = None
ui_mode: str = "personal" ui_mode: str = "personal"
active_organization_id: Optional[int] = None 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) 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): class UserUpdate(BaseModel):
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
preferred_language: Optional[str] = None preferred_language: Optional[str] = None
ui_mode: Optional[str] = None ui_mode: Optional[str] = None
class ActiveOrganizationUpdate(BaseModel): class ActiveOrganizationUpdate(BaseModel):
organization_id: Optional[str] = None # UUID/string or None to revert to personal mode organization_id: Optional[str] = None # UUID/string or None to revert to personal mode
class UserWithTokenResponse(BaseModel): class UserWithTokenResponse(BaseModel):
"""User response with new JWT token for organization switching""" """User response with new JWT token for organization switching"""
user: UserResponse user: UserResponse

View File

@@ -93,7 +93,7 @@ class AuthService:
new_person = Person( new_person = Person(
first_name=user_in.first_name, first_name=user_in.first_name,
last_name=user_in.last_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 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 ice_contact={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
lifetime_xp=0, # default -1, de explicit 0 lifetime_xp=0, # default -1, de explicit 0
@@ -117,7 +117,7 @@ class AuthService:
hashed_password=get_password_hash(user_in.password), hashed_password=get_password_hash(user_in.password),
person_id=new_person.id, person_id=new_person.id,
role=assigned_role, role=assigned_role,
is_active=False, is_active=False, # Email verification required before activation
is_deleted=False, is_deleted=False,
region_code=user_in.region_code, region_code=user_in.region_code,
preferred_language=user_in.lang, preferred_language=user_in.lang,
@@ -217,6 +217,8 @@ class AuthService:
user.person_id = shadow_person.id user.person_id = shadow_person.id
# Frissítjük a megtalált Person rekordot a KYC adatokkal # 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_last_name = kyc_in.mothers_last_name
shadow_person.mothers_first_name = kyc_in.mothers_first_name shadow_person.mothers_first_name = kyc_in.mothers_first_name
shadow_person.birth_place = kyc_in.birth_place shadow_person.birth_place = kyc_in.birth_place
@@ -231,6 +233,8 @@ class AuthService:
p = shadow_person p = shadow_person
else: 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_last_name = kyc_in.mothers_last_name
p.mothers_first_name = kyc_in.mothers_first_name p.mothers_first_name = kyc_in.mothers_first_name
p.birth_place = kyc_in.birth_place p.birth_place = kyc_in.birth_place
@@ -314,7 +318,10 @@ class AuthService:
@staticmethod @staticmethod
async def verify_email(db: AsyncSession, token_str: str): 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: try:
token_uuid = uuid.UUID(token_str) token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(and_( stmt = select(VerificationToken).where(and_(
@@ -323,14 +330,15 @@ class AuthService:
VerificationToken.expires_at > datetime.now(timezone.utc) VerificationToken.expires_at > datetime.now(timezone.utc)
)) ))
token = (await db.execute(stmt)).scalar_one_or_none() token = (await db.execute(stmt)).scalar_one_or_none()
if not token: return False if not token: return None
token.is_used = True token.is_used = True
# Activate user # Activate user
user_stmt = select(User).where(User.id == token.user_id) user_stmt = select(User).where(User.id == token.user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none() user = (await db.execute(user_stmt)).scalar_one_or_none()
if user: if not user: return None
user.is_active = True user.is_active = True
# Activate person # Activate person
@@ -340,8 +348,10 @@ class AuthService:
person.is_active = True person.is_active = True
await db.commit() await db.commit()
return True return user # Return the activated User object
except: return False except Exception as e:
logger.error(f"Email verification error: {e}")
return None
@staticmethod @staticmethod
async def initiate_password_reset(db: AsyncSession, email: str): async def initiate_password_reset(db: AsyncSession, email: str):
@@ -349,7 +359,17 @@ class AuthService:
stmt = select(User).where(and_(User.email == email, User.is_deleted == False)) stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none() user = (await db.execute(stmt)).scalar_one_or_none()
if user: 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"
)
# Dinamikus lejárat az adminból # Dinamikus lejárat az adminból
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2) reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4() token_val = uuid.uuid4()
@@ -359,13 +379,23 @@ class AuthService:
)) ))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}" link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email( 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", recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language 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() await db.commit()
return "success" return "success"
return "not_found"
@staticmethod @staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str): async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
@@ -396,6 +426,57 @@ class AuthService:
raise raise
except: return False 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 @staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int): 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. """ """ Felhasználó törlése (Soft-Delete) auditálással. """

View File

@@ -81,7 +81,7 @@ class ConfigService:
Returns: Returns:
A talált érték (a megfelelő típusban) vagy a default. 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: try:
# Convert scope_level to string for comparison - handle both Enum and string # Convert scope_level to string for comparison - handle both Enum and string
@@ -91,12 +91,12 @@ class ConfigService:
scope_str = str(scope_level) scope_str = str(scope_level)
# Build query with case-insensitive comparison for 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 from sqlalchemy import func
query = select(SystemParameter).where( query = select(SystemParameter).where(
and_( and_(
SystemParameter.key == key, 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 SystemParameter.is_active == True
) )
) )

View File

@@ -16,27 +16,12 @@ from app.db.session import AsyncSessionLocal
logger = logging.getLogger("Email-Manager-2.0") logger = logging.getLogger("Email-Manager-2.0")
class EmailManager: 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 @staticmethod
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str: 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) greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
body = locale_manager.get(f"email.{template_key}_body", 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) 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) 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') link = variables.get('link')
if not link and 'token' in variables: if not link:
link = EmailManager._build_verification_link(variables['token']) logger.error(f"No 'link' variable provided for template '{template_key}' (lang={lang})")
return f""" return f"""
<html> <html>
@@ -83,8 +68,14 @@ class EmailManager:
session_internal = True session_internal = True
try: try:
# Check if emails are disabled via DB config # --- FIX: Wrap DB config lookup in try-except with env fallback ---
provider = await config.get_setting(db, "email_provider", default="smtp") # 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": if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}") logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return {"status": "success", "provider": "disabled", "message": "Email disabled by admin config"} 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) html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang) subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
# Get email provider from environment # Determine email provider: DB config > ENV > fallback to 'smtp'
email_provider = os.getenv("EMAIL_PROVIDER", "smtp").lower() 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": if email_provider == "brevo_api":
result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables) result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables)
@@ -243,7 +235,14 @@ class EmailManager:
else: else:
logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}") logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server: with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
# Try STARTTLS, but fall back to plain if server doesn't support it
# (e.g. local Mailpit for testing)
try:
server.starttls() 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: if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass) server.login(smtp_user, smtp_pass)
server.send_message(msg) server.send_message(msg)

View File

@@ -14,10 +14,25 @@
}, },
"EMAIL": { "EMAIL": {
"REG": { "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": { "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"
} }
} }
} }

View File

@@ -19,10 +19,24 @@
"EMAIL": { "EMAIL": {
"REG": { "REG": {
"SUBJECT": "Regisztráció megerősítése - Service Finder", "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": { "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": { "COMMON": {

121
backend/test_email.py Normal file
View File

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

View File

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

View File

@@ -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<string, any> | null` (line 28)
- Added `ice_contact: Record<string, any> | 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

View File

@@ -8,6 +8,7 @@
"name": "service-finder-frontend", "name": "service-finder-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
@@ -566,6 +567,12 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",

View File

@@ -9,6 +9,7 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",

View File

@@ -115,6 +115,9 @@
<!-- Spacer to push right items --> <!-- Spacer to push right items -->
<div class="flex-1"></div> <div class="flex-1"></div>
<!-- Language Switcher (extracted component) -->
<LanguageSwitcher />
<!-- Right: Welcome + User Dropdown --> <!-- Right: Welcome + User Dropdown -->
<div class="relative flex items-center gap-4"> <div class="relative flex items-center gap-4">
<!-- Welcome text --> <!-- Welcome text -->
@@ -157,7 +160,7 @@
<div class="py-1"> <div class="py-1">
<!-- Profile settings --> <!-- Profile settings -->
<button <button
@click="isUserMenuOpen = false" @click="goToProfile"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white" class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
> >
<!-- User icon --> <!-- User icon -->
@@ -213,9 +216,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const props = withDefaults( const props = withDefaults(
@@ -234,8 +240,9 @@ const authStore = useAuthStore()
// ── Dropdown states ──────────────────────────────────────────────── // ── Dropdown states ────────────────────────────────────────────────
const isUserMenuOpen = ref(false) const isUserMenuOpen = ref(false)
const isFuncMenuOpen = ref(false) const isFuncMenuOpen = ref(false)
// LanguageSwitcher handles its own state internally
// ── Close Funkciók menu on outside click ─────────────────────────── // ── Close dropdowns on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) { function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (isFuncMenuOpen.value && !target.closest('.func-menu-container')) { if (isFuncMenuOpen.value && !target.closest('.func-menu-container')) {
@@ -280,6 +287,12 @@ const initials = computed(() => {
return '?' return '?'
}) })
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isUserMenuOpen.value = false
router.push('/profile')
}
// ── Logout handler ───────────────────────────────────────────────── // ── Logout handler ─────────────────────────────────────────────────
function handleLogout() { function handleLogout() {
isUserMenuOpen.value = false isUserMenuOpen.value = false

View File

@@ -0,0 +1,96 @@
<template>
<div class="relative flex items-center lang-switcher-container">
<button
@click.stop="isOpen = !isOpen"
class="flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
:title="t('header.switchLanguage')"
>
<span class="text-base">{{ currentLocale === 'hu' ? '🇭🇺' : '🇬🇧' }}</span>
<span class="hidden sm:inline font-semibold">{{ currentLocale === 'hu' ? 'HU' : 'EN' }}</span>
<!-- Chevron down -->
<svg
class="w-3.5 h-3.5 ml-0.5 text-white/40 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Language Dropdown (Glassmorphism) -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isOpen"
class="absolute left-0 top-full mt-2 w-40 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<div class="py-1">
<!-- Magyar -->
<button
@click="setLanguage('hu')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'hu' }"
>
<span class="text-base">🇭🇺</span>
<span>Magyar</span>
<span v-if="currentLocale === 'hu'" class="ml-auto text-[#70BC84]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</span>
</button>
<!-- English -->
<button
@click="setLanguage('en')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'en' }"
>
<span class="text-base">🇬🇧</span>
<span>English</span>
<span v-if="currentLocale === 'en'" class="ml-auto text-[#70BC84]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const isOpen = ref(false)
const currentLocale = computed(() => locale.value)
function setLanguage(lang: string) {
locale.value = lang
localStorage.setItem('user-locale', lang)
isOpen.value = false
}
// ── Close dropdown on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (isOpen.value && !target.closest('.lang-switcher-container')) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
</script>

View File

@@ -17,7 +17,7 @@
<div <div
class="relative transition-transform duration-700 transform-style-3d" class="relative transition-transform duration-700 transform-style-3d"
:class="{ :class="{
'rotate-y-180': authMode === 'register', 'rotate-y-180': authMode === 'register' || authMode === 'resend',
'rotate-x-180': authMode === 'forgot' 'rotate-x-180': authMode === 'forgot'
}" }"
> >
@@ -50,7 +50,7 @@
<!-- Title --> <!-- Title -->
<h2 class="mb-8 text-center text-2xl font-bold text-white"> <h2 class="mb-8 text-center text-2xl font-bold text-white">
Belépés a garázsba {{ t('auth.loginTitle') }}
</h2> </h2>
<!-- Email Input --> <!-- Email Input -->
@@ -128,23 +128,43 @@
</button> </button>
</div> </div>
<!-- Forgot Password Link (right-aligned, always visible) --> <!-- Remember Me Checkbox -->
<div class="mb-6 text-right"> <div class="mb-4 flex items-center gap-2">
<input
id="remember-me"
v-model="rememberMe"
type="checkbox"
class="h-4 w-4 rounded border-white/20 bg-white/5 text-[#14B8A6] focus:ring-2 focus:ring-[#14B8A6]/50 focus:ring-offset-0 cursor-pointer"
/>
<label for="remember-me" class="text-sm text-white/70 cursor-pointer select-none hover:text-white/90 transition-colors">
{{ t('auth.rememberMe') }}
</label>
</div>
<!-- Symmetric Link Row: Resend (left) + Forgot Password (right) -->
<div class="flex justify-between items-center w-full mt-2 mb-6 text-sm">
<a
href="#"
@click.prevent="switchMode('resend')"
class="text-xs text-[#75A882]/70 hover:text-[#D4AF37] transition-colors"
>
{{ t('auth.resendLink') }}
</a>
<a <a
href="#" href="#"
@click.prevent="switchMode('forgot')" @click.prevent="switchMode('forgot')"
class="text-xs text-[#75A882] hover:text-[#D4AF37] transition-colors" class="text-xs text-[#75A882] hover:text-[#D4AF37] transition-colors"
> >
Elfelejtetted a jelszavad? {{ t('auth.forgotPassword') }}
</a> </a>
</div> </div>
<!-- Error Message --> <!-- Error Message (translated via i18n) -->
<div <div
v-if="authStore.error && authMode === 'login'" v-if="authStore.error && authMode === 'login'"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300" class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
> >
{{ authStore.error }} {{ translateError(authStore.error) }}
</div> </div>
<!-- Login Button (Premium) --> <!-- Login Button (Premium) -->
@@ -153,17 +173,22 @@
:disabled="authStore.isLoading" :disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg" 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') }}
</button> </button>
<!-- Divider --> <!-- Social Login Divider (Premium) -->
<div class="my-6 flex items-center gap-3"> <div class="my-6 flex items-center gap-3">
<span class="flex-1 border-t border-white/10"></span> <span class="flex-1 border-t border-white/10"></span>
<span class="text-sm text-white/40">vagy</span> <span class="flex items-center gap-2 text-sm text-white/40">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{{ t('auth.socialDivider') }}
</span>
<span class="flex-1 border-t border-white/10"></span> <span class="flex-1 border-t border-white/10"></span>
</div> </div>
<!-- Social Login Placeholders --> <!-- Social Login Buttons -->
<div class="mb-6 grid grid-cols-2 gap-3"> <div class="mb-6 grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
@@ -192,13 +217,13 @@
<!-- Registration Link (Triggers Flip) --> <!-- Registration Link (Triggers Flip) -->
<p class="text-center text-sm text-white/50"> <p class="text-center text-sm text-white/50">
Még nincs garázsod? {{ t('auth.noAccount') }}
<a <a
href="#" href="#"
@click.prevent="switchMode('register')" @click.prevent="switchMode('register')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80" class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
> >
Regisztráció {{ t('auth.registerLink') }}
</a> </a>
</p> </p>
</form> </form>
@@ -232,7 +257,7 @@
<!-- Title --> <!-- Title -->
<h2 class="mb-8 text-center text-2xl font-bold text-white"> <h2 class="mb-8 text-center text-2xl font-bold text-white">
Új Garázs Nyitása {{ t('auth.registerTitle') }}
</h2> </h2>
<!-- Last Name Input --> <!-- Last Name Input -->
@@ -343,12 +368,12 @@
</button> </button>
</div> </div>
<!-- Error Message --> <!-- Error Message (translated via i18n) -->
<div <div
v-if="authStore.error && authMode === 'register'" v-if="authStore.error && authMode === 'register'"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300" class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
> >
{{ authStore.error }} {{ translateError(authStore.error) }}
</div> </div>
<!-- Register Button (Premium) --> <!-- Register Button (Premium) -->
@@ -357,18 +382,18 @@
:disabled="authStore.isLoading" :disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg" 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') }}
</button> </button>
<!-- Switch back to Login --> <!-- Switch back to Login -->
<p class="mt-6 text-center text-sm text-white/50"> <p class="mt-6 text-center text-sm text-white/50">
Már van garázsod? {{ t('auth.haveAccount') }}
<a <a
href="#" href="#"
@click.prevent="switchMode('login')" @click.prevent="switchMode('login')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80" class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
> >
Belépés {{ t('auth.loginLink') }}
</a> </a>
</p> </p>
</form> </form>
@@ -402,16 +427,48 @@
<!-- Title --> <!-- Title -->
<h2 class="mb-6 text-center text-2xl font-bold text-white"> <h2 class="mb-6 text-center text-2xl font-bold text-white">
Új jelszó igénylése {{ t('auth.forgotTitle') }}
</h2> </h2>
<!-- Description --> <!-- Description (only show before successful send) -->
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]"> <p
Add meg az e-mail címed, és küldünk egy biztonsági linket. v-if="!forgotSuccess && !isUserInactive"
class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]"
>
{{ t('auth.forgotDescription') }}
</p> </p>
<!-- Email Input --> <!-- Success Message -->
<div class="mb-6"> <div
v-if="forgotSuccess"
class="mb-6 rounded-xl bg-[#70BC84]/20 px-4 py-3 text-center text-sm text-[#70BC84]"
>
{{ t('auth.forgotSuccess') }}
</div>
<!-- Inactive User Message + Resend Activation Button -->
<div
v-if="isUserInactive"
class="mb-6 rounded-xl bg-amber-500/20 px-4 py-4 text-center"
>
<p class="mb-3 text-sm text-amber-300">
{{ t('auth.inactiveMessage') }}
</p>
<button
type="button"
:disabled="authStore.isLoading"
@click="handleResendActivation"
class="rounded-lg bg-[#D4AF37] px-5 py-2 text-sm font-semibold text-[#062535] transition-all hover:bg-[#D4AF37]/90 disabled:opacity-50"
>
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.resendActivationButton') }}
</button>
<p v-if="resendSuccess" class="mt-2 text-xs text-[#70BC84]">
{{ t('auth.resendActivationSuccess') }}
</p>
</div>
<!-- Email Input (hide after successful send) -->
<div v-if="!forgotSuccess && !isUserInactive" class="mb-6">
<label for="forgot-email" class="mb-2 block text-sm font-medium text-white/80"> <label for="forgot-email" class="mb-2 block text-sm font-medium text-white/80">
E-mail E-mail
</label> </label>
@@ -426,31 +483,133 @@
/> />
</div> </div>
<!-- Error Message --> <!-- Error Message (non-inactive errors, translated) -->
<div <div
v-if="authStore.error && authMode === 'forgot'" v-if="forgotError && !isUserInactive"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300" class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
> >
{{ authStore.error }} {{ translateError(forgotError) }}
</div> </div>
<!-- Send Link Button (Premium) --> <!-- Send Link Button (Premium) hide after successful send or inactive -->
<button <button
v-if="!forgotSuccess && !isUserInactive"
type="submit" type="submit"
:disabled="authStore.isLoading" :disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg" class="btn-premium w-full px-4 py-3 text-lg"
> >
{{ authStore.isLoading ? 'Kérlek várj...' : 'Link Küldése' }} {{ authStore.isLoading ? t('auth.loginLoading') : t('auth.forgotSendButton') }}
</button> </button>
<!-- Back to Login --> <!-- Back to Login (always visible) -->
<p class="mt-6 text-center text-sm text-white/50"> <p class="mt-6 text-center text-sm text-white/50">
<a <a
href="#" href="#"
@click.prevent="switchMode('login')" @click.prevent="switchMode('login')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80" class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
> >
Vissza a belépéshez {{ t('auth.backToLogin') }}
</a>
</p>
</form>
<!-- ==================== FOURTH FACE: RESEND ACTIVATION ==================== -->
<form
v-if="activeBackFace === 'resend'"
@submit.prevent="handleResend"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
>
<!-- Close Button (X) -->
<button
type="button"
@click="$emit('close')"
class="absolute right-4 top-4 text-white/60 transition-colors hover:text-white"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<!-- Title -->
<h2 class="mb-6 text-center text-2xl font-bold text-white">
{{ t('auth.resendTitle') }}
</h2>
<!-- Description (only show before successful send) -->
<p
v-if="!resendSuccess"
class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]"
>
{{ t('auth.resendDescription') }}
</p>
<!-- Success Card (green checkmark) -->
<div
v-if="resendSuccess"
class="mb-6 rounded-xl bg-[#70BC84]/20 px-4 py-6 text-center"
>
<div class="mb-3 flex justify-center">
<svg class="h-12 w-12 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-sm font-medium text-[#70BC84]">
{{ t('auth.resendSuccess') }}
</p>
</div>
<!-- Email Input (hide after successful send) -->
<div v-if="!resendSuccess" class="mb-6">
<label for="resend-email" class="mb-2 block text-sm font-medium text-white/80">
E-mail
</label>
<input
id="resend-email"
v-model="email"
type="email"
placeholder="pelda@email.com"
autocomplete="email"
required
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Error Message (translated) -->
<div
v-if="resendError && !resendSuccess"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ translateError(resendError) }}
</div>
<!-- Send Button (Premium) hide after success, disabled during cooldown -->
<button
v-if="!resendSuccess"
type="submit"
:disabled="resendCooldown > 0 || authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? t('auth.loginLoading') : (resendCooldown > 0 ? t('auth.resend_cooldown', { seconds: resendCooldown }) : t('auth.send_email')) }}
</button>
<!-- Back to Login (always visible) -->
<p class="mt-6 text-center text-sm text-white/50">
<a
href="#"
@click.prevent="switchMode('login')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
>
{{ t('auth.backToLogin') }}
</a> </a>
</p> </p>
</form> </form>
@@ -461,9 +620,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import FingerprintJS from '@fingerprintjs/fingerprintjs'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -476,11 +639,32 @@ const emit = defineEmits<{
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
// ── Login form fields ── // ── Device Fingerprinting (FingerprintJS) ──
const email = ref('') let fpPromise: ReturnType<typeof FingerprintJS.load> | null = null
/**
* Generate a device fingerprint hash asynchronously.
* Returns the visitorId string, or null if FingerprintJS fails to load.
*/
async function getDeviceFingerprint(): Promise<string | null> {
try {
if (!fpPromise) {
fpPromise = FingerprintJS.load()
}
const fp = await fpPromise
const result = await fp.get()
return result.visitorId
} catch (err) {
console.warn('FingerprintJS failed to load:', err)
return null
}
}
// ── Login form fields (persisted across modal close) ──
const email = ref(localStorage.getItem('saved-email') || '')
const password = ref('') const password = ref('')
// ── Register form fields ── // ── Register form fields (persisted across modal close) ──
const lastName = ref('') const lastName = ref('')
const firstName = ref('') const firstName = ref('')
const regEmail = ref('') const regEmail = ref('')
@@ -488,16 +672,90 @@ const regPassword = ref('')
// ── Forgot password form fields ── // ── Forgot password form fields ──
const forgotEmail = ref('') const forgotEmail = ref('')
const forgotError = ref('')
const forgotSuccess = ref(false)
const isUserInactive = ref(false)
const resendSuccess = ref(false)
// ── Resend form fields ──
const resendError = ref('')
// ── Resend cooldown (60s spam protection, persisted in localStorage) ──
const resendCooldown = ref(0)
let cooldownTimer: ReturnType<typeof setInterval> | null = null
function startCooldown() {
const expiry = Date.now() + 60000
localStorage.setItem('resend_cooldown_until', expiry.toString())
resendCooldown.value = 60
if (cooldownTimer) clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => {
const remaining = Math.max(0, Math.round((expiry - Date.now()) / 1000))
resendCooldown.value = remaining
if (remaining <= 0) {
if (cooldownTimer) {
clearInterval(cooldownTimer)
cooldownTimer = null
}
localStorage.removeItem('resend_cooldown_until')
}
}, 1000)
}
// ── Password visibility states (hold-to-show) ── // ── Password visibility states (hold-to-show) ──
const showPassword = ref(false) const showPassword = ref(false)
const showRegPassword = ref(false) const showRegPassword = ref(false)
// ── 3D Flip state machine ── // ── Remember Me state (persisted in localStorage) ──
const authMode = ref<'login' | 'register' | 'forgot'>('login') const rememberMe = ref(localStorage.getItem('remember-me-state') === 'true')
const activeBackFace = ref<'register' | 'forgot'>('register')
// ── Reset modal state when it closes ── // ── 3D Flip state machine ──
const authMode = ref<'login' | 'register' | 'forgot' | 'resend'>('login')
const activeBackFace = ref<'register' | 'forgot' | 'resend'>('register')
// ── Restore saved email, remember-me state, and resend cooldown on mount ──
onMounted(() => {
const saved = localStorage.getItem('saved-email')
if (saved) {
email.value = saved
}
// Restore remember-me checkbox state
rememberMe.value = localStorage.getItem('remember-me-state') === 'true'
// Restore resend cooldown from localStorage (survives page refresh)
const storedExpiry = localStorage.getItem('resend_cooldown_until')
if (storedExpiry) {
const remaining = Math.max(0, Math.round((parseInt(storedExpiry, 10) - Date.now()) / 1000))
if (remaining > 0) {
resendCooldown.value = remaining
// Resume the countdown
if (cooldownTimer) clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => {
const rem = Math.max(0, Math.round((parseInt(storedExpiry, 10) - Date.now()) / 1000))
resendCooldown.value = rem
if (rem <= 0) {
if (cooldownTimer) {
clearInterval(cooldownTimer)
cooldownTimer = null
}
localStorage.removeItem('resend_cooldown_until')
}
}, 1000)
} else {
localStorage.removeItem('resend_cooldown_until')
}
}
})
// ── Clean up interval on unmount ──
onUnmounted(() => {
if (cooldownTimer) {
clearInterval(cooldownTimer)
cooldownTimer = null
}
})
// ── On modal close: keep form fields, just reset view and clear errors ──
watch( watch(
() => props.visible, () => props.visible,
(newVal) => { (newVal) => {
@@ -505,14 +763,11 @@ watch(
// Reset view to login // Reset view to login
authMode.value = 'login' authMode.value = 'login'
activeBackFace.value = 'register' activeBackFace.value = 'register'
// Clear all form fields forgotError.value = ''
email.value = '' forgotSuccess.value = false
password.value = '' isUserInactive.value = false
lastName.value = '' resendSuccess.value = false
firstName.value = '' resendError.value = ''
regEmail.value = ''
regPassword.value = ''
forgotEmail.value = ''
// Clear store error // Clear store error
authStore.clearError() authStore.clearError()
} }
@@ -524,17 +779,44 @@ watch(authMode, () => {
authStore.clearError() authStore.clearError()
}) })
function switchMode(mode: 'login' | 'register' | 'forgot') { function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend') {
if (mode !== 'login') { if (mode !== 'login') {
activeBackFace.value = mode activeBackFace.value = mode
} }
authMode.value = mode authMode.value = mode
} }
// ── Smart Login Logic ── /**
* Translate an error value that may be an i18n key (e.g. 'auth.invalid_credentials')
* or a raw string. If it starts with 'auth.', treat it as an i18n key and translate it.
* Otherwise, return the raw string as-is.
*/
function translateError(msg: string): string {
if (!msg) return ''
if (msg.startsWith('auth.')) {
const translated = t(msg)
// If translation returns the key itself (missing), fall back to raw
if (translated !== msg) return translated
}
return msg
}
// ── Smart Login Logic (with Device Fingerprinting) ──
async function handleLogin() { async function handleLogin() {
try { try {
await authStore.login(email.value, password.value) // Generate device fingerprint asynchronously (non-blocking)
const deviceFingerprint = await getDeviceFingerprint()
await authStore.login(email.value, password.value, deviceFingerprint || undefined)
// Persist remember-me state and email
if (rememberMe.value) {
localStorage.setItem('remember-me-state', 'true')
localStorage.setItem('saved-email', email.value)
} else {
localStorage.setItem('remember-me-state', 'false')
localStorage.removeItem('saved-email')
}
// After successful login, check KYC status // After successful login, check KYC status
if (authStore.isKycComplete) { if (authStore.isKycComplete) {
@@ -546,7 +828,7 @@ async function handleLogin() {
// Close the modal // Close the modal
emit('close') emit('close')
} catch { } catch {
// Error is already set in authStore.error — UI displays it automatically // Error is already set in authStore.error — UI displays it automatically via translateError
} }
} }
@@ -560,15 +842,12 @@ async function handleRegister() {
last_name: lastName.value, last_name: lastName.value,
}) })
// Registration successful — flip back to login so user can sign in // Save email to localStorage for next visit
switchMode('login') localStorage.setItem('saved-email', regEmail.value)
// Pre-fill the email field
email.value = regEmail.value // Registration successful — redirect to email verification page
// Clear register fields emit('close')
lastName.value = '' router.push('/verify')
firstName.value = ''
regEmail.value = ''
regPassword.value = ''
} catch { } catch {
// Error is already set in authStore.error // Error is already set in authStore.error
} }
@@ -576,16 +855,54 @@ async function handleRegister() {
// ── Forgot Password Logic ── // ── Forgot Password Logic ──
async function handleForgotPassword() { async function handleForgotPassword() {
forgotError.value = ''
isUserInactive.value = false
resendSuccess.value = false
try { try {
await authStore.forgotPassword(forgotEmail.value) await authStore.forgotPassword(forgotEmail.value)
// Success — flip back to login // Success — show green success message and "Back to login" button
switchMode('login') forgotSuccess.value = true
forgotEmail.value = '' forgotEmail.value = ''
} catch {
const errMsg = authStore.error || ''
// Check if the error is about inactive user
if (errMsg === 'auth.user_inactive') {
isUserInactive.value = true
forgotError.value = errMsg
} else {
// Show error message from the store (already an i18n key or raw string)
forgotError.value = errMsg || 'auth.forgotFailed'
}
}
}
// ── Resend Activation Email Logic (from forgot view) ──
async function handleResendActivation() {
resendSuccess.value = false
try {
await authStore.resendVerification(forgotEmail.value)
resendSuccess.value = true
} catch { } catch {
// Error is already set in authStore.error // Error is already set in authStore.error
} }
} }
// ── Resend Verification Email (from resend view) ──
async function handleResend() {
resendError.value = ''
resendSuccess.value = false
try {
await authStore.resendVerification(email.value)
// Success — show green success card
resendSuccess.value = true
// Start 60s cooldown ONLY after successful send
startCooldown()
} catch {
const errMsg = authStore.error || ''
resendError.value = errMsg || 'auth.resendFailed'
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -13,6 +13,7 @@ export default {
profile: 'Profile Settings', profile: 'Profile Settings',
logout: 'Logout', logout: 'Logout',
subtitle: 'Your dashboard and vehicle overview', subtitle: 'Your dashboard and vehicle overview',
switchLanguage: 'Switch language',
}, },
dashboard: { dashboard: {
loading: 'Loading...', loading: 'Loading...',
@@ -26,4 +27,47 @@ export default {
hide: 'Hide UI (Zen mode)', hide: 'Hide UI (Zen mode)',
show: 'Show UI', 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.',
},
} }

View File

@@ -13,6 +13,7 @@ export default {
profile: 'Profil beállítások', profile: 'Profil beállítások',
logout: 'Kilépés', logout: 'Kilépés',
subtitle: 'Irányítópultod és járműveid áttekintése', subtitle: 'Irányítópultod és járműveid áttekintése',
switchLanguage: 'Nyelv váltása',
}, },
dashboard: { dashboard: {
loading: 'Betöltés...', loading: 'Betöltés...',
@@ -26,4 +27,47 @@ export default {
hide: 'UI elrejtése (Zen mód)', hide: 'UI elrejtése (Zen mód)',
show: 'UI megjelenítése', 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.',
},
} }

View File

@@ -12,9 +12,31 @@ const messages = {
en, 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({ const i18n = createI18n({
legacy: false, legacy: false,
locale: 'hu', locale: initialLocale,
fallbackLocale: 'en', fallbackLocale: 'en',
messages, messages,
}) })

View File

@@ -20,22 +20,48 @@ const router = createRouter({
name: 'complete-kyc', name: 'complete-kyc',
// Lazy-loaded — placeholder until the actual KYC view is built // Lazy-loaded — placeholder until the actual KYC view is built
component: () => import('../views/CompleteKycView.vue') 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 ────────────────────────────────────────── // ── Global navigation guard ──────────────────────────────────────────
// If an authenticated user navigates to the root ("/"), redirect to "/dashboard". // 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) => { router.beforeEach(async (to, _from, next) => {
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 { useAuthStore } = await import('../stores/auth')
const authStore = useAuthStore() const authStore = useAuthStore()
// Redirect authenticated users away from landing
if (to.path === '/') {
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
return next('/dashboard') return next('/dashboard')
} }
} }
// Protect routes that require authentication
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return next('/')
}
next() next()
}) })

View File

@@ -3,6 +3,34 @@ import { ref, computed } from 'vue'
import router from '../router' import router from '../router'
import api from '../api/axios' 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<string, any> | null
ice_contact: Record<string, any> | null
is_active: boolean
address: AddressData | null
}
export interface UserProfile { export interface UserProfile {
id: number id: number
email: string email: string
@@ -16,8 +44,11 @@ export interface UserProfile {
scope_id: string | null scope_id: string | null
ui_mode: string ui_mode: string
active_organization_id: number | null active_organization_id: number | null
preferred_language: string
// person_id is set when KYC is complete (Person record exists) // person_id is set when KYC is complete (Person record exists)
person_id: number | null person_id: number | null
// Nested person data with address
person: PersonData | null
} }
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@@ -42,14 +73,64 @@ export const useAuthStore = defineStore('auth', () => {
}) })
const userId = computed(() => user.value?.id ?? null) 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<string, string> = {
'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 ──────────────────────────── // ────────────────────────────── Actions ────────────────────────────
/** /**
* Login with email + password. * Login with email + password.
* The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm, * The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm,
* which expects application/x-www-form-urlencoded with `username` and `password`. * 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 isLoading.value = true
error.value = null error.value = null
@@ -57,6 +138,9 @@ export const useAuthStore = defineStore('auth', () => {
const body = new URLSearchParams() const body = new URLSearchParams()
body.append('username', email) // FastAPI OAuth2 form uses `username` key body.append('username', email) // FastAPI OAuth2 form uses `username` key
body.append('password', password) body.append('password', password)
if (deviceFingerprint) {
body.append('device_fingerprint', deviceFingerprint)
}
const res = await api.post('/auth/login', body, { const res = await api.post('/auth/login', body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -78,12 +162,16 @@ export const useAuthStore = defineStore('auth', () => {
// Immediately fetch the user profile // Immediately fetch the user profile
await fetchUser() 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) { } catch (err: any) {
const message = error.value = getErrorMessage(err, 'auth.loginFailed')
err.response?.data?.detail ||
err.response?.data?.message ||
'Bejelentkezés sikertelen. Ellenőrizd az adataidat.'
error.value = message
throw err throw err
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -132,6 +220,9 @@ export const useAuthStore = defineStore('auth', () => {
const res = await api.post('/auth/register', payload) 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 { return res.data as {
status: string status: string
message: string message: string
@@ -139,11 +230,26 @@ export const useAuthStore = defineStore('auth', () => {
email: string email: string
} }
} catch (err: any) { } catch (err: any) {
const message = error.value = getErrorMessage(err, 'auth.registerFailed')
err.response?.data?.detail || throw err
err.response?.data?.message || } finally {
'Regisztráció sikertelen.' isLoading.value = false
error.value = message }
}
/**
* 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 throw err
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -161,11 +267,35 @@ export const useAuthStore = defineStore('auth', () => {
const res = await api.post('/auth/forgot-password', { email }) const res = await api.post('/auth/forgot-password', { email })
return res.data as { status: string; message: string } return res.data as { status: string; message: string }
} catch (err: any) { } catch (err: any) {
const message = const code = extractErrorCode(err)
err.response?.data?.detail || if (code === 'AUTH.USER_INACTIVE') {
err.response?.data?.message || error.value = 'auth.user_inactive'
'Hiba történt a jelszó-visszaállítási kérelem elküldésekor.' } else {
error.value = message 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 throw err
} finally { } finally {
isLoading.value = false 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<string, any> = { 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. * Complete KYC (Know Your Customer) — full profile submission.
* POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile. * POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile.
@@ -213,11 +393,52 @@ export const useAuthStore = defineStore('auth', () => {
await fetchUser() await fetchUser()
return true return true
} catch (err: any) { } catch (err: any) {
const message = error.value = getErrorMessage(err, 'auth.kycFailed')
err.response?.data?.detail || throw err
err.response?.data?.message || } finally {
'A KYC kitöltése sikertelen. Kérlek ellenőrizd az adatokat.' isLoading.value = false
error.value = message }
}
/**
* 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<string, any>): Promise<boolean> {
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 throw err
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -249,8 +470,13 @@ export const useAuthStore = defineStore('auth', () => {
login, login,
fetchUser, fetchUser,
register, register,
resendVerification,
verifyAccount,
forgotPassword, forgotPassword,
resetPassword,
completeKyc, completeKyc,
updatePerson,
changePassword,
logout, logout,
init, init,
clearError, clearError,

View File

@@ -79,10 +79,10 @@
</div> </div>
</div> </div>
<!-- ==================== STEP 2: Address ==================== --> <!-- ==================== STEP 2: Address (Soft KYC) ==================== -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 2">
<h2 class="text-xl font-semibold text-white mb-1">Lakcím adatok</h2> <h2 class="text-xl font-semibold text-white mb-1">Lakcím adatok</h2>
<p class="text-white/40 text-sm mb-6">Pontos cím a garázs és flotta kezeléshez</p> <p class="text-white/40 text-sm mb-6">Csak az irányítószám és város kötelező <span class="text-[#D4AF37]">(Soft KYC)</span></p>
<div class="space-y-4"> <div class="space-y-4">
<!-- Country selector (EU-ready) --> <!-- Country selector (EU-ready) -->
@@ -141,7 +141,7 @@
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-sm text-white/60 mb-1">Utca neve</label> <label class="block text-sm text-white/60 mb-1">Utca neve <span class="text-white/20">(opcionális)</span></label>
<input <input
v-model="kycForm.address_street_name" v-model="kycForm.address_street_name"
type="text" type="text"
@@ -150,7 +150,7 @@
/> />
</div> </div>
<div> <div>
<label class="block text-sm text-white/60 mb-1">Típus</label> <label class="block text-sm text-white/60 mb-1">Típus <span class="text-white/20">(opcionális)</span></label>
<select <select
v-model="kycForm.address_street_type" v-model="kycForm.address_street_type"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
@@ -171,12 +171,13 @@
</div> </div>
<div> <div>
<label class="block text-sm text-white/60 mb-1">Házszám</label> <label class="block text-sm text-white/60 mb-1">Házszám <span class="text-white/20">(opcionális)</span></label>
<input <input
v-model="kycForm.address_house_number" v-model="kycForm.address_house_number"
type="text" type="text"
placeholder="10/A" placeholder="10/A"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
@keydown.tab.exact="handleHouseNumberTab"
/> />
</div> </div>
</div> </div>
@@ -185,7 +186,7 @@
<!-- ==================== STEP 3: Security ==================== --> <!-- ==================== STEP 3: Security ==================== -->
<div v-if="currentStep === 3"> <div v-if="currentStep === 3">
<h2 class="text-xl font-semibold text-white mb-1">Okmányok</h2> <h2 class="text-xl font-semibold text-white mb-1">Okmányok</h2>
<p class="text-white/40 text-sm mb-6">Személyi igazolvány és jogosítvány adatok</p> <p class="text-white/40 text-sm mb-6">Személyi igazolvány és jogosítvány adatok <span class="text-[#D4AF37]">(opcionális)</span></p>
<div class="space-y-6"> <div class="space-y-6">
<!-- Identity Documents --> <!-- Identity Documents -->
@@ -193,8 +194,8 @@
<!-- ID Card --> <!-- ID Card -->
<div class="bg-white/5 rounded-lg p-4 mb-3 border border-white/5"> <div class="bg-white/5 rounded-lg p-4 mb-3 border border-white/5">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-[#00E5A0]" /> <div class="w-2 h-2 rounded-full bg-white/20" />
<span class="text-sm text-white/80">Személyi igazolvány</span> <span class="text-sm text-white/50">Személyi igazolvány <span class="text-white/20">(opcionális)</span></span>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
@@ -288,6 +289,7 @@
<button <button
v-if="currentStep < 3" v-if="currentStep < 3"
@click="nextStep" @click="nextStep"
ref="nextButton"
class="px-6 py-2.5 rounded-lg bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/20 transition-all text-sm" class="px-6 py-2.5 rounded-lg bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/20 transition-all text-sm"
> >
Tovább → Tovább →
@@ -330,7 +332,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -348,6 +350,7 @@ const currentStep = ref(1)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null) const errorMessage = ref<string | null>(null)
const isCityLoading = ref(false) const isCityLoading = ref(false)
const nextButton = ref<HTMLButtonElement | null>(null)
// ── KYC Form (minimal — only DB-required fields) ── // ── KYC Form (minimal — only DB-required fields) ──
const kycForm = reactive({ const kycForm = reactive({
@@ -367,7 +370,7 @@ const kycForm = reactive({
preferred_currency: 'HUF', preferred_currency: 'HUF',
}) })
// ── Debounced ZIP → City auto-fill via zippopotam.us ── // ── Debounced ZIP → City auto-fill via internal API ──
let zipTimeout: ReturnType<typeof setTimeout> let zipTimeout: ReturnType<typeof setTimeout>
watch( watch(
() => kycForm.address_zip, () => kycForm.address_zip,
@@ -377,11 +380,11 @@ watch(
isCityLoading.value = true isCityLoading.value = true
zipTimeout = setTimeout(async () => { zipTimeout = setTimeout(async () => {
try { try {
const country = kycForm.region_code.toLowerCase() const country = kycForm.region_code || 'HU'
const response = await fetch(`https://api.zippopotam.us/${country}/${newZip}`) const response = await fetch(`/api/v1/system/zip-lookup?country_code=${country}&zip_code=${newZip}`)
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
kycForm.address_city = data.places[0]['place name'] kycForm.address_city = data.city
} }
} catch (error) { } catch (error) {
console.error('Cím lekérdezési hiba:', error) console.error('Cím lekérdezési hiba:', error)
@@ -407,7 +410,7 @@ function stepCircleClass(idx: number): string {
return 'bg-white/5 text-white/30 border border-white/10' return 'bg-white/5 text-white/30 border border-white/10'
} }
// ── Simple step validation ── // ── Simple step validation (Soft KYC) ──
function validateStep(): boolean { function validateStep(): boolean {
errorMessage.value = null errorMessage.value = null
@@ -419,15 +422,10 @@ function validateStep(): boolean {
if (currentStep.value === 2) { if (currentStep.value === 2) {
if (!kycForm.address_zip) { errorMessage.value = 'Kérlek add meg az irányítószámot.'; return false } if (!kycForm.address_zip) { errorMessage.value = 'Kérlek add meg az irányítószámot.'; return false }
if (!kycForm.address_city) { errorMessage.value = 'Kérlek add meg a várost.'; return false } if (!kycForm.address_city) { errorMessage.value = 'Kérlek add meg a várost.'; return false }
if (!kycForm.address_street_name) { errorMessage.value = 'Kérlek add meg az utca nevét.'; return false } // Soft KYC: Utca, típus, házszám már NEM kötelező
if (!kycForm.address_street_type) { errorMessage.value = 'Kérlek válaszd ki az utca típusát.'; return false }
if (!kycForm.address_house_number) { errorMessage.value = 'Kérlek add meg a házszámot.'; return false }
} }
if (currentStep.value === 3) { // Step 3 has no required validations anymore (Soft KYC - all optional)
if (!kycForm.identity_docs.ID_CARD.number) { errorMessage.value = 'Kérlek add meg a személyi igazolvány számát.'; return false }
if (!kycForm.identity_docs.ID_CARD.expiry_date) { errorMessage.value = 'Kérlek add meg a személyi lejárati dátumát.'; return false }
}
return true return true
} }
@@ -447,6 +445,16 @@ function prevStep() {
} }
} }
// ── Tab order fix: Házszám → Tovább gomb ──
function handleHouseNumberTab(event: KeyboardEvent) {
if (currentStep.value === 2 && nextButton.value) {
event.preventDefault()
nextTick(() => {
nextButton.value?.focus()
})
}
}
// ── Submit ── // ── Submit ──
async function handleSubmit() { async function handleSubmit() {
if (!validateStep()) return if (!validateStep()) return
@@ -455,34 +463,42 @@ async function handleSubmit() {
errorMessage.value = null errorMessage.value = null
try { try {
// Build the minimal payload matching UserKYCComplete schema // Build the payload matching UserKYCComplete schema (Soft KYC)
const payload = { const payload: any = {
last_name: kycForm.last_name,
first_name: kycForm.first_name, first_name: kycForm.first_name,
last_name: kycForm.last_name,
region_code: kycForm.region_code, region_code: kycForm.region_code,
address_zip: kycForm.address_zip, address_zip: kycForm.address_zip,
address_city: kycForm.address_city, address_city: kycForm.address_city,
address_street_name: kycForm.address_street_name,
address_street_type: kycForm.address_street_type,
address_house_number: kycForm.address_house_number,
identity_docs: {
ID_CARD: {
number: kycForm.identity_docs.ID_CARD.number,
expiry_date: kycForm.identity_docs.ID_CARD.expiry_date,
},
...(kycForm.identity_docs.LICENSE.number
? {
LICENSE: {
number: kycForm.identity_docs.LICENSE.number,
expiry_date: kycForm.identity_docs.LICENSE.expiry_date,
},
}
: {}),
},
preferred_language: kycForm.preferred_language, preferred_language: kycForm.preferred_language,
preferred_currency: kycForm.preferred_currency, preferred_currency: kycForm.preferred_currency,
} }
// Soft KYC: Csak akkor küldjük a részletes címet, ha ki van töltve
if (kycForm.address_street_name) payload.address_street_name = kycForm.address_street_name
if (kycForm.address_street_type) payload.address_street_type = kycForm.address_street_type
if (kycForm.address_house_number) payload.address_house_number = kycForm.address_house_number
// Only include identity_docs if at least one document is filled
const hasIdCard = kycForm.identity_docs.ID_CARD.number || kycForm.identity_docs.ID_CARD.expiry_date
const hasLicense = kycForm.identity_docs.LICENSE.number || kycForm.identity_docs.LICENSE.expiry_date
if (hasIdCard || hasLicense) {
payload.identity_docs = {}
if (hasIdCard) {
payload.identity_docs.ID_CARD = {
number: kycForm.identity_docs.ID_CARD.number || null,
expiry_date: kycForm.identity_docs.ID_CARD.expiry_date || null,
}
}
if (hasLicense) {
payload.identity_docs.LICENSE = {
number: kycForm.identity_docs.LICENSE.number || null,
expiry_date: kycForm.identity_docs.LICENSE.expiry_date || null,
}
}
}
await authStore.completeKyc(payload) await authStore.completeKyc(payload)
// Navigate to dashboard on success // Navigate to dashboard on success

View File

@@ -3,6 +3,22 @@
<!-- Garage Background Image (no dark overlay bright, light garage) --> <!-- Garage Background Image (no dark overlay bright, light garage) -->
<div class="fixed inset-0 z-0"> <div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div> <div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
<!-- 3D Wall Text: Szerviznaptár (left wall calendar area) -->
<div
class="absolute top-[18%] left-[12%] text-2xl font-bold text-[#04151F] opacity-80 uppercase pointer-events-none"
style="transform: perspective(500px) rotateY(2deg) rotateX(-1deg);"
>
{{ t('menu.calendar') }}
</div>
<!-- 3D Wall Text: SERVICE FINDER (center wall logo area) -->
<div
class="absolute top-[35%] left-[45%] text-4xl font-extrabold text-[#418890] tracking-widest opacity-90 pointer-events-none"
style="transform: perspective(800px) rotateY(-5deg);"
>
SERVICE FINDER
</div>
</div> </div>
<!-- Spatial UI Layer: Hotspots (always visible, even in Zen mode) --> <!-- Spatial UI Layer: Hotspots (always visible, even in Zen mode) -->
@@ -137,15 +153,15 @@
</svg> </svg>
</button> </button>
<!-- Content (above the overlay) with fade transition --> <!-- Top Navbar (always visible, even in Zen mode) -->
<Transition name="fade">
<div v-if="isUiVisible" class="relative z-10">
<!-- Top Navbar -->
<DashboardHeader <DashboardHeader
:first-name="authStore.user?.first_name || 'Vendég'" :first-name="authStore.user?.first_name || 'Vendég'"
subtitle="Irányítópultod és járműveid áttekintése" subtitle="Irányítópultod és járműveid áttekintése"
/> />
<!-- Content (above the overlay) with fade transition -->
<Transition name="fade">
<div v-if="isUiVisible" class="relative z-10">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Loading State --> <!-- Loading State -->
<div <div
@@ -250,10 +266,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle' import { useVehicleStore } from '../stores/vehicle'
import DashboardHeader from '../components/DashboardHeader.vue' import DashboardHeader from '../components/DashboardHeader.vue'
const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const vehicleStore = useVehicleStore() const vehicleStore = useVehicleStore()

View File

@@ -17,7 +17,9 @@
<span class="text-2xl font-extrabold tracking-wider hidden sm:block"><span class="text-white">SERVICE</span> <span class="text-[#418890]">FINDER</span></span> <span class="text-2xl font-extrabold tracking-wider hidden sm:block"><span class="text-white">SERVICE</span> <span class="text-[#418890]">FINDER</span></span>
</router-link> </router-link>
<!-- Right: "Garázs Nyitása" Button (Premium) --> <!-- Right: Language Switcher + "Garázs Nyitása" Button (Premium) -->
<div class="flex items-center gap-3">
<LanguageSwitcher />
<button <button
@click="showLogin = true" @click="showLogin = true"
class="btn-premium px-6 py-2 text-sm" class="btn-premium px-6 py-2 text-sm"
@@ -25,6 +27,7 @@
Garázs Nyitása Garázs Nyitása
</button> </button>
</div> </div>
</div>
</header> </header>
<!-- Hero Section: 2-Column Grid with JS Parallax --> <!-- Hero Section: 2-Column Grid with JS Parallax -->
@@ -168,6 +171,7 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import Logo from '@/components/Logo.vue' import Logo from '@/components/Logo.vue'
import LoginModal from '@/components/LoginModal.vue' import LoginModal from '@/components/LoginModal.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const showLogin = ref(false) const showLogin = ref(false)
const scrollY = ref(0) const scrollY = ref(0)

View File

@@ -0,0 +1,784 @@
<template>
<div class="relative min-h-screen text-white">
<!-- Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Header -->
<DashboardHeader
:first-name="authStore.user?.person?.first_name || authStore.user?.first_name || 'Vendég'"
subtitle="Profil beállítások"
/>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Account Info Card -->
<div
class="relative mb-8 rounded-2xl border border-white/10 bg-black/40 p-6 backdrop-blur-xl shadow-xl shadow-black/20"
>
<!-- Close (X) Button -->
<button
type="button"
@click="router.push('/dashboard')"
class="absolute right-4 top-4 text-white/40 transition-colors duration-200 hover:text-red-400 cursor-pointer"
aria-label="Bezárás"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<h2 class="text-xl font-bold text-white">Fiók Adatok</h2>
</div>
<!-- Email (readonly) -->
<div class="mb-4">
<label class="mb-2 block text-sm text-white/50">Email</label>
<input
:value="authStore.user?.email || ''"
type="email"
readonly
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm cursor-not-allowed"
/>
</div>
<!-- Change Password Button -->
<button
@click="showPasswordModal = true"
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0] px-6 py-2.5 text-sm font-semibold text-black transition-all duration-200 hover:bg-[#00E5A0]/90 cursor-pointer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Jelszó módosítása
</button>
</div>
<!-- Personal Data (KYC) Card -->
<div
class="rounded-2xl border border-white/10 bg-black/40 p-6 backdrop-blur-xl shadow-xl shadow-black/20"
>
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h2 class="text-xl font-bold text-white">Személyes Adatok (KYC)</h2>
</div>
<!-- Edit/Save Buttons -->
<div class="flex justify-end gap-3 mb-4">
<button
v-if="!isEditing"
@click="startEditing"
class="inline-flex items-center gap-2 rounded-xl border border-[#00E5A0]/40 bg-[#00E5A0]/10 px-4 py-2 text-sm text-[#00E5A0] transition-all duration-200 hover:bg-[#00E5A0]/20 cursor-pointer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Szerkesztés
</button>
<template v-if="isEditing">
<button
@click="cancelEditing"
class="rounded-xl border border-white/20 bg-white/5 px-4 py-2 text-sm text-white/70 transition-all duration-200 hover:bg-white/10 cursor-pointer"
>
Mégse
</button>
<button
@click="savePerson"
:disabled="isSaving"
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0] px-4 py-2 text-sm text-black font-semibold transition-all duration-200 hover:bg-[#00E5A0]/90 disabled:opacity-50 cursor-pointer"
>
<svg v-if="isSaving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Mentés
</button>
</template>
</div>
<!-- Person Fields -->
<div class="grid grid-cols-1 gap-4 mb-4 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm text-white/50">Vezetéknév</label>
<input
v-if="isEditing"
v-model="editForm.last_name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Vezetéknév"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ person?.last_name || '—' }}
</div>
</div>
<div>
<label class="mb-2 block text-sm text-white/50">Keresztnév</label>
<input
v-if="isEditing"
v-model="editForm.first_name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Keresztnév"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ person?.first_name || '—' }}
</div>
</div>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm text-white/50">Telefonszám</label>
<input
v-if="isEditing"
v-model="editForm.phone"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="+36 20 123 4567"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ person?.phone || '—' }}
</div>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm text-white/50">Születési hely</label>
<input
v-if="isEditing"
v-model="editForm.birth_place"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Születési hely"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ person?.birth_place || '—' }}
</div>
</div>
<div>
<label class="mb-2 block text-sm text-white/50">Születési dátum</label>
<input
v-if="isEditing"
v-model="editForm.birth_date"
type="date"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ formatDate(person?.birth_date) || '—' }}
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm text-white/50">Anyja vezetékneve</label>
<input
v-if="isEditing"
v-model="editForm.mothers_last_name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Anyja vezetékneve"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ person?.mothers_last_name || '—' }}
</div>
</div>
<div>
<label class="mb-2 block text-sm text-white/50">Anyja keresztneve</label>
<input
v-if="isEditing"
v-model="editForm.mothers_first_name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Anyja keresztneve"
/>
<div v-else class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium">
{{ person?.mothers_first_name || '—' }}
</div>
</div>
</div>
<!-- Identity Docs Section -->
<div class="mb-4 p-4 rounded-xl border border-white/[0.06] bg-white/[0.03]">
<h3 class="text-sm font-semibold text-white/70 mb-3">Okmányok (Identity Docs)</h3>
<div v-if="isEditing" class="space-y-3">
<!-- ID Card -->
<div class="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<h4 class="text-xs font-semibold text-white/60 mb-2 uppercase tracking-wider">Személyi Igazolvány (ID_CARD)</h4>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs text-white/50">Szám</label>
<input
v-model="editForm.id_card_number"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Személyi ig. szám"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Lejárat</label>
<input
v-model="editForm.id_card_expiry"
type="date"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
/>
</div>
</div>
</div>
<!-- Driving License -->
<div class="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<h4 class="text-xs font-semibold text-white/60 mb-2 uppercase tracking-wider">Jogosítvány (LICENSE)</h4>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs text-white/50">Szám</label>
<input
v-model="editForm.license_number"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Jogosítvány szám"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Lejárat</label>
<input
v-model="editForm.license_expiry"
type="date"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
/>
</div>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs text-white/50">Kategóriák</label>
<input
v-model="editForm.license_categories"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Pl. A, B, C"
/>
</div>
</div>
</div>
<div v-else class="space-y-3">
<!-- ID Card (read-only) -->
<div class="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<h4 class="text-xs font-semibold text-white/60 mb-2 uppercase tracking-wider">Személyi Igazolvány (ID_CARD)</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-white/50">Szám:</span>
<span class="ml-2 text-white font-medium">{{ identityDocs?.ID_CARD?.number || '—' }}</span>
</div>
<div>
<span class="text-white/50">Lejárat:</span>
<span class="ml-2 text-white font-medium">{{ formatDate(identityDocs?.ID_CARD?.expiry_date) || '—' }}</span>
</div>
</div>
</div>
<!-- Driving License (read-only) -->
<div class="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<h4 class="text-xs font-semibold text-white/60 mb-2 uppercase tracking-wider">Jogosítvány (LICENSE)</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-white/50">Szám:</span>
<span class="ml-2 text-white font-medium">{{ identityDocs?.LICENSE?.number || '—' }}</span>
</div>
<div>
<span class="text-white/50">Lejárat:</span>
<span class="ml-2 text-white font-medium">{{ formatDate(identityDocs?.LICENSE?.expiry_date) || '—' }}</span>
</div>
</div>
<div class="mt-2 text-sm">
<span class="text-white/50">Kategóriák:</span>
<span class="ml-2 text-white font-medium">{{ identityDocs?.LICENSE?.categories || '—' }}</span>
</div>
</div>
</div>
</div>
<!-- Address Section -->
<div class="mb-4 p-4 rounded-xl border border-white/[0.06] bg-white/[0.03]">
<h3 class="text-sm font-semibold text-white/70 mb-3">Lakcím adatok</h3>
<div v-if="isEditing" class="space-y-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs text-white/50">Irányítószám</label>
<input
v-model="editForm.address_zip"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Irányítószám"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Város</label>
<input
v-model="editForm.address_city"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Város"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label class="mb-1 block text-xs text-white/50">Utca</label>
<input
v-model="editForm.address_street_name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Utca"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Közterület jellege</label>
<input
v-model="editForm.address_street_type"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="utca, út, tér..."
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Házszám</label>
<input
v-model="editForm.address_house_number"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Házszám"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label class="mb-1 block text-xs text-white/50">Lépcsőház</label>
<input
v-model="editForm.address_stairwell"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Lépcsőház"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Emelet</label>
<input
v-model="editForm.address_floor"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Emelet"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">Ajtó</label>
<input
v-model="editForm.address_door"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="Ajtó"
/>
</div>
</div>
</div>
<div v-else class="text-sm space-y-1">
<p class="text-white font-medium">{{ address?.zip || '—' }} {{ address?.city || '' }}</p>
<p class="text-white font-medium">{{ address?.street_name || '' }} {{ address?.street_type || '' }} {{ address?.house_number || '' }}</p>
<p v-if="address?.stairwell || address?.floor || address?.door" class="text-white font-medium">
Lépcsőház: {{ address?.stairwell || '' }}, Emelet: {{ address?.floor || '' }}, Ajtó: {{ address?.door || '' }}
</p>
</div>
</div>
<!-- Success / Error message -->
<div
v-if="saveMessage"
class="mb-4 rounded-xl px-4 py-3 text-sm"
:class="saveMessageType === 'success' ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'"
>
{{ saveMessage }}
</div>
</div>
<!-- Back to Garage Button -->
<div class="mt-8 text-center">
<button
@click="router.push('/dashboard')"
class="inline-flex items-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.04] px-6 py-3 text-white/70 transition-all duration-200 hover:border-[#00E5A0]/40 hover:text-white hover:bg-white/[0.08] backdrop-blur-sm cursor-pointer"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
Vissza a Garázsba
</button>
</div>
</div>
<!-- Change Password Modal -->
<Teleport to="body">
<div
v-if="showPasswordModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closePasswordModal"
>
<div class="relative w-full max-w-md mx-4 rounded-2xl border border-white/10 bg-black/80 p-6 backdrop-blur-2xl shadow-2xl">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h3 class="text-lg font-bold text-white">Jelszó módosítása</h3>
</div>
<button
@click="closePasswordModal"
class="text-white/40 transition-colors duration-200 hover:text-red-400 cursor-pointer"
aria-label="Bezárás"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm text-white/50">Jelenlegi jelszó</label>
<input
v-model="passwordForm.currentPassword"
type="password"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="••••••••"
/>
</div>
<div>
<label class="mb-2 block text-sm text-white/50">Új jelszó</label>
<input
v-model="passwordForm.newPassword"
type="password"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="••••••••"
/>
</div>
<div>
<label class="mb-2 block text-sm text-white/50">Új jelszó újra</label>
<input
v-model="passwordForm.confirmPassword"
type="password"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
placeholder="••••••••"
/>
</div>
<!-- Validation error -->
<div
v-if="passwordError"
class="rounded-xl px-4 py-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20"
>
{{ passwordError }}
</div>
<!-- Success message -->
<div
v-if="passwordSuccess"
class="rounded-xl px-4 py-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20"
>
{{ passwordSuccess }}
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end gap-3 mt-6">
<button
@click="closePasswordModal"
class="rounded-xl border border-white/20 bg-white/5 px-4 py-2 text-sm text-white/70 transition-all duration-200 hover:bg-white/10 cursor-pointer"
>
Mégse
</button>
<button
@click="submitPasswordChange"
:disabled="isPasswordSaving"
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0] px-6 py-2 text-sm text-black font-semibold transition-all duration-200 hover:bg-[#00E5A0]/90 disabled:opacity-50 cursor-pointer"
>
<svg v-if="isPasswordSaving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Mentés
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import api from '../api/axios'
import DashboardHeader from '../components/DashboardHeader.vue'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
// ── Computed helpers ──────────────────────────────────────────────────
const person = computed(() => authStore.user?.person ?? null)
const address = computed(() => person.value?.address ?? null)
const identityDocs = computed(() => person.value?.identity_docs ?? null)
const isCityLoading = ref(false)
// ── Edit State ────────────────────────────────────────────────────────
const isEditing = ref(false)
const isSaving = ref(false)
const saveMessage = ref('')
const saveMessageType = ref<'success' | 'error'>('success')
const editForm = reactive({
first_name: '',
last_name: '',
phone: '',
mothers_last_name: '',
mothers_first_name: '',
birth_place: '',
birth_date: '',
// Identity docs
id_card_number: '',
id_card_expiry: '',
license_number: '',
license_expiry: '',
license_categories: '',
// Address
address_zip: '',
address_city: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
address_stairwell: '',
address_floor: '',
address_door: '',
address_hrsz: '',
})
// ── Password Modal State ──────────────────────────────────────────────
const showPasswordModal = ref(false)
const isPasswordSaving = ref(false)
const passwordError = ref('')
const passwordSuccess = ref('')
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
function formatDate(dateVal: string | null | undefined): string {
if (!dateVal) return ''
return dateVal.substring(0, 10)
}
function startEditing() {
const p = person.value
const a = address.value
const docs = identityDocs.value
editForm.first_name = p?.first_name || ''
editForm.last_name = p?.last_name || ''
editForm.phone = p?.phone || ''
editForm.mothers_last_name = p?.mothers_last_name || ''
editForm.mothers_first_name = p?.mothers_first_name || ''
editForm.birth_place = p?.birth_place || ''
editForm.birth_date = formatDate(p?.birth_date)
// Identity docs
editForm.id_card_number = docs?.ID_CARD?.number || ''
editForm.id_card_expiry = formatDate(docs?.ID_CARD?.expiry_date) || ''
editForm.license_number = docs?.LICENSE?.number || ''
editForm.license_expiry = formatDate(docs?.LICENSE?.expiry_date) || ''
editForm.license_categories = docs?.LICENSE?.categories || ''
// Address
editForm.address_zip = a?.zip || ''
editForm.address_city = a?.city || ''
editForm.address_street_name = a?.street_name || ''
editForm.address_street_type = a?.street_type || ''
editForm.address_house_number = a?.house_number || ''
editForm.address_stairwell = a?.stairwell || ''
editForm.address_floor = a?.floor || ''
editForm.address_door = a?.door || ''
saveMessage.value = ''
isEditing.value = true
}
function cancelEditing() {
isEditing.value = false
saveMessage.value = ''
}
// ── Debounced ZIP → City auto-fill via internal API ──
let zipTimeout: ReturnType<typeof setTimeout>
watch(
() => editForm.address_zip,
(newZip) => {
clearTimeout(zipTimeout)
if (newZip && newZip.length >= 3) {
isCityLoading.value = true
zipTimeout = setTimeout(async () => {
try {
const countryCode = 'HU' // Default to HU for now
const response = await api.get('/system/zip-lookup', {
params: { country_code: countryCode, zip_code: newZip }
})
if (response.data?.city) {
editForm.address_city = response.data.city
}
} catch (error) {
// Silently fail - user can type city manually
console.warn('Zip lookup failed:', error)
} finally {
isCityLoading.value = false
}
}, 600)
} else {
isCityLoading.value = false
}
}
)
function buildIdentityDocsPayload(): Record<string, any> | null {
const idCardNumber = editForm.id_card_number?.trim()
const idCardExpiry = editForm.id_card_expiry?.trim()
const licenseNumber = editForm.license_number?.trim()
const licenseExpiry = editForm.license_expiry?.trim()
const licenseCategories = editForm.license_categories?.trim()
if (!idCardNumber && !idCardExpiry && !licenseNumber && !licenseExpiry && !licenseCategories) {
return null
}
const payload: Record<string, any> = {}
if (idCardNumber || idCardExpiry) {
payload.ID_CARD = {}
if (idCardNumber) payload.ID_CARD.number = idCardNumber
if (idCardExpiry) payload.ID_CARD.expiry_date = idCardExpiry
}
if (licenseNumber || licenseExpiry || licenseCategories) {
payload.LICENSE = {}
if (licenseNumber) payload.LICENSE.number = licenseNumber
if (licenseExpiry) payload.LICENSE.expiry_date = licenseExpiry
if (licenseCategories) payload.LICENSE.categories = licenseCategories
}
return payload
}
async function savePerson() {
isSaving.value = true
saveMessage.value = ''
try {
const payload: Record<string, any> = {}
const fields = [
'first_name', 'last_name', 'phone',
'mothers_last_name', 'mothers_first_name',
'birth_place', 'birth_date',
'address_zip', 'address_city',
'address_street_name', 'address_street_type', 'address_house_number',
'address_stairwell', 'address_floor', 'address_door',
]
for (const field of fields) {
const val = (editForm as any)[field]
if (val !== null && val !== undefined && val !== '') {
payload[field] = val
}
}
const docsPayload = buildIdentityDocsPayload()
if (docsPayload) {
payload.identity_docs = docsPayload
}
await authStore.updatePerson(payload)
saveMessage.value = 'A profil adatok és okmányok sikeresen frissítve!'
saveMessageType.value = 'success'
isEditing.value = false
} catch (err: any) {
saveMessage.value = err?.response?.data?.detail || 'Hiba történt a mentés során.'
saveMessageType.value = 'error'
} finally {
isSaving.value = false
}
}
// ── Change Password ──────────────────────────────────────────────────
function closePasswordModal() {
showPasswordModal.value = false
passwordError.value = ''
passwordSuccess.value = ''
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
async function submitPasswordChange() {
passwordError.value = ''
passwordSuccess.value = ''
// Frontend validation
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
passwordError.value = 'Minden mező kitöltése kötelező.'
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
passwordError.value = 'Az új jelszó és a megerősítés nem egyezik.'
return
}
if (passwordForm.newPassword.length < 6) {
passwordError.value = 'Az új jelszónak legalább 6 karakter hosszúnak kell lennie.'
return
}
isPasswordSaving.value = true
try {
const result = await authStore.changePassword(
passwordForm.currentPassword,
passwordForm.newPassword
)
passwordSuccess.value = result.message || 'Jelszó sikeresen megváltoztatva!'
// Auto-close after 2 seconds on success
setTimeout(() => {
closePasswordModal()
}, 2000)
} catch (err: any) {
passwordError.value = err?.response?.data?.detail || 'A jelszó módosítása sikertelen.'
} finally {
isPasswordSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div class="relative flex min-h-screen items-center justify-center text-white">
<!-- Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Glassmorphism Card -->
<div class="relative z-10 mx-auto w-full max-w-md px-4">
<div
class="rounded-2xl border border-white/[0.08] bg-white/[0.05] p-8 backdrop-blur-xl shadow-xl shadow-black/20 text-center"
>
<!-- Lock Icon -->
<div class="mb-6 flex justify-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-[#418890]/20">
<svg class="h-8 w-8 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<!-- Title -->
<h1 class="mb-4 text-2xl font-bold text-white">
Jelszó visszaállítás
</h1>
<!-- Description -->
<p class="mb-8 text-white/60 leading-relaxed">
Add meg az e-mail címed és az új jelszót.
</p>
<!-- Email Input -->
<div class="mb-4 text-left">
<label for="reset-email" class="mb-2 block text-sm font-medium text-white/80">
E-mail cím
</label>
<input
id="reset-email"
v-model="email"
type="email"
placeholder="pelda@email.com"
autocomplete="email"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- New Password Input -->
<div class="mb-4 text-left">
<label for="new-password" class="mb-2 block text-sm font-medium text-white/80">
Új jelszó
</label>
<input
id="new-password"
v-model="password"
type="password"
placeholder="Új jelszó"
autocomplete="new-password"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Confirm Password Input -->
<div class="mb-6 text-left">
<label for="confirm-password" class="mb-2 block text-sm font-medium text-white/80">
Jelszó megerősítése
</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
placeholder="Jelszó újra"
autocomplete="new-password"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Submit Button -->
<button
@click="handleReset"
:disabled="isSubmitting || !email || !password || !confirmPassword"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ isSubmitting ? 'Visszaállítás...' : 'Jelszó visszaállítása' }}
</button>
<!-- Feedback Message -->
<div
v-if="feedbackMessage"
class="mt-4 rounded-xl px-4 py-3 text-sm"
:class="feedbackType === 'success' ? 'bg-[#70BC84]/20 text-[#70BC84]' : 'bg-red-500/20 text-red-300'"
>
{{ feedbackMessage }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const isSubmitting = ref(false)
const feedbackMessage = ref('')
const feedbackType = ref<'success' | 'error'>('success')
const token = ref('')
onMounted(() => {
// Read token from query parameter
const queryToken = route.query.token as string
if (queryToken) {
token.value = queryToken
} else {
feedbackType.value = 'error'
feedbackMessage.value = 'Hiányzó token. Kérlek használd a jelszó-visszaállító linket az e-mailből.'
}
})
async function handleReset() {
if (!token.value || !email.value || !password.value || !confirmPassword.value) return
if (password.value !== confirmPassword.value) {
feedbackType.value = 'error'
feedbackMessage.value = 'A két jelszó nem egyezik.'
return
}
if (password.value.length < 8) {
feedbackType.value = 'error'
feedbackMessage.value = 'A jelszónak legalább 8 karakter hosszúnak kell lennie.'
return
}
isSubmitting.value = true
feedbackMessage.value = ''
try {
await authStore.resetPassword(email.value, token.value, password.value)
feedbackType.value = 'success'
feedbackMessage.value = 'A jelszó sikeresen megváltoztatva! Átirányítás a bejelentkezéshez...'
setTimeout(() => {
router.push('/')
}, 2000)
} catch (err: any) {
feedbackType.value = 'error'
feedbackMessage.value = authStore.error || 'A jelszó visszaállítás sikertelen. Kérlek próbáld újra.'
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div class="relative flex min-h-screen items-center justify-center text-white">
<!-- Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Glassmorphism Card -->
<div class="relative z-10 mx-auto w-full max-w-md px-4">
<div
class="rounded-2xl border border-white/[0.08] bg-white/[0.05] p-8 backdrop-blur-xl shadow-xl shadow-black/20 text-center"
>
<!-- STATE: Verifying (Loading) -->
<template v-if="isVerifying">
<!-- Spinner -->
<div class="mb-6 flex justify-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-[#418890]/20">
<svg class="h-8 w-8 animate-spin text-[#418890]" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
</div>
<h1 class="mb-4 text-2xl font-bold text-white">Fiók aktiválása...</h1>
<p class="mb-4 text-white/60">Kérlek várj, amíg ellenőrizzük a megerősítő linket.</p>
</template>
<!-- STATE: Verify Success (Magic Link - Auto-Login) -->
<template v-else-if="verifySuccess">
<!-- Glassmorphism Green Checkmark -->
<div class="mb-6 flex justify-center">
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-[#70BC84]/10 backdrop-blur-md shadow-lg shadow-[#70BC84]/20 ring-2 ring-[#70BC84]/30">
<svg class="h-10 w-10 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<h1 class="mb-4 text-2xl font-bold text-white">Sikeres aktiválás és bejelentkezés!</h1>
<p class="mb-8 text-white/60 leading-relaxed">
A fiókod aktív lett, és automatikusan be vagy jelentkezve.
{{ autoRedirectCountdown > 0 ? `Átirányítás ${autoRedirectCountdown} másodperc múlva...` : 'Átirányítás...' }}
</p>
<!-- Premium CTA Button (opcionális gyorsabb átirányításhoz) -->
<button
@click="goToKyc"
class="btn-premium w-full px-4 py-3 text-lg"
>
Tovább a Profilhoz
</button>
</template>
<!-- STATE: Default (Resend form) -->
<template v-else>
<!-- Mail Icon -->
<div class="mb-6 flex justify-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-[#418890]/20">
<svg class="h-8 w-8 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<!-- Title -->
<h1 class="mb-4 text-2xl font-bold text-white">
Erősítsd meg az e-mail címed
</h1>
<!-- Error message (shown when token verification failed) -->
<div
v-if="verifyError"
class="mb-6 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ verifyError }}
</div>
<!-- Description -->
<p class="mb-8 text-white/60 leading-relaxed">
Kérlek, erősítsd meg az e-mail címedet a kiküldött linkre kattintva!
</p>
<!-- Email Input -->
<div class="mb-6">
<label for="verify-email" class="mb-2 block text-sm font-medium text-white/80 text-left">
E-mail cím
</label>
<input
id="verify-email"
v-model="email"
type="email"
placeholder="pelda@email.com"
autocomplete="email"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Resend Button -->
<button
@click="handleResend"
:disabled="isResending || !email"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ isResending ? 'Küldés...' : 'Nem kaptam meg az e-mailt, küldjétek újra' }}
</button>
<!-- Resend Feedback Message -->
<div
v-if="feedbackMessage"
class="mt-4 rounded-xl px-4 py-3 text-sm"
:class="feedbackType === 'success' ? 'bg-[#70BC84]/20 text-[#70BC84]' : 'bg-red-500/20 text-red-300'"
>
{{ feedbackMessage }}
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import FingerprintJS from '@fingerprintjs/fingerprintjs'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// ── Device Fingerprinting (FingerprintJS) ──
let fpPromise: ReturnType<typeof FingerprintJS.load> | null = null
/**
* Generate a device fingerprint hash asynchronously.
* Returns the visitorId string, or null if FingerprintJS fails to load.
*/
async function getDeviceFingerprint(): Promise<string | null> {
try {
if (!fpPromise) {
fpPromise = FingerprintJS.load()
}
const fp = await fpPromise
const result = await fp.get()
return result.visitorId
} catch (err) {
console.warn('FingerprintJS failed to load:', err)
return null
}
}
// ── State ──
const email = ref('')
const isResending = ref(false)
const feedbackMessage = ref('')
const feedbackType = ref<'success' | 'error'>('success')
// Token verification states
const isVerifying = ref(false)
const verifySuccess = ref(false)
const verifyError = ref('')
// Auto-redirect countdown for Magic Link
const autoRedirectCountdown = ref(3)
let countdownInterval: ReturnType<typeof setInterval> | null = null
// ── Lifecycle ──
onMounted(async () => {
// Pre-fill email from localStorage (set during registration)
const savedEmail = localStorage.getItem('pending_verification_email')
if (savedEmail) {
email.value = savedEmail
} else if (authStore.user?.email) {
email.value = authStore.user.email
}
// Check for verification token in URL query params
const token = route.query.token as string | undefined
if (token) {
await processToken(token)
}
})
onUnmounted(() => {
// Cleanup countdown interval
if (countdownInterval) {
clearInterval(countdownInterval)
}
})
// ── Token Processing (Magic Link) ──
async function processToken(token: string) {
isVerifying.value = true
verifyError.value = ''
try {
// Generate device fingerprint asynchronously (non-blocking)
const deviceFingerprint = await getDeviceFingerprint()
const result = await authStore.verifyAccount(token, deviceFingerprint ?? undefined)
// Success
isVerifying.value = false
verifySuccess.value = true
// If Magic Link returned JWT tokens (user is now logged in), start countdown
if (result.access_token && authStore.isAuthenticated) {
startAutoRedirectCountdown()
}
} catch (err: any) {
// Failure — token expired or invalid
isVerifying.value = false
verifySuccess.value = false
verifyError.value = 'A link lejárt vagy érvénytelen.'
}
}
// ── Auto-Redirect Countdown (Magic Link) ──
function startAutoRedirectCountdown() {
autoRedirectCountdown.value = 3
countdownInterval = setInterval(() => {
autoRedirectCountdown.value -= 1
if (autoRedirectCountdown.value <= 0) {
if (countdownInterval) {
clearInterval(countdownInterval)
}
goToKyc()
}
}, 1000)
}
// ── Navigation ──
function goToKyc() {
// Navigate to KYC completion page (user is now authenticated)
router.push('/complete-kyc')
}
// ── Resend ──
async function handleResend() {
if (!email.value) return
isResending.value = true
feedbackMessage.value = ''
try {
await authStore.resendVerification(email.value)
feedbackType.value = 'success'
feedbackMessage.value = 'Az új megerősítő e-mail elküldésre került!'
} catch (err: any) {
feedbackType.value = 'error'
feedbackMessage.value = authStore.error || 'Az újraküldés sikertelen. Kérlek próbáld újra később.'
} finally {
isResending.value = false
}
}
</script>

View File

@@ -18,8 +18,7 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://sf_api:8000', target: 'http://sf_api:8000',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false
rewrite: (path) => path.replace(/^\/api/, '')
} }
} }
} }

View File

@@ -0,0 +1,43 @@
# logic_spec_auth_debug.md — Auth Debug Deep Dive
## Modul Célja
Az auth végpontok (forgot-password, login) hibakezelésének debug-javítása. Az `initiate_password_reset` már dob kivételeket (inaktív user esetén HTTPException, email hiba esetén 500), de a végpont ezeket némán elnyeli. A login végpont pedig nem ismeri fel az inaktív fiókot.
## Érintett Fájlok
| Fájl | Szerep |
|------|--------|
| `backend/app/api/v1/endpoints/auth.py` | API végpontok (forgot-password, login) |
| `backend/app/services/auth_service.py` | Üzleti logika (initiate_password_reset, authenticate) |
| `backend/app/services/email_manager.py` | Email küldés Brevo API-n / SMTP-n keresztül |
## 1. Beavatkozás: Forgot-Password Anti-Enumeration Debug
### Jelenlegi állapot
Az `auth.py:114-125` végpont `initiate_password_reset`-et hív, de a visszatérési értéket teljesen figyelmen kívül hagyja. Mindig ugyanazt a generikus választ adja.
Az `auth_service.py:347-388` `initiate_password_reset`:
- Ha nincs user: return "not_found"
- Ha user inaktív: raise HTTPException(400, "AUTH.USER_INACTIVE")
- Ha email hiba: raise HTTPException(500, "Email küldési hiba: ...")
- Siker esetén: return "success"
### Módosítás
A `result` változó ellenőrzése. Ha "not_found", dobjon HTTPException-t.
## 2. Beavatkozás: Login Inaktív Fiók Felismerése
### Jelenlegi állapot
Az `auth.py:32-81` login végpont `AuthService.authenticate`-et hív, ami csak email+jelszó párost ellenőriz, `is_active`-ot NEM. Inaktív user esetén is visszaküldi a tokent `"is_active": false`-cal.
### Módosítás
Token generálás ELŐTT ellenőrizni a `user.is_active` flag-et. Ha False, dobjon explicit `HTTPException(400, "AUTH.USER_INACTIVE")`-t.
## 3. Élő Diagnosztika
### Parancsok
1. curl POST a forgot-password végpontra
2. Docker logok: `docker compose logs --tail 100 sf_api`
## Kockázatok
- Anti-enumeration gyengítése biztonsági rés lehet debug időszakban