ソースを参照

feat: implement multi-language email verification workflow

unknown 2 日 前
コミット
a14a7e790a

+ 9 - 0
backend/config.py

@@ -46,3 +46,12 @@ EFI_BUS_UNIT = os.getenv("EFI_BUS_UNIT", "br123")
 EFI_OPERATOR = os.getenv("EFI_OPERATOR", "op123")      
 EFI_STAGING = os.getenv("EFI_STAGING", "True").lower() == "true"
 GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "254513580225-j893ad8nd2f2celd2thn1l42miqm9e7s.apps.googleusercontent.com")
+
+# SMTP Configuration (Local MTA)
+SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
+SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
+SMTP_USER = os.getenv("SMTP_USER", "")
+SMTP_PASS = os.getenv("SMTP_PASS", "")
+SMTP_FROM = os.getenv("SMTP_FROM", "noreply@radionica3d.me")
+# Frontend URL for links in emails
+FRONTEND_URL = os.getenv("FRONTEND_URL", "https://radionica3d.me")

+ 33 - 5
backend/routers/auth.py

@@ -11,6 +11,8 @@ from datetime import datetime, timedelta
 import locales
 from dependencies import get_current_user, require_admin
 import config
+import secrets
+from services.email_service import send_verification_email
 
 try:
     from google.oauth2 import id_token
@@ -31,15 +33,39 @@ async def register(request: Request, user: schemas.UserCreate, lang: str = "en")
     hashed_password = auth_utils.get_password_hash(user.password)
     
     query = """
-    INSERT INTO users (email, password_hash, first_name, last_name, phone, shipping_address, preferred_language, role, ip_address, is_company, company_name, company_pib, company_address)
-    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+    INSERT INTO users (email, password_hash, first_name, last_name, phone, shipping_address, preferred_language, role, ip_address, is_company, company_name, company_pib, company_address, is_active)
+    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0)
     """
     params = (user.email, hashed_password, user.first_name, user.last_name, user.phone, user.shipping_address, user.preferred_language, 'user', ip_address, user.is_company, user.company_name, user.company_pib, user.company_address)
     
     user_id = db.execute_commit(query, params)
+    
+    # Generate Verification Token
+    token = secrets.token_urlsafe(32)
+    expires_at = datetime.utcnow() + timedelta(hours=24)
+    db.execute_commit("INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES (%s, %s, %s)", (user_id, token, expires_at))
+    
+    # Send Email
+    send_verification_email(user.email, token, user.preferred_language or lang)
+
     new_user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, can_chat, is_active, is_company, company_name, company_pib, company_address, ip_address, created_at FROM users WHERE id = %s", (user_id,))
     return new_user[0]
 
+@router.get("/verify-email")
+async def verify_email(token: str, lang: str = "en"):
+    res = db.execute_query("SELECT user_id, expires_at FROM email_verification_tokens WHERE token = %s", (token,))
+    if not res:
+        raise HTTPException(status_code=400, detail="Invalid verification token")
+    
+    user_id, expires_at = res[0]['user_id'], res[0]['expires_at']
+    if expires_at < datetime.utcnow():
+        raise HTTPException(status_code=400, detail="Verification token expired")
+    
+    db.execute_commit("UPDATE users SET is_active = 1 WHERE id = %s", (user_id,))
+    db.execute_commit("DELETE FROM email_verification_tokens WHERE user_id = %s", (user_id,))
+    
+    return {"message": "Email verified successfully. You can now log in."}
+
 @router.post("/login", response_model=schemas.Token)
 async def login(request: Request, user_data: schemas.UserLogin, lang: str = "en"):
     ip = request.client.host if request.client else "unknown"
