Spaces:
Running on Zero
Running on Zero
| import json | |
| import re | |
| from datetime import datetime, timezone | |
| from typing import Any, Dict, List, Optional, Tuple | |
| from uuid import uuid4 | |
| from urllib.parse import urlparse | |
| from fastapi import APIRouter, Body, Header, HTTPException, Request | |
| from fastapi.responses import HTMLResponse | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| import requests | |
| from app.core.config import ( | |
| SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, | |
| SUPABASE_TIMEOUT_SECONDS, PASSWORD_RESET_REDIRECT_URL, | |
| INTERNAL_LOGIN_EMAIL_SUFFIX, USERNAME_PATTERN, PHONE_PATTERN, NATIONAL_ID_PATTERN, | |
| supabase_configured, LOGGER, | |
| ) | |
| from app.security.auth import ( | |
| require_authenticated_user, _normalize_spaces, | |
| _supabase_headers, _extract_response_error_message, | |
| is_valid_redirect_url, _looks_like_jwt, | |
| _extract_bearer_token, verify_barrier_token, verify_supabase_user_token, | |
| ) | |
| from app.services.supabase_client import ( | |
| supabase_get_profile_by_user_id, supabase_get_profile_by_field, | |
| supabase_find_profile_by_identifier, supabase_password_login, | |
| upsert_profile, | |
| ) | |
| from app.api.helpers import ( | |
| _resolve_profile_name_fields, _compose_full_name, _normalize_parking_location, | |
| _extract_registration_vehicle_payload, _create_vehicle_for_owner, | |
| _ensure_auth_profile_schema_ready, _upsert_profile_after_register, | |
| _missing_schema_hint, _is_supabase_duplicate_auth_user_error, | |
| _find_supabase_auth_user_by_email, _update_supabase_auth_user, | |
| _extract_supabase_session_tokens, _resolve_public_base_url, | |
| _resolve_default_password_reset_redirect_url, _resolve_password_reset_deep_link_url, | |
| _is_supabase_duplicate_auth_user_error, _is_internal_login_email, | |
| _build_internal_login_email, _is_valid_http_url, | |
| _normalize_otp_channel, _resolve_otp_identifier, | |
| _request_supabase_otp, _verify_supabase_otp, _supabase_upsert_profile, | |
| ) | |
| router = APIRouter(prefix="/auth", tags=["auth"]) | |
| TZ_UTC = timezone.utc | |
| def auth_register(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| _ensure_auth_profile_schema_ready() | |
| username = _normalize_spaces(str(payload.get("username") or "")) | |
| phone_number = _normalize_spaces(str(payload.get("phone") or payload.get("phone_number") or "")) | |
| email = _normalize_spaces(str(payload.get("email") or "")).lower() | |
| national_id = _normalize_spaces(str(payload.get("nid") or payload.get("national_id") or "")) | |
| password = str(payload.get("password") or "").strip() | |
| first_name, last_name, full_name = _resolve_profile_name_fields(payload, fallback_first_name=username) | |
| if not first_name: | |
| first_name = username | |
| full_name = _compose_full_name(first_name, last_name) or username | |
| if not username or not USERNAME_PATTERN.fullmatch(username): | |
| raise HTTPException(status_code=400, detail="username is required (3-32 chars, letters, numbers, underscore, dot, or dash).") | |
| if not phone_number or not PHONE_PATTERN.fullmatch(phone_number): | |
| raise HTTPException(status_code=400, detail="phone is required (7-15 digits, may start with +).") | |
| if email and "@" not in email: | |
| raise HTTPException(status_code=400, detail="email format is invalid.") | |
| if national_id and not NATIONAL_ID_PATTERN.fullmatch(national_id): | |
| raise HTTPException(status_code=400, detail="nid format is invalid.") | |
| if len(password) < 6: | |
| raise HTTPException(status_code=400, detail="Password must be at least 6 characters.") | |
| registration_email = email or _build_internal_login_email(username, phone_number) | |
| existing_auth_user_id: Optional[str] = None | |
| if email: | |
| existing_auth_user = _find_supabase_auth_user_by_email(registration_email) | |
| existing_auth_user_id = str((existing_auth_user or {}).get("id") or "").strip() or None | |
| def _has_profile_conflict(profile_row: Optional[Dict[str, Any]]) -> bool: | |
| if not isinstance(profile_row, dict): | |
| return False | |
| profile_user_id = str(profile_row.get("id") or "").strip() | |
| if not profile_user_id: | |
| return True | |
| if existing_auth_user_id and profile_user_id == existing_auth_user_id: | |
| return False | |
| return True | |
| if _has_profile_conflict(supabase_get_profile_by_field("username", username)): | |
| raise HTTPException(status_code=409, detail="username is already in use.") | |
| if _has_profile_conflict(supabase_get_profile_by_field("phone_number", phone_number)): | |
| raise HTTPException(status_code=409, detail="phone is already in use.") | |
| if national_id and _has_profile_conflict(supabase_get_profile_by_field("national_id", national_id)): | |
| raise HTTPException(status_code=409, detail="nid is already in use.") | |
| if email and _has_profile_conflict(supabase_get_profile_by_field("email", email, case_insensitive=True)): | |
| raise HTTPException(status_code=409, detail="email is already in use.") | |
| vehicle_payload = _extract_registration_vehicle_payload(payload) | |
| auth_user_metadata = { | |
| "first_name": first_name, "last_name": last_name, "full_name": full_name, | |
| "username": username, "phone_number": phone_number, "national_id": national_id, | |
| } | |
| auth_payload = {"email": registration_email, "password": password, "email_confirm": True, "user_metadata": auth_user_metadata} | |
| user_id: Optional[str] = None | |
| if existing_auth_user_id: | |
| try: | |
| _update_supabase_auth_user(existing_auth_user_id, {"email": registration_email, "password": password, "email_confirm": True, "user_metadata": auth_user_metadata}) | |
| except RuntimeError as exc: | |
| raise HTTPException(status_code=502, detail=f"Register failed: {exc}") from exc | |
| user_id = existing_auth_user_id | |
| else: | |
| try: | |
| response = requests.post( | |
| f"{SUPABASE_URL}/auth/v1/admin/users", | |
| json=auth_payload, | |
| headers={"apikey": SUPABASE_SERVICE_ROLE_KEY, "Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}", "Content-Type": "application/json"}, | |
| timeout=SUPABASE_TIMEOUT_SECONDS, | |
| ) | |
| except requests.RequestException as exc: | |
| raise HTTPException(status_code=503, detail=f"Supabase register failed: {exc}") from exc | |
| if response.status_code in {200, 201}: | |
| auth_user = response.json() if response.text else {} | |
| if not isinstance(auth_user, dict): | |
| auth_user = {} | |
| user_id = str(auth_user.get("id") or auth_user.get("user", {}).get("id") or "").strip() | |
| else: | |
| error_msg = _extract_response_error_message(response) | |
| if registration_email and _is_supabase_duplicate_auth_user_error(error_msg): | |
| existing_auth_user = _find_supabase_auth_user_by_email(registration_email) | |
| existing_user_id = str((existing_auth_user or {}).get("id") or "").strip() | |
| if not existing_user_id: | |
| raise HTTPException(status_code=409, detail="Register failed: email is already in use.") | |
| try: | |
| _update_supabase_auth_user(existing_user_id, {"email": registration_email, "password": password, "email_confirm": True, "user_metadata": auth_user_metadata}) | |
| except RuntimeError as exc: | |
| raise HTTPException(status_code=502, detail=f"Register failed: {exc}") from exc | |
| user_id = existing_user_id | |
| else: | |
| raise HTTPException(status_code=response.status_code, detail=f"Register failed: {error_msg}") | |
| if not user_id: | |
| raise HTTPException(status_code=502, detail="Supabase did not return a user id.") | |
| profile = _upsert_profile_after_register(user_id=user_id, first_name=first_name, last_name=last_name, username=username, phone_number=phone_number, email=registration_email, national_id=national_id or None, role=str(payload.get("role") or "admin")) | |
| created_vehicle = _create_vehicle_for_owner(owner_id=user_id, vehicle_payload=vehicle_payload) if vehicle_payload else None | |
| session_data, login_error = supabase_password_login(password=password, email=registration_email) | |
| if not isinstance(session_data, dict): | |
| raise HTTPException(status_code=502, detail=f"Registration succeeded but login failed: {login_error}") | |
| resolved_profile = profile if isinstance(profile, dict) else supabase_get_profile_by_user_id(user_id) | |
| role = str((resolved_profile or {}).get("role") or "user") | |
| resolved_first_name, resolved_last_name, _ = _resolve_profile_name_fields(resolved_profile if isinstance(resolved_profile, dict) else None, fallback_full_name=full_name, fallback_first_name=first_name, fallback_last_name=last_name) | |
| return { | |
| "status": "ok", | |
| "user": { | |
| "id": user_id, "first_name": resolved_first_name, "last_name": resolved_last_name, | |
| "full_name": resolved_profile.get("full_name") if resolved_profile else full_name, | |
| "username": (resolved_profile or {}).get("username") or username, | |
| "phone_number": (resolved_profile or {}).get("phone_number") or phone_number, | |
| "email": (resolved_profile or {}).get("email") or registration_email, | |
| "national_id": (resolved_profile or {}).get("national_id"), "role": role, | |
| "is_staff": role in {"admin", "security"}, | |
| }, | |
| "session": {"access_token": session_data.get("access_token"), "refresh_token": session_data.get("refresh_token"), "expires_in": session_data.get("expires_in")}, | |
| "vehicle": created_vehicle, | |
| } | |
| def auth_login(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| _ensure_auth_profile_schema_ready() | |
| identifier = _normalize_spaces(str(payload.get("identifier") or payload.get("email") or payload.get("phone") or payload.get("phone_number") or payload.get("username") or payload.get("nid") or payload.get("national_id") or "")) | |
| password = str(payload.get("password") or "").strip() | |
| if not identifier or not password: | |
| raise HTTPException(status_code=400, detail="identifier and password are required.") | |
| profile = supabase_find_profile_by_identifier(identifier) | |
| login_candidates: List[Tuple[Optional[str], Optional[str]]] = [] | |
| if isinstance(profile, dict): | |
| profile_email = _normalize_spaces(str(profile.get("email") or "")).lower() | |
| profile_phone = _normalize_spaces(str(profile.get("phone_number") or "")) | |
| if profile_email: | |
| login_candidates.append((profile_email, None)) | |
| if profile_phone: | |
| login_candidates.append((None, profile_phone)) | |
| if "@" in identifier: | |
| login_candidates.append((_normalize_spaces(identifier).lower(), None)) | |
| if PHONE_PATTERN.fullmatch(identifier): | |
| login_candidates.append((None, identifier)) | |
| if not login_candidates: | |
| raise HTTPException(status_code=401, detail="Login failed: account was not found.") | |
| data: Optional[Dict[str, Any]] = None | |
| last_error = "Invalid login credentials" | |
| seen_candidates = set() | |
| for candidate_email, candidate_phone in login_candidates: | |
| candidate_key = (candidate_email or "", candidate_phone or "") | |
| if candidate_key in seen_candidates: | |
| continue | |
| seen_candidates.add(candidate_key) | |
| login_result, login_error = supabase_password_login(password=password, email=candidate_email, phone_number=candidate_phone) | |
| if isinstance(login_result, dict): | |
| data = login_result | |
| break | |
| if login_error: | |
| last_error = login_error | |
| if not isinstance(data, dict): | |
| raise HTTPException(status_code=401, detail=f"Login failed: {last_error}") | |
| user_id = str(data.get("user", {}).get("id") or "").strip() | |
| resolved_profile = supabase_get_profile_by_user_id(user_id) if user_id else profile | |
| if isinstance(resolved_profile, dict): | |
| role = str(resolved_profile.get("role") or "user") | |
| if role not in {"admin", "security", "developer"}: | |
| try: | |
| requests.patch( | |
| f"{SUPABASE_URL}/rest/v1/profiles", | |
| params={"id": f"eq.{user_id}"}, | |
| json={"role": "admin"}, | |
| headers={"apikey": SUPABASE_SERVICE_ROLE_KEY, "Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}", "Content-Type": "application/json"}, | |
| timeout=SUPABASE_TIMEOUT_SECONDS, | |
| ) | |
| role = "admin" | |
| resolved_profile["role"] = "admin" | |
| except Exception: | |
| pass | |
| else: | |
| role = "user" | |
| resolved_first_name, resolved_last_name, _ = _resolve_profile_name_fields(resolved_profile if isinstance(resolved_profile, dict) else None, fallback_full_name=(resolved_profile or {}).get("full_name") if isinstance(resolved_profile, dict) else None) | |
| return { | |
| "status": "ok", | |
| "user": { | |
| "id": user_id, "first_name": resolved_first_name, "last_name": resolved_last_name, | |
| "full_name": (resolved_profile or {}).get("full_name"), "username": (resolved_profile or {}).get("username"), | |
| "phone_number": (resolved_profile or {}).get("phone_number"), "email": (resolved_profile or {}).get("email") or data.get("user", {}).get("email"), | |
| "national_id": (resolved_profile or {}).get("national_id"), "role": role, | |
| "is_staff": role in {"admin", "security"}, | |
| }, | |
| "session": {"access_token": data.get("access_token"), "refresh_token": data.get("refresh_token"), "expires_in": data.get("expires_in")}, | |
| } | |
| def auth_forgot_password( | |
| request: Request, | |
| payload: Dict[str, Any] = Body(...), | |
| ) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| identifier = _normalize_spaces(str(payload.get("identifier") or payload.get("email") or payload.get("phone") or payload.get("phone_number") or payload.get("username") or payload.get("nid") or payload.get("national_id") or "")) | |
| if not identifier: | |
| raise HTTPException(status_code=400, detail="identifier is required.") | |
| profile = supabase_find_profile_by_identifier(identifier) | |
| resolved_email = _normalize_spaces(str((profile or {}).get("email") or "")).lower() | |
| resolved_phone = _normalize_spaces(str((profile or {}).get("phone_number") or "")) | |
| if not resolved_email and "@" in identifier: | |
| resolved_email = _normalize_spaces(identifier).lower() | |
| has_real_email = bool(resolved_email and "@" in resolved_email and not _is_internal_login_email(resolved_email)) | |
| if not has_real_email and not resolved_phone: | |
| return {"status": "ok", "message": "If the account exists, reset instructions were sent."} | |
| if has_real_email: | |
| request_payload = {"email": resolved_email} | |
| payload_redirect_to = _normalize_spaces(str(payload.get("redirect_to") or "")) | |
| redirect_to = payload_redirect_to or _resolve_default_password_reset_redirect_url(request) | |
| request_host = _normalize_spaces(urlparse(_resolve_public_base_url(request)).hostname or "").lower() | |
| allowed_hosts = [request_host] if request_host else None | |
| if not _is_valid_redirect_url(redirect_to, allowed_http_hosts=allowed_hosts): | |
| raise HTTPException(status_code=400, detail="redirect_to must be an allow-listed URL or deep-link.") | |
| request_payload["redirect_to"] = redirect_to | |
| try: | |
| response = requests.post(f"{SUPABASE_URL}/auth/v1/recover", json=request_payload, headers={"apikey": SUPABASE_ANON_KEY, "Content-Type": "application/json"}, timeout=SUPABASE_TIMEOUT_SECONDS) | |
| except requests.RequestException as exc: | |
| raise HTTPException(status_code=503, detail=f"Supabase forgot-password failed: {exc}") from exc | |
| if response.status_code not in {200, 201, 204}: | |
| error_data = response.json() if response.text else {} | |
| error_msg = "" | |
| if isinstance(error_data, dict): | |
| error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip() | |
| if not error_msg: | |
| error_msg = response.text[:220] | |
| LOGGER.warning("Forgot-password provider response %s: %s", response.status_code, error_msg) | |
| else: | |
| try: | |
| _request_supabase_otp(channel="phone", identifier=resolved_phone, create_user=False, redirect_to=None, purpose="password_reset") | |
| except HTTPException as exc: | |
| LOGGER.warning("Forgot-password phone OTP response %s: %s", exc.status_code, exc.detail) | |
| return {"status": "ok", "message": "If the account exists, reset instructions were sent."} | |
| def auth_reset_password_resolve_token(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| direct_access_token = _normalize_spaces(str(payload.get("access_token") or payload.get("token") or "")) | |
| if direct_access_token: | |
| return {"status": "ok", "session": {"access_token": direct_access_token, "refresh_token": _normalize_spaces(str(payload.get("refresh_token") or "")), "expires_in": payload.get("expires_in")}} | |
| verify_type = _normalize_spaces(str(payload.get("type") or "recovery")).lower() or "recovery" | |
| resolved_email = _normalize_spaces(str(payload.get("email") or "")).lower() | |
| token_candidates = [] | |
| token_hash = _normalize_spaces(str(payload.get("token_hash") or payload.get("tokenHash") or "")) | |
| if token_hash: | |
| token_candidates.append(token_hash) | |
| code = _normalize_spaces(str(payload.get("code") or "")) | |
| if code and code not in token_candidates: | |
| token_candidates.append(code) | |
| if not token_candidates: | |
| raise HTTPException(status_code=400, detail="token_hash or code is required when access_token is missing.") | |
| last_error = "" | |
| for candidate in token_candidates: | |
| verify_payload = {"type": verify_type, "token_hash": candidate} | |
| if resolved_email: | |
| verify_payload["email"] = resolved_email | |
| try: | |
| response = requests.post(f"{SUPABASE_URL}/auth/v1/verify", json=verify_payload, headers={"apikey": SUPABASE_ANON_KEY, "Content-Type": "application/json"}, timeout=SUPABASE_TIMEOUT_SECONDS) | |
| except requests.RequestException as exc: | |
| raise HTTPException(status_code=503, detail=f"Supabase verify failed: {exc}") from exc | |
| if response.status_code == 200: | |
| try: | |
| data = response.json() if response.text else {} | |
| except ValueError: | |
| data = {} | |
| if not isinstance(data, dict): | |
| last_error = "Supabase verify returned an unexpected payload." | |
| continue | |
| session_payload = _extract_supabase_session_tokens(data) | |
| if session_payload.get("access_token"): | |
| return {"status": "ok", "session": session_payload} | |
| last_error = "Supabase verify did not include an access token." | |
| continue | |
| try: | |
| error_data = response.json() if response.text else {} | |
| except ValueError: | |
| error_data = {} | |
| error_msg = "" | |
| if isinstance(error_data, dict): | |
| error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip() | |
| if not error_msg: | |
| error_msg = response.text[:220] | |
| last_error = error_msg or f"Supabase verify failed with status {response.status_code}." | |
| raise HTTPException(status_code=401, detail=f"Could not resolve reset token: {last_error or 'Invalid or expired link.'}") | |
| def auth_reset_password(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| access_token = _normalize_spaces(str(payload.get("access_token") or payload.get("token") or "")) | |
| new_password = str(payload.get("new_password") or payload.get("password") or "").strip() | |
| if not access_token: | |
| raise HTTPException(status_code=400, detail="access_token is required.") | |
| if len(new_password) < 6: | |
| raise HTTPException(status_code=400, detail="new_password must be at least 6 characters.") | |
| try: | |
| response = requests.put(f"{SUPABASE_URL}/auth/v1/user", json={"password": new_password}, headers={"apikey": SUPABASE_ANON_KEY, "Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}, timeout=SUPABASE_TIMEOUT_SECONDS) | |
| except requests.RequestException as exc: | |
| raise HTTPException(status_code=503, detail=f"Supabase reset-password failed: {exc}") from exc | |
| if response.status_code != 200: | |
| error_data = response.json() if response.text else {} | |
| error_msg = "" | |
| if isinstance(error_data, dict): | |
| error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip() | |
| if not error_msg: | |
| error_msg = response.text[:220] | |
| raise HTTPException(status_code=response.status_code, detail=f"Reset-password failed: {error_msg}") | |
| return {"status": "ok", "message": "Password reset successful."} | |
| def auth_reset_password_page(): | |
| return HTMLResponse(content="""<!DOCTYPE html><html><head><meta charset="utf-8"><title>إعادة تعيين كلمة المرور</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,.1);max-width:400px;width:100%;text-align:center}h2{color:#333;margin-bottom:1.5rem}input{width:100%;padding:12px;border:1px solid #ddd;border-radius:8px;margin-bottom:1rem;font-size:16px;box-sizing:border-box}button{background:#007bff;color:#fff;border:none;padding:12px 24px;border-radius:8px;font-size:16px;cursor:pointer;width:100%}button:hover{background:#0056b3}.message{margin-top:1rem;padding:8px;border-radius:6px}.error{background:#ffe0e0;color:#c00}.success{background:#d4edda;color:#155724}</style></head><body><div class="card"><h2>إعادة تعيين كلمة المرور</h2><input type="password" id="newPassword" placeholder="كلمة المرور الجديدة" /><input type="password" id="confirmPassword" placeholder="تأكيد كلمة المرور" /><button onclick="resetPassword()">تأكيد</button><div id="message" class="message"></div></div><script>async function resetPassword(){const e=document.getElementById("newPassword").value,t=document.getElementById("confirmPassword").value;if(!e||e.length<6)return showMessage("كلمة المرور يجب أن تكون 6 أحرف على الأقل","error");if(e!==t)return showMessage("كلمة المرور غير متطابقة","error");const n=new URLSearchParams(window.location.hash.replace("#","?")).get("access_token")||new URLSearchParams(window.location.search).get("access_token")||"";if(!n){const o=window.location.hash.replace("#","").split("&").reduce((e,t)=>{const[n,a]=t.split("=");return n&&a&&(e[n]=a),e},{});if(!o.access_token&&!o.token)return showMessage("رمز إعادة التعيين غير صالح أو منتهي الصلاحية","error")}try{const a=await fetch("/auth/reset-password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({access_token:n||o.access_token,new_password:e})}),r=await a.json();r.status==="ok"?showMessage("تم تغيير كلمة المرور بنجاح ✅","success"):showMessage(r.detail||"فشل تغيير كلمة المرور","error")}catch{showMessage("حدث خطأ في الاتصال","error")}}function showMessage(e,t){const n=document.getElementById("message");n.textContent=e,n.className="message "+t}</script></body></html>""") | |
| def auth_reset_password_bridge(): | |
| return HTMLResponse(content="""<!DOCTYPE html><html><head><meta charset="utf-8"><title>توجيه إلى التطبيق</title><meta name="viewport" content="width=device-width,initial-scale=1"><script>window.onload=function(){const e=window.location.hash||"",t=window.location.search||"",n=e||t,a="ainelaql://auth/reset"+n;window.location.href=a,setTimeout(function(){document.getElementById("fallback").style.display="block"},3e3)}</script><style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f5f5f5;text-align:center}.card{background:#fff;padding:2rem;border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,.1)}</style></head><body><div class="card"><h2>جاري التوجيه إلى التطبيق...</h2><p id="fallback" style="display:none;color:#666">إذا لم يتم فتح التطبيق، يرجى نسخ الرابط وفتحه يدويًا</p></div></body></html>""") | |
| def auth_otp_request(payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| purpose = _normalize_spaces(str(payload.get("purpose") or "register")).lower() or "register" | |
| raw_channel = payload.get("channel") or payload.get("otp_channel") | |
| if raw_channel is None: | |
| has_email = bool(_normalize_spaces(str(payload.get("email") or ""))) | |
| has_phone = bool(_normalize_spaces(str(payload.get("phone") or payload.get("phone_number") or ""))) | |
| raw_channel = "email" if has_email else ("phone" if has_phone else "email") | |
| channel = _normalize_otp_channel(str(raw_channel)) | |
| request_user = None | |
| requester_id = None | |
| profile = None | |
| if purpose in {"vehicle_verify", "profile_update"}: | |
| request_user = require_authenticated_user(authorization) | |
| if request_user is None: | |
| raise HTTPException(status_code=401, detail="Authentication is required for this OTP purpose.") | |
| requester_id = str(request_user.get("id") or "").strip() | |
| if not requester_id: | |
| raise HTTPException(status_code=401, detail="Authenticated user id is missing.") | |
| profile = supabase_get_profile_by_user_id(requester_id) | |
| identifier = _resolve_otp_identifier(channel=channel, payload=payload, profile=profile) | |
| raw_create_user = payload.get("create_user") | |
| if isinstance(raw_create_user, bool): | |
| create_user = raw_create_user | |
| elif raw_create_user is None: | |
| create_user = purpose in {"register", "signup"} | |
| else: | |
| create_user = str(raw_create_user).strip().lower() in {"1", "true", "yes", "on"} | |
| if purpose in {"register", "signup"}: | |
| create_user = True | |
| raw_redirect_to = _normalize_spaces(str(payload.get("redirect_to") or "")) | |
| redirect_to = raw_redirect_to or None | |
| if channel == "email" and not redirect_to: | |
| default_redirect = _normalize_spaces(PASSWORD_RESET_REDIRECT_URL) | |
| if default_redirect: | |
| redirect_to = default_redirect | |
| _request_supabase_otp(channel=channel, identifier=identifier, create_user=create_user, redirect_to=redirect_to, purpose=purpose) | |
| return {"status": "ok", "purpose": purpose, "channel": channel, "identifier": identifier, "message": "OTP was sent successfully."} | |
| def auth_otp_verify(payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: | |
| if not supabase_configured(): | |
| raise HTTPException(status_code=503, detail="Supabase is not configured.") | |
| purpose = _normalize_spaces(str(payload.get("purpose") or "register")).lower() or "register" | |
| channel = _normalize_otp_channel(str(payload.get("channel") or payload.get("otp_channel") or "email")) | |
| request_user = None | |
| token_str = _extract_bearer_token(authorization) | |
| if token_str: | |
| barrier_user = verify_barrier_token(token_str) | |
| if barrier_user is not None: | |
| request_user = barrier_user | |
| else: | |
| request_user = verify_supabase_user_token(token_str) | |
| if purpose in {"vehicle_verify", "profile_update"} and request_user is None: | |
| raise HTTPException(status_code=401, detail="Authentication is required for this OTP purpose.") | |
| requester_profile = None | |
| if request_user is not None: | |
| requester_id = str(request_user.get("id") or "").strip() | |
| if requester_id: | |
| requester_profile = supabase_get_profile_by_user_id(requester_id) | |
| identifier = _resolve_otp_identifier(channel=channel, payload=payload, profile=requester_profile) | |
| verify_payload = _verify_supabase_otp(channel=channel, identifier=identifier, token=str(payload.get("otp_token") or payload.get("token") or ""), verify_type=str(payload.get("type") or payload.get("verify_type") or "").strip() or None, purpose=purpose) | |
| user_payload = verify_payload.get("user") if isinstance(verify_payload.get("user"), dict) else {} | |
| user_id = user_payload.get("id") | |
| if purpose == "register" and user_id: | |
| LOGGER.info("Initializing profile for OTP registered user: %s", user_id) | |
| _supabase_upsert_profile(user_id=user_id, payload={"full_name": payload.get("full_name") or user_payload.get("email", "").split("@")[0] or "New User", "email": user_payload.get("email"), "phone_number": user_payload.get("phone") or payload.get("phone_number") or payload.get("phone"), "role": "user"}) | |
| return {"status": "ok", "purpose": purpose, "channel": channel, "user": {"id": user_id, "email": user_payload.get("email"), "phone": user_payload.get("phone")}, "session": {"access_token": verify_payload.get("access_token"), "refresh_token": verify_payload.get("refresh_token"), "expires_in": verify_payload.get("expires_in"), "token_type": verify_payload.get("token_type")}, "message": "OTP verified successfully and profile initialized."} | |