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
# We need to load the person relationship to avoid lazy loading issues
stmt = select(User).where(User.id == int(user_id)).options(joinedload(User.person))
# Also load Person.address for nested profile response
from app.models.identity import Person
from app.models.identity.address import Address
stmt = (
select(User)
.where(User.id == int(user_id))
.options(
joinedload(User.person).joinedload(Person.address)
)
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()

View File

@@ -1,4 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/auth.py
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,9 +11,10 @@ from app.core.config import settings
from app.services.config_service import config
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
from app.api.deps import get_current_user
from app.models.identity import User # JAVÍTVA: Új központi modell
from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-Hub modellek
from app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone
router = APIRouter()
@@ -34,18 +36,71 @@ async def login(
response: Response,
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
remember_me: bool = Form(False)
remember_me: bool = Form(False),
device_fingerprint: Optional[str] = Form(None)
):
"""
Bejelentkezés remember_me opcióval.
Bejelentkezés remember_me opcióval és Device Fingerprinting támogatással.
A remember_me paramétert explicit Form mezőként kell elküldeni, mivel az
OAuth2PasswordRequestForm nem támogatja alapból.
A device_fingerprint opcionális, a frontend FingerprintJS által generált hash.
"""
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail=t("AUTH.INVALID_CREDENTIALS"))
# DEBUG: Inaktív fiók explicit ellenőrzése
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="AUTH.USER_INACTIVE"
)
# ── Device Fingerprint Tracking ──
if device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
existing_device = await db.execute(
select(Device).where(Device.fingerprint_hash == device_fingerprint)
)
device = existing_device.scalar_one_or_none()
if not device:
# Új eszköz létrehozása
device = Device(
fingerprint_hash=device_fingerprint,
risk_score=100,
is_banned=False
)
db.add(device)
# 2. Ellenőrizzük a UserDeviceLink-et
existing_link = await db.execute(
select(UserDeviceLink).where(
UserDeviceLink.user_id == user.id,
UserDeviceLink.device_hash == device_fingerprint
)
)
link = existing_link.scalar_one_or_none()
now = datetime.now(timezone.utc)
if link:
# Létező kapcsolat frissítése
link.last_seen_at = now
link.login_count = link.login_count + 1
else:
# Új kapcsolat létrehozása
link = UserDeviceLink(
user_id=user.id,
device_hash=device_fingerprint,
first_seen_at=now,
last_seen_at=now,
login_count=1
)
db.add(link)
await db.commit()
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
@@ -82,16 +137,112 @@ async def login(
class VerifyEmailRequest(BaseModel):
token: str = Field(..., description="Email verification token (UUID)")
device_fingerprint: Optional[str] = Field(None, description="Device fingerprint hash from FingerprintJS")
@router.post("/verify-email")
async def verify_email(request: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
@router.post("/verify-email", response_model=Token)
async def verify_email(
request: VerifyEmailRequest,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""
Email megerősítés token alapján.
Email megerősítés token alapján + automatikus bejelentkezés (Magic Link).
Sikeres verifikáció után azonnal JWT tokent ad vissza, így a felhasználó
bejelentkezik anélkül, hogy külön be kellene írnia a jelszavát.
"""
success = await AuthService.verify_email(db, request.token)
if not success:
user = await AuthService.verify_email(db, request.token)
if not user:
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
return {"status": "success", "message": t("AUTH.EMAIL_VERIFICATION_SUCCESS")}
# ── Device Fingerprint Tracking (Magic Link) ──
if request.device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
existing_device = await db.execute(
select(Device).where(Device.fingerprint_hash == request.device_fingerprint)
)
device = existing_device.scalar_one_or_none()
if not device:
# Új eszköz létrehozása
device = Device(
fingerprint_hash=request.device_fingerprint,
risk_score=100,
is_banned=False
)
db.add(device)
# 2. Ellenőrizzük a UserDeviceLink-et
existing_link = await db.execute(
select(UserDeviceLink).where(
UserDeviceLink.user_id == user.id,
UserDeviceLink.device_hash == request.device_fingerprint
)
)
link = existing_link.scalar_one_or_none()
now = datetime.now(timezone.utc)
if link:
# Létező kapcsolat frissítése
link.last_seen_at = now
link.login_count = link.login_count + 1
else:
# Új kapcsolat létrehozása
link = UserDeviceLink(
user_id=user.id,
device_hash=request.device_fingerprint,
first_seen_at=now,
last_seen_at=now,
login_count=1
)
db.add(link)
await db.commit()
# Generálj JWT tokent a most aktivált felhasználóhoz (ugyanaz a logika, mint login-nál)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
# Magic Link esetén ne használjunk remember_me-t (alapértelmezett 1 napos token)
access, refresh = create_tokens(data=token_data, remember_me=False)
# Refresh token lejárat
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
max_age=max_age_sec
)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
class ResendVerificationRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím az aktiváló levél újraküldéséhez")
@router.post("/resend-verification")
async def resend_verification(request: ResendVerificationRequest, db: AsyncSession = Depends(get_db)):
"""
Aktiváló e-mail újraküldése.
Mindig sikeres választ ad, hogy megelőzzük az email enumerációt.
"""
result = await AuthService.resend_verification(db, request.email)
return {
"status": "success",
"message": "Ha ez az email cím regisztrálva van és még nem aktív, akkor elküldtük az aktiváló e-mailt."
}
class ForgotPasswordRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím a jelszó visszaállításhoz")
@@ -100,10 +251,18 @@ class ForgotPasswordRequest(BaseModel):
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""
Elfelejtett jelszó folyamat indítása.
Mindig sikeres választ ad, hogy megelőzzük az email enumerációt.
DEBUG MODE: Explicit hibákat dob az email küldési problémák diagnosztizálásához.
"""
result = await AuthService.initiate_password_reset(db, request.email)
# Mindig ugyanazt a választ adjuk, függetlenül attól, hogy létezik-e a felhasználó
# DEBUG: Ha nem található a user, dobjunk explicit hibát
if result == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A felhasználó nem található ezzel az email címmel."
)
# Ha success, adjuk vissza a sikeres választ
return {
"status": "success",
"message": "Ha ez az email cím regisztrálva van, akkor elküldtünk egy jelszó-visszaállítási linket."

View File

@@ -1,8 +1,9 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/system_parameters.py
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import List, Optional
from sqlalchemy import select, update, and_
from typing import List, Optional, Dict, Any
from app.api.deps import get_db, get_current_user
from app.schemas.system import (
@@ -11,9 +12,11 @@ from app.schemas.system import (
SystemParameterCreate,
)
from app.models.system import SystemParameter, ParameterScope
from app.models.identity.address import GeoPostalCode
from app.models.identity import UserRole
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=List[SystemParameterResponse])
@@ -41,6 +44,40 @@ async def list_system_parameters(
return parameters
@router.get("/zip-lookup", response_model=Dict[str, Any])
async def zip_lookup(
country_code: str = Query("HU", description="Országkód (pl. HU, AT, DE)"),
zip_code: str = Query(..., min_length=1, description="Irányítószám"),
db: AsyncSession = Depends(get_db),
):
"""
Belső irányítószám lekérdező végpont.
A system.geo_postal_codes táblában keres a megadott country_code és zip_code párosra.
Ha megtalálja, visszaadja a várost: {"city": "Budapest"}.
Ha nem találja, 404-es hibát dob.
"""
if not zip_code.strip():
raise HTTPException(status_code=400, detail="zip_code is required")
stmt = select(GeoPostalCode).where(
and_(
GeoPostalCode.country_code == country_code,
GeoPostalCode.zip_code == zip_code.strip(),
)
).limit(1)
result = await db.execute(stmt)
postal_code = result.scalar_one_or_none()
if not postal_code:
raise HTTPException(
status_code=404,
detail=f"No city found for country_code='{country_code}' and zip_code='{zip_code}'"
)
return {"city": postal_code.city}
@router.get("/{key}", response_model=SystemParameterResponse)
async def get_system_parameter(
key: str,
@@ -128,5 +165,4 @@ async def update_system_parameter(
await db.execute(stmt)
await db.commit()
await db.refresh(parameter)
return parameter

View File

@@ -1,16 +1,23 @@
#/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py
import copy
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select, or_
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.attributes import flag_modified
from typing import Dict, Any, List, Optional
from datetime import datetime
from app.api.deps import get_db, get_current_user
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse
from app.models.identity import User
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
from app.models.identity import User, Person
from app.models.identity.address import Address
from app.services.trust_engine import TrustEngine
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.services.geo_service import GeoService
from app.services.auth_service import AuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP, verify_password, get_password_hash
from app.core.config import settings
from pydantic import BaseModel
@@ -33,26 +40,105 @@ class NetworkResponse(BaseModel):
level2: List[NetworkMemberL2L3]
level3: List[NetworkMemberL2L3]
def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
"""
Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe.
"""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
# Determine active organization ID
if active_org_id is None:
if user.scope_id is not None:
try:
active_org_id = int(user.scope_id)
except (ValueError, TypeError):
active_org_id = None
if active_org_id is None:
# 1. Check if user is a member of any organization with ADMIN/OWNER role
# (This is a helper - in real usage the caller should pass active_org_id)
pass
person = user.person
person_data = None
if person:
address_data = None
if person.address:
address_data = {
"zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None),
"city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None),
"street_name": person.address.street_name,
"street_type": person.address.street_type,
"house_number": person.address.house_number,
"stairwell": person.address.stairwell,
"floor": person.address.floor,
"door": person.address.door,
"parcel_id": person.address.parcel_id,
"full_address_text": person.address.full_address_text,
"latitude": person.address.latitude,
"longitude": person.address.longitude,
}
person_data = {
"id": person.id,
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
"first_name": person.first_name,
"last_name": person.last_name,
"phone": person.phone,
"mothers_last_name": person.mothers_last_name,
"mothers_first_name": person.mothers_first_name,
"birth_place": person.birth_place,
"birth_date": person.birth_date,
"identity_docs": person.identity_docs,
"ice_contact": person.ice_contact,
"is_active": person.is_active,
"address": address_data,
}
# Get first_name/last_name from person for backward compatibility
first_name = person.first_name if person else ""
last_name = person.last_name if person else ""
return {
"id": user.id,
"email": user.email,
"first_name": first_name,
"last_name": last_name,
"is_active": user.is_active,
"region_code": user.region_code,
"person_id": user.person_id,
"role": user.role.value if hasattr(user.role, 'value') else str(user.role),
"subscription_plan": user.subscription_plan,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": user.ui_mode or "personal",
"active_organization_id": active_org_id,
"preferred_language": user.preferred_language or "hu",
"person": person_data,
}
@router.get("/me", response_model=UserResponse)
async def read_users_me(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Visszaadja a bejelentkezett felhasználó profilját"""
from sqlalchemy import select, or_
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
# Determine active organization ID
active_org_id = None
# If user already has a scope_id, use it
if current_user.scope_id is not None:
try:
active_org_id = int(current_user.scope_id)
except (ValueError, TypeError):
active_org_id = None
# If still no active org ID, try to find user's primary organization
if active_org_id is None:
# 1. Check if user is a member of any organization with ADMIN/OWNER role
@@ -63,10 +149,10 @@ async def read_users_me(
OrganizationMember.role == OrgUserRole.OWNER
)
).limit(1)
result = await db.execute(stmt)
org_member_row = result.first()
if org_member_row:
active_org_id = org_member_row[0]
else:
@@ -76,7 +162,7 @@ async def read_users_me(
).limit(1)
result = await db.execute(stmt)
org_owner_row = result.first()
if org_owner_row:
active_org_id = org_owner_row[0]
else:
@@ -86,37 +172,138 @@ async def read_users_me(
).limit(1)
result = await db.execute(stmt)
org_row = result.first()
active_org_id = org_row[0] if org_row else None
# Create a response dictionary with the active_organization_id
# Get first_name and last_name from person relation if available
person = current_user.person
# Safe extraction with fallback to empty string
first_name = ""
last_name = ""
if person:
first_name = getattr(person, 'first_name', '')
last_name = getattr(person, 'last_name', '')
response_data = {
"id": current_user.id,
"email": current_user.email,
"first_name": first_name,
"last_name": last_name,
"is_active": current_user.is_active,
"region_code": current_user.region_code,
"person_id": current_user.person_id,
"role": current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role),
"subscription_plan": current_user.subscription_plan,
"scope_level": current_user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": current_user.ui_mode or "personal",
"active_organization_id": active_org_id
}
response_data = _build_user_response(current_user, active_org_id)
return UserResponse.model_validate(response_data)
@router.put("/me/person", response_model=UserResponse)
async def update_my_person(
update_data: PersonUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Frissíti a bejelentkezett felhasználó Person (és Address) rekordját.
Elfogadja a PersonUpdate sémát, amely tartalmazza a személyes, okmány és cím adatokat.
Visszaadja a frissített UserResponse objektumot.
"""
# Reload user with eager loaded person + address
stmt = (
select(User)
.where(User.id == current_user.id)
.options(joinedload(User.person).joinedload(Person.address))
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
person = user.person
if not person:
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
# ── Update Person fields ──
update_dict = update_data.dict(exclude_unset=True)
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
"mothers_first_name", "birth_place", "birth_date"]
address_fields = ["address_zip", "address_city", "address_street_name",
"address_street_type", "address_house_number",
"address_stairwell", "address_floor", "address_door",
"address_hrsz"]
for field in person_fields:
if field in update_dict and update_dict[field] is not None:
setattr(person, field, update_dict[field])
# ── Update identity_docs (if provided and not None) ──
if "identity_docs" in update_dict and update_dict["identity_docs"] is not None:
# Use deep copy + flag_modified to ensure SQLAlchemy detects the JSON mutation
existing_docs = copy.deepcopy(person.identity_docs) if person.identity_docs else {}
if isinstance(existing_docs, dict) and isinstance(update_dict["identity_docs"], dict):
existing_docs.update(update_dict["identity_docs"])
person.identity_docs = existing_docs
flag_modified(person, "identity_docs")
else:
person.identity_docs = update_dict["identity_docs"]
flag_modified(person, "identity_docs")
# ── Update Address fields ──
has_address_update = any(f in update_dict for f in address_fields)
if has_address_update:
# Use GeoService to get or create address
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=update_dict.get("address_zip"),
city=update_dict.get("address_city"),
street_name=update_dict.get("address_street_name"),
street_type=update_dict.get("address_street_type"),
house_number=update_dict.get("address_house_number"),
parcel_id=update_dict.get("address_hrsz"),
)
if addr_id:
person.address_id = addr_id
try:
await db.commit()
# Refresh to get the latest state
await db.refresh(person)
if person.address:
await db.refresh(person.address)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Build and return the full response
response_data = _build_user_response(user)
return UserResponse.model_validate(response_data)
@router.put("/me/password", status_code=200)
async def change_my_password(
change_data: ChangePasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jelszó módosítás a bejelentkezett felhasználó számára.
Ellenőrzi a jelenlegi jelszót, validálja az új jelszó komplexitását,
majd elmenti az új hash-t. Válaszként egy {'status': 'ok', 'message': ...} dict-et ad.
"""
# 1. Ellenőrizzük a jelenlegi jelszót
if not verify_password(change_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=400,
detail="A jelenlegi jelszó helytelen."
)
# 2. Ellenőrizzük, hogy az új jelszó nem egyezik-e a régivel
if verify_password(change_data.new_password, current_user.hashed_password):
raise HTTPException(
status_code=400,
detail="Az új jelszó nem egyezhet meg a jelenlegi jelszóval."
)
# 3. Jelszó komplexitás ellenőrzése (dinamikus admin beállítások alapján)
await AuthService._validate_password_complexity(db, change_data.new_password, current_user.region_code)
# 4. Új jelszó hash-elése és mentése
current_user.hashed_password = get_password_hash(change_data.new_password)
try:
await db.commit()
await db.refresh(current_user)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
return {"status": "ok", "message": "Jelszó sikeresen megváltoztatva."}
@router.get("/me/trust")
async def get_user_trust(
db: AsyncSession = Depends(get_db),
@@ -125,10 +312,10 @@ async def get_user_trust(
) -> Dict[str, Any]:
"""
Visszaadja a felhasználó Gondos Gazda Index (Trust Score) értékét.
A számítás dinamikusan betölti a paramétereket a SystemParameter rendszerből
(Global/Country/Region/User hierarchia).
Paraméterek:
- force_recalculate: Ha True, akkor újraszámolja a trust score-t
(alapértelmezetten cache-elt értéket ad vissza, ha kevesebb mint 24 órája számoltuk)
@@ -154,28 +341,29 @@ async def update_user_preferences(
update_dict = update_data.dict(exclude_unset=True)
if not update_dict:
raise HTTPException(status_code=400, detail="No fields to update")
# Validate ui_mode if present
if "ui_mode" in update_dict:
if update_dict["ui_mode"] not in ["personal", "fleet"]:
raise HTTPException(status_code=422, detail="ui_mode must be 'personal' or 'fleet'")
# Update user fields
for field, value in update_dict.items():
if hasattr(current_user, field):
setattr(current_user, field, value)
else:
raise HTTPException(status_code=400, detail=f"Invalid field: {field}")
try:
await db.commit()
await db.refresh(current_user)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Return the Pydantic model instead of raw SQLAlchemy object
return UserResponse.model_validate(current_user)
response_data = _build_user_response(current_user)
return UserResponse.model_validate(response_data)
@router.patch("/me/active-organization", response_model=UserWithTokenResponse)
@@ -186,18 +374,18 @@ async def update_active_organization(
):
"""
Update the user's active organization (scope_id).
Accepts an organization_id (UUID/string) or None to revert to personal mode.
Returns a new JWT token with updated scope_id in the payload.
"""
# Extract organization_id from request
org_id = update_data.organization_id
# Validate that the user has access to this organization if org_id is provided
if org_id is not None:
from sqlalchemy import select
from app.models.marketplace.organization import OrganizationMember
# Check if user is a member of the organization
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
@@ -205,23 +393,23 @@ async def update_active_organization(
)
result = await db.execute(stmt)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(
status_code=403,
detail="You are not a member of this organization"
)
# Update user's scope_id
current_user.scope_id = org_id
try:
await db.commit()
await db.refresh(current_user)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Generate new JWT token with updated scope_id
role_key = current_user.role.value.upper()
token_payload = {
@@ -232,16 +420,18 @@ async def update_active_organization(
"scope_id": org_id,
"person_id": str(current_user.person_id) if current_user.person_id else None,
}
access_token, _ = create_tokens(data=token_payload)
# Return user data with new token
response_data = _build_user_response(current_user)
return UserWithTokenResponse(
user=UserResponse.model_validate(current_user),
user=UserResponse.model_validate(response_data),
access_token=access_token,
token_type="bearer"
)
@router.get("/me/network", response_model=NetworkResponse)
async def get_my_network(
db: AsyncSession = Depends(get_db),
@@ -257,7 +447,7 @@ async def get_my_network(
l1_stmt = select(User).where(User.referred_by_id == current_user.id)
l1_result = await db.execute(l1_stmt)
l1_users = l1_result.scalars().all()
level1 = [
NetworkMemberL1(
email=u.email,
@@ -267,7 +457,7 @@ async def get_my_network(
)
for u in l1_users
]
# L2: L1 userek által meghívottak
level2 = []
if l1_users:
@@ -275,7 +465,7 @@ async def get_my_network(
l2_stmt = select(User).where(User.referred_by_id.in_(l1_ids))
l2_result = await db.execute(l2_stmt)
l2_users = l2_result.scalars().all()
level2 = [
NetworkMemberL2L3(
referral_code=u.referral_code or "",
@@ -283,7 +473,7 @@ async def get_my_network(
)
for u in l2_users
]
# L3: L2 userek által meghívottak
level3 = []
if l2_users:
@@ -291,7 +481,7 @@ async def get_my_network(
l3_stmt = select(User).where(User.referred_by_id.in_(l2_ids))
l3_result = await db.execute(l3_stmt)
l3_users = l3_result.scalars().all()
level3 = [
NetworkMemberL2L3(
referral_code=u.referral_code or "",
@@ -299,7 +489,7 @@ async def get_my_network(
)
for u in l3_users
]
return NetworkResponse(
level1=level1,
level2=level2,

View File

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

View File

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

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)
postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("system.geo_postal_codes.id"))
street_name: Mapped[str] = mapped_column(String(200), nullable=False)
street_type: Mapped[str] = mapped_column(String(50), nullable=False)
house_number: Mapped[str] = mapped_column(String(50), nullable=False)
street_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
house_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: Mapped[Optional[str]] = mapped_column(String(20))
@@ -60,6 +60,9 @@ class Address(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")
class Rating(Base):

View File

@@ -2,17 +2,18 @@
from __future__ import annotations
import uuid
import enum
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM, JSONB
from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
if TYPE_CHECKING:
from .address import Address
from .organization import Organization, OrganizationMember
from .asset import VehicleOwnership
from .gamification import UserStats
@@ -69,6 +70,14 @@ class Person(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_ghost: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Soft KYC: Duplikált személyek összefésülése (merge)
merged_into_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("identity.persons.id", ondelete="SET NULL"),
nullable=True,
index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -94,6 +103,13 @@ class Person(Base):
nullable=True
)
# Kapcsolat a lakcímhez (system.addresses tábla)
address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys="[Person.address_id]",
lazy="joined"
)
memberships: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="person")
# Kapcsolat a tulajdonolt szervezetekhez (Organization táblában legal_owner_id)
@@ -113,7 +129,7 @@ class User(Base):
hashed_password: Mapped[Optional[str]] = mapped_column(String)
role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"),
PG_ENUM(UserRole, name="userrole", schema="identity"),
default=UserRole.user
)
@@ -142,6 +158,10 @@ class User(Base):
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
# E-mail történetiség és alternatív e-mailek (MB 2.0.1)
alternative_emails: Mapped[Any] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'::jsonb"))
email_history: Mapped[Any] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# --- KAPCSOLATOK ---
@@ -192,7 +212,7 @@ class Wallet(Base):
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), unique=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True)
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
@@ -263,10 +283,66 @@ class UserTrustProfile(Base):
maintenance_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False)
quality_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False)
preventive_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False)
# Soft KYC / Identity Trust bővítés
identity_score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
verification_level: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
verified_channels: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
identity_risk_flag: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
last_calculated: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False
)
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False)
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)
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
scope_level: Mapped[ParameterScope] = mapped_column(SQLEnum(ParameterScope, name="parameter_scope", schema="system"), server_default=ParameterScope.GLOBAL.value, index=True)
scope_level: Mapped[str] = mapped_column(String(50), server_default=ParameterScope.GLOBAL.value, index=True)
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -4,8 +4,8 @@ from typing import Optional, Dict, List
from datetime import date, datetime
class DocumentDetail(BaseModel):
number: str
expiry_date: date
number: Optional[str] = None
expiry_date: Optional[date] = None
class ICEContact(BaseModel):
name: str
@@ -26,7 +26,9 @@ class UserLiteRegister(BaseModel):
model_config = ConfigDict(from_attributes=True)
class UserKYCComplete(BaseModel):
""" Step 2: Teljes körű személyazonosítás és címadatok. """
""" Step 2: Teljes körű személyazonosítás és címadatok (Soft KYC). """
first_name: Optional[str] = None
last_name: Optional[str] = None
phone_number: Optional[str] = Field(default=None, pattern=r"^\+?[0-9]{7,15}$")
birth_place: Optional[str] = None
birth_date: Optional[date] = None
@@ -34,19 +36,19 @@ class UserKYCComplete(BaseModel):
mothers_first_name: Optional[str] = None
region_code: Optional[str] = "HU"
# Atomizált címadatok a pontos GPS-hez és Robot-munkához
# Soft KYC: Csak az irányítószám és város kötelező, a többi opcionális
address_zip: str
address_city: str
address_street_name: str
address_street_type: str # utca, út, tér...
address_house_number: str
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None # utca, út, tér...
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám
# Okmányok és Vészhelyzet
identity_docs: Dict[str, DocumentDetail] # pl: {"ID_CARD": {...}, "LICENSE": {...}}
# Okmányok és Vészhelyzet (Opcionális Soft KYC)
identity_docs: Optional[Dict[str, DocumentDetail]] = None # pl: {"ID_CARD": {...}, "LICENSE": {...}}
ice_contact: Optional[ICEContact] = None
preferred_language: str = "hu"

View File

@@ -1,7 +1,46 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional
from datetime import date
from typing import Optional, Any
from datetime import date, datetime
class AddressResponse(BaseModel):
"""Beágyazott cím adatok a PersonResponse számára."""
zip: Optional[str] = None
city: Optional[str] = None
street_name: Optional[str] = None
street_type: Optional[str] = None
house_number: Optional[str] = None
stairwell: Optional[str] = None
floor: Optional[str] = None
door: Optional[str] = None
parcel_id: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
class PersonResponse(BaseModel):
"""Beágyazott személyes adatok a UserResponse számára."""
id: int
id_uuid: uuid.UUID
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
mothers_last_name: Optional[str] = None
mothers_first_name: Optional[str] = None
birth_place: Optional[str] = None
birth_date: Optional[datetime] = None
identity_docs: Optional[Any] = None
ice_contact: Optional[Any] = None
is_active: bool = True
address: Optional[AddressResponse] = None
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
email: EmailStr
@@ -10,6 +49,7 @@ class UserBase(BaseModel):
is_active: bool = True
region_code: str = "HU"
class UserResponse(UserBase):
id: int
person_id: Optional[int] = None
@@ -19,19 +59,56 @@ class UserResponse(UserBase):
scope_id: Optional[str] = None
ui_mode: str = "personal"
active_organization_id: Optional[int] = None
preferred_language: str = "hu"
# Beágyazott személyes adatok (Person + Address)
person: Optional[PersonResponse] = None
model_config = ConfigDict(from_attributes=True)
class PersonUpdate(BaseModel):
"""PUT /users/me/person séma - a személyes adatok szerkesztéséhez."""
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
mothers_last_name: Optional[str] = None
mothers_first_name: Optional[str] = None
birth_place: Optional[str] = None
birth_date: Optional[date] = None
# Okmány adatok (identity_docs)
identity_docs: Optional[Any] = None
# Cím adatok
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""PUT /users/me/password séma - jelszó módosításhoz."""
current_password: str
new_password: str
class UserUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
preferred_language: Optional[str] = None
ui_mode: Optional[str] = None
class ActiveOrganizationUpdate(BaseModel):
organization_id: Optional[str] = None # UUID/string or None to revert to personal mode
class UserWithTokenResponse(BaseModel):
"""User response with new JWT token for organization switching"""
user: UserResponse
access_token: str
token_type: str = "bearer"
token_type: str = "bearer"

View File

@@ -93,7 +93,7 @@ class AuthService:
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
is_active=False,
is_active=False, # Email verification required before activation
identity_docs={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
ice_contact={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
lifetime_xp=0, # default -1, de explicit 0
@@ -117,7 +117,7 @@ class AuthService:
hashed_password=get_password_hash(user_in.password),
person_id=new_person.id,
role=assigned_role,
is_active=False,
is_active=False, # Email verification required before activation
is_deleted=False,
region_code=user_in.region_code,
preferred_language=user_in.lang,
@@ -217,6 +217,8 @@ class AuthService:
user.person_id = shadow_person.id
# Frissítjük a megtalált Person rekordot a KYC adatokkal
if kyc_in.first_name: shadow_person.first_name = kyc_in.first_name
if kyc_in.last_name: shadow_person.last_name = kyc_in.last_name
shadow_person.mothers_last_name = kyc_in.mothers_last_name
shadow_person.mothers_first_name = kyc_in.mothers_first_name
shadow_person.birth_place = kyc_in.birth_place
@@ -231,6 +233,8 @@ class AuthService:
p = shadow_person
else:
if kyc_in.first_name: p.first_name = kyc_in.first_name
if kyc_in.last_name: p.last_name = kyc_in.last_name
p.mothers_last_name = kyc_in.mothers_last_name
p.mothers_first_name = kyc_in.mothers_first_name
p.birth_place = kyc_in.birth_place
@@ -314,7 +318,10 @@ class AuthService:
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
""" Email megerősítés. """
"""
Email megerősítés.
Visszaadja az aktivált User objektumot sikeres verifikáció után (Magic Link támogatás).
"""
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(and_(
@@ -323,25 +330,28 @@ class AuthService:
VerificationToken.expires_at > datetime.now(timezone.utc)
))
token = (await db.execute(stmt)).scalar_one_or_none()
if not token: return False
if not token: return None
token.is_used = True
# Activate user
user_stmt = select(User).where(User.id == token.user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if user:
user.is_active = True
# Activate person
person_stmt = select(Person).where(Person.user_id == user.id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
if not user: return None
user.is_active = True
# Activate person
person_stmt = select(Person).where(Person.user_id == user.id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
await db.commit()
return True
except: return False
return user # Return the activated User object
except Exception as e:
logger.error(f"Email verification error: {e}")
return None
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
@@ -349,23 +359,43 @@ class AuthService:
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
# Dinamikus lejárat az adminból
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val, user_id=user.id, token_type="password_reset",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h))
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
if not user:
return "not_found"
# Ha a felhasználó létezik, de inaktív, dobjunk hibát
if not user.is_active:
logger.warning(f"Password reset requested for inactive user: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="AUTH.USER_INACTIVE"
)
await db.commit()
return "success"
return "not_found"
# Dinamikus lejárat az adminból
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val, user_id=user.id, token_type="password_reset",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h))
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
logger.info(f"Email küldés indítása (password reset) ide: {email}...")
result = await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (password reset): {result}")
# Ha az email küldés hibát dobott, ne titkoljuk el
if result.get("status") == "error":
logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Email küldési hiba: {result.get('message')}"
)
await db.commit()
return "success"
@staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
@@ -396,6 +426,57 @@ class AuthService:
raise
except: return False
@staticmethod
async def resend_verification(db: AsyncSession, email: str):
"""
Aktiváló e-mail újraküldése.
Ellenőrzi, hogy a felhasználó létezik, de még nem aktív (is_active == False),
generál egy új verifikációs tokent, és kiküldi az e-mailt.
"""
# Eager load person relationship to avoid async lazy-load issue
stmt = select(User).options(joinedload(User.person)).where(
and_(User.email == email, User.is_deleted == False)
)
user = (await db.execute(stmt)).unique().scalar_one_or_none()
if not user:
# Ne áruljuk el, hogy létezik-e az email — biztonsági okokból mindig sikert adunk
logger.info(f"Resend verification requested for non-existent email: {email}")
return "not_found"
if user.is_active:
logger.info(f"Resend verification requested for already active user: {email}")
return "already_active"
# Dinamikus lejárat az adminból
reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user.region_code, default=48)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val,
user_id=user.id,
token_type="registration",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours))
))
# Email küldés — user.person már eager-loaded, így nem okoz MissingGreenlet hibát
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
person = user.person
first_name = person.first_name if person else user.email
logger.info(f"Email küldés indítása (resend verification) ide: {email}...")
result = await email_manager.send_email(
recipient=email,
template_key="reg",
variables={"first_name": first_name, "link": verification_link},
lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (resend verification): {result}")
await db.commit()
logger.info(f"Resend verification email sent to {email}")
return "success"
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
""" Felhasználó törlése (Soft-Delete) auditálással. """

View File

@@ -81,7 +81,7 @@ class ConfigService:
Returns:
A talált érték (a megfelelő típusban) vagy a default.
"""
from sqlalchemy import select, and_, cast, String
from sqlalchemy import select, and_, cast, String, Text
try:
# Convert scope_level to string for comparison - handle both Enum and string
@@ -91,12 +91,12 @@ class ConfigService:
scope_str = str(scope_level)
# Build query with case-insensitive comparison for scope_level
# Use ilike or lower() for case-insensitive comparison since enum values might have inconsistent casing
# Use cast to Text (not String) to avoid asyncpg enum codec cache issues
from sqlalchemy import func
query = select(SystemParameter).where(
and_(
SystemParameter.key == key,
func.lower(cast(SystemParameter.scope_level, String)) == scope_str.lower(),
func.lower(cast(SystemParameter.scope_level, Text)) == scope_str.lower(),
SystemParameter.is_active == True
)
)

View File

@@ -16,27 +16,12 @@ from app.db.session import AsyncSessionLocal
logger = logging.getLogger("Email-Manager-2.0")
class EmailManager:
@staticmethod
def _get_base_url() -> str:
"""Return the appropriate base URL for email links."""
# Check environment variable first
base_url = os.getenv("EMAIL_BASE_URL")
if base_url:
return base_url.rstrip('/')
# Fallback to dev domain for external access
return "https://dev.servicefinder.hu"
@staticmethod
def _build_verification_link(token: str) -> str:
"""Build verification link with proper path."""
base = EmailManager._get_base_url()
# Use frontend verification route (adjust if needed)
return f"{base}/verify?token={token}"
@staticmethod
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
"""HTML sablon generálása a fordítási fájlok alapján."""
"""HTML sablon generálása a fordítási fájlok alapján.
The caller MUST provide a 'link' key in variables. No fallback guessing.
"""
greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
@@ -44,10 +29,10 @@ class EmailManager:
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
# If link is not provided but token is, build verification link
# Strict: only use variables.get('link'), no guessing
link = variables.get('link')
if not link and 'token' in variables:
link = EmailManager._build_verification_link(variables['token'])
if not link:
logger.error(f"No 'link' variable provided for template '{template_key}' (lang={lang})")
return f"""
<html>
@@ -83,8 +68,14 @@ class EmailManager:
session_internal = True
try:
# Check if emails are disabled via DB config
provider = await config.get_setting(db, "email_provider", default="smtp")
# --- FIX: Wrap DB config lookup in try-except with env fallback ---
# If the DB query fails (e.g. enum mismatch), fall back to environment variable
try:
provider = await config.get_setting(db, "email_provider", default=None)
except Exception as cfg_err:
logger.warning(f"DB config error reading email_provider: {cfg_err}. Falling back to ENV variables...")
provider = None
if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return {"status": "success", "provider": "disabled", "message": "Email disabled by admin config"}
@@ -92,8 +83,9 @@ class EmailManager:
html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
# Get email provider from environment
email_provider = os.getenv("EMAIL_PROVIDER", "smtp").lower()
# Determine email provider: DB config > ENV > fallback to 'smtp'
email_provider_raw = provider if provider else os.getenv("EMAIL_PROVIDER", "smtp")
email_provider = str(email_provider_raw).lower().strip()
if email_provider == "brevo_api":
result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables)
@@ -243,7 +235,14 @@ class EmailManager:
else:
logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
server.starttls()
# Try STARTTLS, but fall back to plain if server doesn't support it
# (e.g. local Mailpit for testing)
try:
server.starttls()
except smtplib.SMTPNotSupportedError:
logger.warning(f"STARTTLS not supported by {smtp_host}:{smtp_port}, sending in plain text")
except Exception:
logger.warning(f"STARTTLS failed on {smtp_host}:{smtp_port}, sending in plain text")
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)

View File

@@ -14,10 +14,25 @@
},
"EMAIL": {
"REG": {
"SUBJECT": "Confirm your registration"
"SUBJECT": "Confirm your registration - Service Finder",
"GREETING": "Hi {{first_name}}!",
"BODY": "Thank you for registering! Click the button below to activate your account:",
"BUTTON": "Activate Account",
"FOOTER": "If you did not register, please ignore this email."
},
"PWD_RESET": {
"SUBJECT": "Password Reset Request"
"SUBJECT": "Password Reset Request",
"GREETING": "Hi!",
"BODY": "You requested a password reset. Click the button below to set a new password:",
"BUTTON": "Reset Password",
"FOOTER": "If you did not request a password reset, please ignore this email."
},
"TEST": {
"SUBJECT": "🧪 Test Email - Service Finder",
"GREETING": "Hi {{first_name}}!",
"BODY": "This is a test email from the Service Finder system. If you see this, email sending is working!",
"BUTTON": "Go to Dashboard",
"FOOTER": "Service Finder - Test system message"
}
}
}

View File

@@ -19,10 +19,24 @@
"EMAIL": {
"REG": {
"SUBJECT": "Regisztráció megerősítése - Service Finder",
"GREETING": "Szia {{first_name}}! Kattints a linkre a flottád aktiválásához: {{link}}"
"GREETING": "Szia {{first_name}}!",
"BODY": "Köszönjük a regisztrációt! Kattints az alábbi gombra a fiókod aktiválásához:",
"BUTTON": "Fiók Aktiválása",
"FOOTER": "Ha nem te regisztráltál, kérjük hagyd figyelmen kívül ezt az e-mailt."
},
"PWD_RESET": {
"SUBJECT": "Jelszó visszaállítás"
"SUBJECT": "Jelszó visszaállítás",
"GREETING": "Szia!",
"BODY": "Jelszó visszaállítást kértél. Kattints az alábbi gombra az új jelszó beállításához:",
"BUTTON": "Jelszó Visszaállítása",
"FOOTER": "Ha nem te kérted a jelszó visszaállítást, kérjük hagyd figyelmen kívül ezt az e-mailt."
},
"TEST": {
"SUBJECT": "🧪 Teszt levél - Service Finder",
"GREETING": "Szia {{first_name}}!",
"BODY": "Ez egy teszt e-mail a Service Finder rendszerből. Ha ezt látod, az e-mail küldés működik!",
"BUTTON": "Irány a Dashboard",
"FOOTER": "Service Finder - Teszt rendszerüzenet"
}
},
"COMMON": {

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",
"version": "1.0.0",
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"axios": "^1.6.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
@@ -566,6 +567,12 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fingerprintjs/fingerprintjs": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz",
"integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",

View File

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

View File

@@ -115,6 +115,9 @@
<!-- Spacer to push right items -->
<div class="flex-1"></div>
<!-- Language Switcher (extracted component) -->
<LanguageSwitcher />
<!-- Right: Welcome + User Dropdown -->
<div class="relative flex items-center gap-4">
<!-- Welcome text -->
@@ -157,7 +160,7 @@
<div class="py-1">
<!-- Profile settings -->
<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"
>
<!-- User icon -->
@@ -213,9 +216,12 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const { t } = useI18n()
const props = withDefaults(
@@ -234,8 +240,9 @@ const authStore = useAuthStore()
// ── Dropdown states ────────────────────────────────────────────────
const isUserMenuOpen = 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) {
const target = e.target as HTMLElement
if (isFuncMenuOpen.value && !target.closest('.func-menu-container')) {
@@ -280,6 +287,12 @@ const initials = computed(() => {
return '?'
})
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isUserMenuOpen.value = false
router.push('/profile')
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
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
class="relative transition-transform duration-700 transform-style-3d"
:class="{
'rotate-y-180': authMode === 'register',
'rotate-y-180': authMode === 'register' || authMode === 'resend',
'rotate-x-180': authMode === 'forgot'
}"
>
@@ -50,7 +50,7 @@
<!-- Title -->
<h2 class="mb-8 text-center text-2xl font-bold text-white">
Belépés a garázsba
{{ t('auth.loginTitle') }}
</h2>
<!-- Email Input -->
@@ -128,23 +128,43 @@
</button>
</div>
<!-- Forgot Password Link (right-aligned, always visible) -->
<div class="mb-6 text-right">
<!-- Remember Me Checkbox -->
<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
href="#"
@click.prevent="switchMode('forgot')"
class="text-xs text-[#75A882] hover:text-[#D4AF37] transition-colors"
>
Elfelejtetted a jelszavad?
{{ t('auth.forgotPassword') }}
</a>
</div>
<!-- Error Message -->
<!-- Error Message (translated via i18n) -->
<div
v-if="authStore.error && authMode === 'login'"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ authStore.error }}
{{ translateError(authStore.error) }}
</div>
<!-- Login Button (Premium) -->
@@ -153,17 +173,22 @@
:disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? 'Kérlek várj...' : 'Belépés' }}
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.loginButton') }}
</button>
<!-- Divider -->
<!-- Social Login Divider (Premium) -->
<div class="my-6 flex items-center gap-3">
<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>
</div>
<!-- Social Login Placeholders -->
<!-- Social Login Buttons -->
<div class="mb-6 grid grid-cols-2 gap-3">
<button
type="button"
@@ -192,13 +217,13 @@
<!-- Registration Link (Triggers Flip) -->
<p class="text-center text-sm text-white/50">
Még nincs garázsod?
{{ t('auth.noAccount') }}
<a
href="#"
@click.prevent="switchMode('register')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
>
Regisztráció
{{ t('auth.registerLink') }}
</a>
</p>
</form>
@@ -232,7 +257,7 @@
<!-- Title -->
<h2 class="mb-8 text-center text-2xl font-bold text-white">
Új Garázs Nyitása
{{ t('auth.registerTitle') }}
</h2>
<!-- Last Name Input -->
@@ -343,12 +368,12 @@
</button>
</div>
<!-- Error Message -->
<!-- Error Message (translated via i18n) -->
<div
v-if="authStore.error && authMode === 'register'"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ authStore.error }}
{{ translateError(authStore.error) }}
</div>
<!-- Register Button (Premium) -->
@@ -357,18 +382,18 @@
:disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? 'Kérlek várj...' : 'Regisztráció' }}
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.registerButton') }}
</button>
<!-- Switch back to Login -->
<p class="mt-6 text-center text-sm text-white/50">
Már van garázsod?
{{ t('auth.haveAccount') }}
<a
href="#"
@click.prevent="switchMode('login')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
>
Belépés
{{ t('auth.loginLink') }}
</a>
</p>
</form>
@@ -402,16 +427,48 @@
<!-- Title -->
<h2 class="mb-6 text-center text-2xl font-bold text-white">
Új jelszó igénylése
{{ t('auth.forgotTitle') }}
</h2>
<!-- Description -->
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]">
Add meg az e-mail címed, és küldünk egy biztonsági linket.
<!-- Description (only show before successful send) -->
<p
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>
<!-- Email Input -->
<div class="mb-6">
<!-- Success Message -->
<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">
E-mail
</label>
@@ -426,31 +483,133 @@
/>
</div>
<!-- Error Message -->
<!-- Error Message (non-inactive errors, translated) -->
<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"
>
{{ authStore.error }}
{{ translateError(forgotError) }}
</div>
<!-- Send Link Button (Premium) -->
<!-- Send Link Button (Premium) hide after successful send or inactive -->
<button
v-if="!forgotSuccess && !isUserInactive"
type="submit"
:disabled="authStore.isLoading"
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>
<!-- Back to Login -->
<!-- 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"
>
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>
</p>
</form>
@@ -461,9 +620,13 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import FingerprintJS from '@fingerprintjs/fingerprintjs'
const { t } = useI18n()
const props = defineProps<{
visible: boolean
@@ -476,11 +639,32 @@ const emit = defineEmits<{
const router = useRouter()
const authStore = useAuthStore()
// ── Login form fields ──
const email = ref('')
// ── 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
}
}
// ── Login form fields (persisted across modal close) ──
const email = ref(localStorage.getItem('saved-email') || '')
const password = ref('')
// ── Register form fields ──
// ── Register form fields (persisted across modal close) ──
const lastName = ref('')
const firstName = ref('')
const regEmail = ref('')
@@ -488,16 +672,90 @@ const regPassword = ref('')
// ── Forgot password form fields ──
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) ──
const showPassword = ref(false)
const showRegPassword = ref(false)
// ── 3D Flip state machine ──
const authMode = ref<'login' | 'register' | 'forgot'>('login')
const activeBackFace = ref<'register' | 'forgot'>('register')
// ── Remember Me state (persisted in localStorage) ──
const rememberMe = ref(localStorage.getItem('remember-me-state') === 'true')
// ── 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(
() => props.visible,
(newVal) => {
@@ -505,14 +763,11 @@ watch(
// Reset view to login
authMode.value = 'login'
activeBackFace.value = 'register'
// Clear all form fields
email.value = ''
password.value = ''
lastName.value = ''
firstName.value = ''
regEmail.value = ''
regPassword.value = ''
forgotEmail.value = ''
forgotError.value = ''
forgotSuccess.value = false
isUserInactive.value = false
resendSuccess.value = false
resendError.value = ''
// Clear store error
authStore.clearError()
}
@@ -524,17 +779,44 @@ watch(authMode, () => {
authStore.clearError()
})
function switchMode(mode: 'login' | 'register' | 'forgot') {
function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend') {
if (mode !== 'login') {
activeBackFace.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() {
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
if (authStore.isKycComplete) {
@@ -546,7 +828,7 @@ async function handleLogin() {
// Close the modal
emit('close')
} 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,
})
// Registration successful — flip back to login so user can sign in
switchMode('login')
// Pre-fill the email field
email.value = regEmail.value
// Clear register fields
lastName.value = ''
firstName.value = ''
regEmail.value = ''
regPassword.value = ''
// Save email to localStorage for next visit
localStorage.setItem('saved-email', regEmail.value)
// Registration successful — redirect to email verification page
emit('close')
router.push('/verify')
} catch {
// Error is already set in authStore.error
}
@@ -576,16 +855,54 @@ async function handleRegister() {
// ── Forgot Password Logic ──
async function handleForgotPassword() {
forgotError.value = ''
isUserInactive.value = false
resendSuccess.value = false
try {
await authStore.forgotPassword(forgotEmail.value)
// Success — flip back to login
switchMode('login')
// Success — show green success message and "Back to login" button
forgotSuccess.value = true
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 {
// 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>
<style scoped>
@@ -621,4 +938,4 @@ async function handleForgotPassword() {
.rotate-x-180 {
transform: rotateX(180deg);
}
</style>
</style>

View File

@@ -13,6 +13,7 @@ export default {
profile: 'Profile Settings',
logout: 'Logout',
subtitle: 'Your dashboard and vehicle overview',
switchLanguage: 'Switch language',
},
dashboard: {
loading: 'Loading...',
@@ -26,4 +27,47 @@ export default {
hide: 'Hide UI (Zen mode)',
show: 'Show UI',
},
auth: {
invalid_credentials: 'Invalid email or password.',
user_inactive: 'Your account is not activated yet. Please verify your email!',
forgotPasswordMessage: 'The password reset feature is under development. Please contact the administrator!',
// Resend verification
resendTitle: 'Resend Activation Email',
resendDescription: 'Enter your email address and we will resend the activation link.',
resendButton: 'Send Email',
resend_cooldown: 'Resend ({seconds}s)',
send_email: 'Send Email',
resendSuccess: 'The activation email has been sent! Check your folders (including Spam).',
resendLink: 'Didn\'t receive the activation email?',
backToLogin: 'Back to Login',
// Login view
loginTitle: 'Login to Garage',
loginButton: 'Login',
loginLoading: 'Please wait...',
forgotPassword: 'Forgot your password?',
rememberMe: 'Remember me',
noAccount: 'Don\'t have a garage yet?',
registerLink: 'Register',
// Register view
registerTitle: 'Open New Garage',
registerButton: 'Register',
haveAccount: 'Already have a garage?',
loginLink: 'Login',
// Forgot password view
forgotTitle: 'Request New Password',
forgotDescription: 'Enter your email address and we will send you a security link.',
forgotSuccess: 'The reset link has been sent to your email!',
forgotSendButton: 'Send Link',
// Inactive user in forgot flow
inactiveMessage: 'Your account is not activated yet! Please activate using the link in the email, or request a new activation email.',
resendActivationButton: 'Resend Activation Email',
resendActivationSuccess: 'The activation email has been resent!',
// Social login
socialDivider: 'Or login with Social account',
// Errors
loginFailed: 'Login failed. Please check your credentials.',
registerFailed: 'Registration failed.',
resendFailed: 'Resend failed. Please try again later.',
forgotFailed: 'An error occurred while sending the request. Please try again.',
},
}

View File

@@ -13,6 +13,7 @@ export default {
profile: 'Profil beállítások',
logout: 'Kilépés',
subtitle: 'Irányítópultod és járműveid áttekintése',
switchLanguage: 'Nyelv váltása',
},
dashboard: {
loading: 'Betöltés...',
@@ -26,4 +27,47 @@ export default {
hide: 'UI elrejtése (Zen mód)',
show: 'UI megjelenítése',
},
auth: {
invalid_credentials: 'Hibás e-mail cím vagy jelszó.',
user_inactive: 'A fiókod még nincs aktiválva. Kérlek, erősítsd meg az e-mail címedet!',
forgotPasswordMessage: 'A jelszóvisszaállítás funkció fejlesztés alatt áll. Kérlek, fordulj az adminisztrátorhoz!',
// Resend verification
resendTitle: 'Aktiváló levél újraküldése',
resendDescription: 'Add meg az e-mail címedet, és újraküldjük az aktiváló linket.',
resendButton: 'Levél Küldése',
resend_cooldown: 'Újraküldés ({seconds} mp)',
send_email: 'Levél Küldése',
resendSuccess: 'Az aktiváló levelet elküldtük! Ellenőrizd a mappáidat (a Spam-et is).',
resendLink: 'Nem kaptad meg az aktiváló levelet?',
backToLogin: 'Vissza a belépéshez',
// Login view
loginTitle: 'Belépés a garázsba',
loginButton: 'Belépés',
loginLoading: 'Kérlek várj...',
forgotPassword: 'Elfelejtetted a jelszavad?',
rememberMe: 'Emlékezz rám',
noAccount: 'Még nincs garázsod?',
registerLink: 'Regisztráció',
// Register view
registerTitle: 'Új Garázs Nyitása',
registerButton: 'Regisztráció',
haveAccount: 'Már van garázsod?',
loginLink: 'Belépés',
// Forgot password view
forgotTitle: 'Új jelszó igénylése',
forgotDescription: 'Add meg az e-mail címed, és küldünk egy biztonsági linket.',
forgotSuccess: 'A visszaállítási linket elküldtük az e-mail címedre!',
forgotSendButton: 'Link Küldése',
// Inactive user in forgot flow
inactiveMessage: 'A fiókod még nincs aktiválva! Kérlek aktiváld az e-mail-ben kapott linkkel, vagy kérj egy új aktiváló levelet.',
resendActivationButton: 'Aktiváló levél újraküldése',
resendActivationSuccess: 'Az aktiváló levelet újraküldtük!',
// Social login
socialDivider: 'Vagy lépj be Social fiókkal',
// Errors
loginFailed: 'Bejelentkezés sikertelen. Ellenőrizd az adataidat.',
registerFailed: 'Regisztráció sikertelen.',
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
forgotFailed: 'Hiba történt a kérelem elküldésekor. Kérlek próbáld újra.',
},
}

View File

@@ -12,9 +12,31 @@ const messages = {
en,
}
/**
* Determine the initial locale:
* 1. If a saved locale exists in localStorage, use it.
* 2. Otherwise, detect the browser language (navigator.language).
* If it starts with 'hu', use 'hu'. For everything else, use 'en'.
*/
function detectLocale(): string {
const saved = localStorage.getItem('user-locale')
if (saved) return saved
if (typeof navigator !== 'undefined' && navigator.language) {
const browserLang = navigator.language.toLowerCase()
if (browserLang === 'hu' || browserLang.startsWith('hu-')) {
return 'hu'
}
}
return 'en'
}
const initialLocale = detectLocale()
const i18n = createI18n({
legacy: false,
locale: 'hu',
locale: initialLocale,
fallbackLocale: 'en',
messages,
})

View File

@@ -20,22 +20,48 @@ const router = createRouter({
name: 'complete-kyc',
// Lazy-loaded — placeholder until the actual KYC view is built
component: () => import('../views/CompleteKycView.vue')
},
{
path: '/profile',
name: 'profile',
// Auth-protected profile settings page
component: () => import('../views/ProfileView.vue'),
meta: { requiresAuth: true }
},
{
path: '/verify',
name: 'verify',
// Email verification page (accessible without auth)
component: () => import('../views/VerifyEmailView.vue')
},
{
path: '/reset-password',
name: 'reset-password',
// Password reset page — reads ?token= from query param
component: () => import('../views/ResetPasswordView.vue')
}
]
})
// ── Global navigation guard ──────────────────────────────────────────
// If an authenticated user navigates to the root ("/"), redirect to "/dashboard".
// Protected routes (meta.requiresAuth) redirect unauthenticated users to "/".
router.beforeEach(async (to, _from, next) => {
const { useAuthStore } = await import('../stores/auth')
const authStore = useAuthStore()
// Redirect authenticated users away from landing
if (to.path === '/') {
// Lazy-access the Pinia store inside the guard to avoid any
// circular-dependency issues at module-load time.
const { useAuthStore } = await import('../stores/auth')
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
return next('/dashboard')
}
}
// Protect routes that require authentication
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return next('/')
}
next()
})

View File

@@ -3,6 +3,34 @@ import { ref, computed } from 'vue'
import router from '../router'
import api from '../api/axios'
export interface AddressData {
zip: string | null
city: string | null
street_name: string | null
street_type: string | null
house_number: string | null
stairwell: string | null
floor: string | null
door: string | null
parcel_id: string | null
full_address_text: string | null
}
export interface PersonData {
id: number
first_name: string | null
last_name: string | null
phone: string | null
mothers_last_name: string | null
mothers_first_name: string | null
birth_place: string | null
birth_date: string | null
identity_docs: Record<string, any> | null
ice_contact: Record<string, any> | null
is_active: boolean
address: AddressData | null
}
export interface UserProfile {
id: number
email: string
@@ -16,8 +44,11 @@ export interface UserProfile {
scope_id: string | null
ui_mode: string
active_organization_id: number | null
preferred_language: string
// person_id is set when KYC is complete (Person record exists)
person_id: number | null
// Nested person data with address
person: PersonData | null
}
export const useAuthStore = defineStore('auth', () => {
@@ -42,14 +73,64 @@ export const useAuthStore = defineStore('auth', () => {
})
const userId = computed(() => user.value?.id ?? null)
// ────────────────────────────── Helpers ────────────────────────────
/**
* Extract the raw error code from the backend response.
* Backend returns codes like "[AUTH.INVALID_CREDENTIALS]" or plain strings.
* Strips brackets and returns the clean code, or null if not a code.
*/
function extractErrorCode(err: any): string | null {
const detail = err.response?.data?.detail || err.response?.data?.message || ''
if (!detail) return null
// Strip surrounding brackets: [AUTH.INVALID_CREDENTIALS] -> AUTH.INVALID_CREDENTIALS
const match = detail.match(/^\[?([A-Z]+\.[A-Z_]+)\]?$/)
return match ? match[1] : null
}
/**
* Map a backend error code to a user-friendly i18n key path.
* Returns the i18n key (e.g. 'auth.invalid_credentials') or null if unmapped.
*/
function mapErrorCodeToI18nKey(code: string): string | null {
const map: Record<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 ────────────────────────────
/**
* Login with email + password.
* The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm,
* which expects application/x-www-form-urlencoded with `username` and `password`.
* Supports optional device_fingerprint for Device-Hub tracking.
*/
async function login(email: string, password: string) {
async function login(email: string, password: string, deviceFingerprint?: string) {
isLoading.value = true
error.value = null
@@ -57,6 +138,9 @@ export const useAuthStore = defineStore('auth', () => {
const body = new URLSearchParams()
body.append('username', email) // FastAPI OAuth2 form uses `username` key
body.append('password', password)
if (deviceFingerprint) {
body.append('device_fingerprint', deviceFingerprint)
}
const res = await api.post('/auth/login', body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -78,12 +162,16 @@ export const useAuthStore = defineStore('auth', () => {
// Immediately fetch the user profile
await fetchUser()
// Clear pending verification email after successful login
localStorage.removeItem('pending_verification_email')
// If KYC is not complete, redirect to KYC page
if (!isKycComplete.value) {
router.push('/complete-kyc')
}
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Bejelentkezés sikertelen. Ellenőrizd az adataidat.'
error.value = message
error.value = getErrorMessage(err, 'auth.loginFailed')
throw err
} finally {
isLoading.value = false
@@ -132,6 +220,9 @@ export const useAuthStore = defineStore('auth', () => {
const res = await api.post('/auth/register', payload)
// Save email to localStorage for verification page auto-fill
localStorage.setItem('pending_verification_email', data.email)
return res.data as {
status: string
message: string
@@ -139,11 +230,26 @@ export const useAuthStore = defineStore('auth', () => {
email: string
}
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Regisztráció sikertelen.'
error.value = message
error.value = getErrorMessage(err, 'auth.registerFailed')
throw err
} finally {
isLoading.value = false
}
}
/**
* Resend the verification email.
* POST /auth/resend-verification with { email }.
*/
async function resendVerification(email: string) {
isLoading.value = true
error.value = null
try {
const res = await api.post('/auth/resend-verification', { email })
return res.data as { status: string; message: string }
} catch (err: any) {
error.value = getErrorMessage(err, 'auth.resendFailed')
throw err
} finally {
isLoading.value = false
@@ -161,11 +267,35 @@ export const useAuthStore = defineStore('auth', () => {
const res = await api.post('/auth/forgot-password', { email })
return res.data as { status: string; message: string }
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Hiba történt a jelszó-visszaállítási kérelem elküldésekor.'
error.value = message
const code = extractErrorCode(err)
if (code === 'AUTH.USER_INACTIVE') {
error.value = 'auth.user_inactive'
} else {
error.value = getErrorMessage(err, 'auth.forgotFailed')
}
throw err
} finally {
isLoading.value = false
}
}
/**
* Reset password using token from email.
* POST /auth/reset-password with { email, token, new_password }.
*/
async function resetPassword(email: string, token: string, newPassword: string) {
isLoading.value = true
error.value = null
try {
const res = await api.post('/auth/reset-password', {
email,
token,
new_password: newPassword,
})
return res.data as { status: string; message: string }
} catch (err: any) {
error.value = getErrorMessage(err, 'auth.resetPasswordFailed')
throw err
} finally {
isLoading.value = false
@@ -198,6 +328,56 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* Verify email account using the token from the activation link (Magic Link).
* POST /auth/verify-email with { token, device_fingerprint }.
* Now returns JWT tokens on success, automatically logging the user in.
* Supports optional device_fingerprint for Device-Hub tracking.
*/
async function verifyAccount(verificationToken: string, deviceFingerprint?: string) {
isLoading.value = true
error.value = null
try {
const payload: Record<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.
* POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile.
@@ -213,11 +393,52 @@ export const useAuthStore = defineStore('auth', () => {
await fetchUser()
return true
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'A KYC kitöltése sikertelen. Kérlek ellenőrizd az adatokat.'
error.value = message
error.value = getErrorMessage(err, 'auth.kycFailed')
throw err
} finally {
isLoading.value = false
}
}
/**
* Update the user's Person record (profile data + address).
* PUT /users/me/person with PersonUpdate schema.
* On success, refreshes the local user state with the response.
*/
async function updatePerson(personData: Record<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
} finally {
isLoading.value = false
@@ -249,8 +470,13 @@ export const useAuthStore = defineStore('auth', () => {
login,
fetchUser,
register,
resendVerification,
verifyAccount,
forgotPassword,
resetPassword,
completeKyc,
updatePerson,
changePassword,
logout,
init,
clearError,

View File

@@ -79,10 +79,10 @@
</div>
</div>
<!-- ==================== STEP 2: Address ==================== -->
<!-- ==================== STEP 2: Address (Soft KYC) ==================== -->
<div v-if="currentStep === 2">
<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">
<!-- Country selector (EU-ready) -->
@@ -141,7 +141,7 @@
<div class="grid grid-cols-3 gap-3">
<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
v-model="kycForm.address_street_name"
type="text"
@@ -150,7 +150,7 @@
/>
</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
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"
@@ -171,12 +171,13 @@
</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
v-model="kycForm.address_house_number"
type="text"
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"
@keydown.tab.exact="handleHouseNumberTab"
/>
</div>
</div>
@@ -185,7 +186,7 @@
<!-- ==================== STEP 3: Security ==================== -->
<div v-if="currentStep === 3">
<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">
<!-- Identity Documents -->
@@ -193,8 +194,8 @@
<!-- ID Card -->
<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="w-2 h-2 rounded-full bg-[#00E5A0]" />
<span class="text-sm text-white/80">Személyi igazolvány</span>
<div class="w-2 h-2 rounded-full bg-white/20" />
<span class="text-sm text-white/50">Személyi igazolvány <span class="text-white/20">(opcionális)</span></span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
@@ -288,6 +289,7 @@
<button
v-if="currentStep < 3"
@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"
>
Tovább →
@@ -330,7 +332,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ref, reactive, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
@@ -348,6 +350,7 @@ const currentStep = ref(1)
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const isCityLoading = ref(false)
const nextButton = ref<HTMLButtonElement | null>(null)
// ── KYC Form (minimal — only DB-required fields) ──
const kycForm = reactive({
@@ -367,7 +370,7 @@ const kycForm = reactive({
preferred_currency: 'HUF',
})
// ── Debounced ZIP → City auto-fill via zippopotam.us ──
// ── Debounced ZIP → City auto-fill via internal API ──
let zipTimeout: ReturnType<typeof setTimeout>
watch(
() => kycForm.address_zip,
@@ -377,11 +380,11 @@ watch(
isCityLoading.value = true
zipTimeout = setTimeout(async () => {
try {
const country = kycForm.region_code.toLowerCase()
const response = await fetch(`https://api.zippopotam.us/${country}/${newZip}`)
const country = kycForm.region_code || 'HU'
const response = await fetch(`/api/v1/system/zip-lookup?country_code=${country}&zip_code=${newZip}`)
if (response.ok) {
const data = await response.json()
kycForm.address_city = data.places[0]['place name']
kycForm.address_city = data.city
}
} catch (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'
}
// ── Simple step validation ──
// ── Simple step validation (Soft KYC) ──
function validateStep(): boolean {
errorMessage.value = null
@@ -419,16 +422,11 @@ function validateStep(): boolean {
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_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 }
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) {
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 }
// Soft KYC: Utca, típus, házszám már NEM kötelező
}
// Step 3 has no required validations anymore (Soft KYC - all optional)
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 ──
async function handleSubmit() {
if (!validateStep()) return
@@ -455,34 +463,42 @@ async function handleSubmit() {
errorMessage.value = null
try {
// Build the minimal payload matching UserKYCComplete schema
const payload = {
last_name: kycForm.last_name,
// Build the payload matching UserKYCComplete schema (Soft KYC)
const payload: any = {
first_name: kycForm.first_name,
last_name: kycForm.last_name,
region_code: kycForm.region_code,
address_zip: kycForm.address_zip,
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_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)
// Navigate to dashboard on success

View File

@@ -3,6 +3,22 @@
<!-- Garage Background Image (no dark overlay bright, light garage) -->
<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>
<!-- 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>
<!-- Spatial UI Layer: Hotspots (always visible, even in Zen mode) -->
@@ -137,15 +153,15 @@
</svg>
</button>
<!-- Top Navbar (always visible, even in Zen mode) -->
<DashboardHeader
:first-name="authStore.user?.first_name || 'Vendég'"
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">
<!-- Top Navbar -->
<DashboardHeader
:first-name="authStore.user?.first_name || 'Vendég'"
subtitle="Irányítópultod és járműveid áttekintése"
/>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Loading State -->
<div
@@ -250,10 +266,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import DashboardHeader from '../components/DashboardHeader.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()

View File

@@ -17,13 +17,16 @@
<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>
<!-- Right: "Garázs Nyitása" Button (Premium) -->
<button
@click="showLogin = true"
class="btn-premium px-6 py-2 text-sm"
>
Garázs Nyitása
</button>
<!-- Right: Language Switcher + "Garázs Nyitása" Button (Premium) -->
<div class="flex items-center gap-3">
<LanguageSwitcher />
<button
@click="showLogin = true"
class="btn-premium px-6 py-2 text-sm"
>
Garázs Nyitása
</button>
</div>
</div>
</header>
@@ -168,6 +171,7 @@
import { ref, onMounted, onUnmounted } from 'vue'
import Logo from '@/components/Logo.vue'
import LoginModal from '@/components/LoginModal.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const showLogin = ref(false)
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': {
target: 'http://sf_api:8000',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, '')
secure: false
}
}
}

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