@@ -75,7 +101,9 @@ async def login(request: Request, user_data: schemas.UserLogin, lang: str = "en"
         raise HTTPException(status_code=401, detail=locales.translate_error("incorrect_credentials", lang))
     
     if not user[0].get('is_active', True):
-        raise HTTPException(status_code=403, detail="Your account has been suspended.")
+        # We assume if it's 0 it might be unverified or suspended. 
+        # For simplicity, let's say "Account not active".
+        raise HTTPException(status_code=403, detail="Your account is not active. Please verify your email or contact support.")
     
     # 5. Success - Reset Rate Limits
     rate_limit_service.reset_attempts(email, ip)
@@ -136,8 +164,8 @@ async def social_login(request: Request, data: schemas.SocialLogin):
         hashed_password = auth_utils.get_password_hash(str(uuid.uuid4()))
         
         query = """
-        INSERT INTO users (email, password_hash, first_name, last_name, preferred_language, role, ip_address) 
-        VALUES (%s, %s, %s, %s, %s, %s, %s)
+        INSERT INTO users (email, password_hash, first_name, last_name, preferred_language, role, ip_address, is_active) 
+        VALUES (%s, %s, %s, %s, %s, %s, %s, 1)
         """
         params = (email, hashed_password, first_name, last_name, data.preferred_language, 'user', ip_address)
         user_id = db.execute_commit(query, params)

+ 70 - 0
backend/services/email_service.py

@@ -0,0 +1,70 @@
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+import config
+
+def send_email(to_email: str, subject: str, body_html: str):
+    try:
+        msg = MIMEMultipart("alternative")
+        msg["Subject"] = subject
+        msg["From"] = config.SMTP_FROM
+        msg["To"] = to_email
+
+        html_part = MIMEText(body_html, "html")
+        msg.attach(html_part)
+
+        with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as server:
+            if config.SMTP_USER and config.SMTP_PASS:
+                server.starttls()
+                server.login(config.SMTP_USER, config.SMTP_PASS)
+            server.sendmail(config.SMTP_FROM, to_email, msg.as_string())
+        return True
+    except Exception as e:
+        print(f"FAILED TO SEND EMAIL: {e}")
+        return False
+
+def send_verification_email(to_email: str, token: str, lang: str = "en"):
+    verify_url = f"{config.FRONTEND_URL}/{lang}/auth?verify_token={token}"
+    
+    # Simple templates based on language
+    templates = {
+        "en": {
+            "subject": "Verify your Radionica 3D account",
+            "body": f"""
+                <h2>Welcome to Radionica 3D!</h2>
+                <p>Please click the link below to verify your email address and activate your account:</p>
+                <p><a href="{verify_url}" style="padding: 10px 20px; background-color: #000; color: #fff; text-decoration: none; border-radius: 5px;">Verify Email</a></p>
+                <p>Or copy this link: {verify_url}</p>
+            """
+        },
+        "me": {
+            "subject": "Potvrdite vaš Radionica 3D nalog",
+            "body": f"""
+                <h2>Dobrodošli u Radionicu 3D!</h2>
+                <p>Kliknite na link ispod kako biste potvrdili vašu email adresu и aktivirali nalog:</p>
+                <p><a href="{verify_url}" style="padding: 10px 20px; background-color: #000; color: #fff; text-decoration: none; border-radius: 5px;">Potvrdi Email</a></p>
+                <p>Ili kopirajte ovaj link: {verify_url}</p>
+            """
+        },
+        "ru": {
+            "subject": "Подтвердите вашу учетную запись Radionica 3D",
+            "body": f"""
+                <h2>Добро пожаловать в Radionica 3D!</h2>
+                <p>Пожалуйста, нажмите на ссылку ниже, чтобы подтвердить свой email и активировать аккаунт:</p>
+                <p><a href="{verify_url}" style="padding: 10px 20px; background-color: #000; color: #fff; text-decoration: none; border-radius: 5px;">Подтвердить Email</a></p>
+                <p>Или скопируйте эту ссылку: {verify_url}</p>
+            """
+        },
+        "ua": {
+            "subject": "Підтвердіть вашу обліковий запис Radionica 3D",
+            "body": f"""
+                <h2>Ласкаво просимо до Radionica 3D!</h2>
+                <p>Будь ласка, натисніть на посилання нижче, щоб підтвердити свій email та активувати акаунт:</p>
+                <p><a href="{verify_url}" style="padding: 10px 20px; background-color: #000; color: #fff; text-decoration: none; border-radius: 5px;">Підтвердити Email</a></p>
+                <p>Або скопіюйте це посилання: {verify_url}</p>
+            """
+        }
+    }
+    
+    tpl = templates.get(lang, templates["en"])
+    return send_email(to_email, tpl["subject"], tpl["body"])

+ 8 - 0
src/lib/api.ts

@@ -146,6 +146,14 @@ export const socialLogin = async (socialData: any) => {
   return response.json();
 };
 
+export const verifyEmail = async (token: string) => {
+  const response = await fetch(`${API_BASE_URL}/auth/verify-email?token=${token}&lang=${i18n.global.locale.value}`);
+  if (!response.ok) {
+    throw new Error(await getErrorMessage(response, 'Verification failed'));
+  }
+  return response.json();
+};
+
 export const getCurrentUser = async () => {
   const token = localStorage.getItem("token");
   if (!token) return null;

+ 16 - 4
src/locales/translations.user.json

@@ -342,10 +342,16 @@
     },
     "toasts": {
       "accountCreated": {
-        "en": "Account created! Please log in.",
-        "me": "Nalog je kreiran! Molimo prijavite se.",
-        "ru": "Аккаунт создан! Теперь можно войти.",
-        "ua": "Акаунт створено! Тепер можна увійти."
+        "en": "Account created!",
+        "me": "Nalog je kreiran!",
+        "ru": "Аккаунт создан!",
+        "ua": "Акаунт створено!"
+      },
+      "checkEmailForVerify": {
+        "en": "Please check your email to verify your account.",
+        "me": "Molimo provjerite svoj email kako biste aktivirali nalog.",
+        "ru": "Пожалуйста, проверьте почту для подтверждения аккаунта.",
+        "ua": "Будь ласка, перевірте пошту для підтвердження акаунта."
       },
       "passwordChanged": {
         "en": "Password changed successfully!",
@@ -371,6 +377,12 @@
         "ru": "Вход через {provider} скоро появится!",
         "ua": "Вхід через {provider} скоро з'явиться!"
       },
+      "verified": {
+        "en": "Account verified! You can now log in.",
+        "me": "Nalog je potvrđen! Sada se možete prijaviti.",
+        "ru": "Аккаунт подтвержден! Теперь можно войти.",
+        "ua": "Акаунт підтверджено! Тепер можна увійти."
+      },
       "welcomeBack": {
         "en": "Welcome back!",
         "me": "Dobrodošao nazad!",

+ 21 - 2
src/pages/Auth.vue

@@ -243,10 +243,26 @@ const formData = reactive({
   captcha: ""
 });
 
-onMounted(() => {
+onMounted(async () => {
   const token = route.query.token as string;
+  const verifyToken = route.query.verify_token as string;
+
   if (token) { mode.value = "reset"; formData.token = token; }
   
+  if (verifyToken) {
+    isLoading.value = true;
+    try {
+      const { verifyEmail } = await import("@/lib/api");
+      await verifyEmail(verifyToken);
+      toast.success(t("auth.toasts.verified"));
+      mode.value = "login";
+    } catch (err: any) {
+      toast.error(err.message || "Verification failed");
+    } finally {
+      isLoading.value = false;
+    }
+  }
+  
   // Load Google Identity Services script
   const script = document.createElement('script');
   script.src = 'https://accounts.google.com/gsi/client';
@@ -357,7 +373,10 @@ async function handleSubmit() {
         company_pib: formData.is_company ? formData.company_pib : null,
         company_address: formData.is_company ? formData.company_address : null
       });
-      toast.success(t("auth.toasts.accountCreated"));
+      toast.success(t("auth.toasts.accountCreated"), {
+        description: t("auth.toasts.checkEmailForVerify"),
+        duration: 10000
+      });
       mode.value = "login";
     } else if (mode.value === "forgot") {
       const res = await forgotPassword(formData.email);