Spaces:
Sleeping
Sleeping
feat: split auth into dedicated pages and enable flask auth routes
Browse files- .gitignore +4 -1
- app.py +15 -0
- auth_module/flask_auth_routes.py +306 -0
- static/css/style.css +11 -0
- static/js/auth-pages.js +140 -0
- static/js/script.js +4 -2
- templates/index.html +0 -99
- templates/login.html +38 -0
- templates/signup.html +40 -0
- templates/verify.html +42 -0
.gitignore
CHANGED
|
@@ -8,4 +8,7 @@ venv/
|
|
| 8 |
.ruff_cache/
|
| 9 |
.cache/
|
| 10 |
Thumbs.db
|
| 11 |
-
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
.ruff_cache/
|
| 9 |
.cache/
|
| 10 |
Thumbs.db
|
| 11 |
+
.DS_Store
|
| 12 |
+
users.db
|
| 13 |
+
*.db
|
| 14 |
+
auth_module/.jwt_secret
|
app.py
CHANGED
|
@@ -175,6 +175,21 @@ def index():
|
|
| 175 |
return render_template("index.html")
|
| 176 |
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
@app.route("/health")
|
| 179 |
def health():
|
| 180 |
uptime = int(time.time() - START_TIME)
|
|
|
|
| 175 |
return render_template("index.html")
|
| 176 |
|
| 177 |
|
| 178 |
+
@app.route("/login")
|
| 179 |
+
def login_page():
|
| 180 |
+
return render_template("login.html")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.route("/signup")
|
| 184 |
+
def signup_page():
|
| 185 |
+
return render_template("signup.html")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@app.route("/verify")
|
| 189 |
+
def verify_page():
|
| 190 |
+
return render_template("verify.html")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
@app.route("/health")
|
| 194 |
def health():
|
| 195 |
uptime = int(time.time() - START_TIME)
|
auth_module/flask_auth_routes.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
|
| 6 |
+
from flask import Blueprint, jsonify, make_response, request
|
| 7 |
+
|
| 8 |
+
from .database import auth_db
|
| 9 |
+
from .security import create_access_token, decode_access_token, validate_password_strength
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
auth_blueprint = Blueprint("auth_blueprint", __name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _run(coro):
|
| 16 |
+
return asyncio.run(coro)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _extract_token_from_request():
|
| 20 |
+
auth_header = request.headers.get("Authorization", "").strip()
|
| 21 |
+
if auth_header.lower().startswith("bearer "):
|
| 22 |
+
return auth_header.split(" ", 1)[1].strip()
|
| 23 |
+
|
| 24 |
+
cookie_value = request.cookies.get("access_token", "").strip()
|
| 25 |
+
if cookie_value.lower().startswith("bearer "):
|
| 26 |
+
return cookie_value.split(" ", 1)[1].strip()
|
| 27 |
+
return cookie_value or None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _get_current_user_or_error():
|
| 31 |
+
token = _extract_token_from_request()
|
| 32 |
+
if not token:
|
| 33 |
+
return None, (jsonify({"detail": "غير مصرح — سجّل دخولك أولاً"}), 401)
|
| 34 |
+
|
| 35 |
+
payload = decode_access_token(token)
|
| 36 |
+
if not payload:
|
| 37 |
+
return None, (jsonify({"detail": "جلسة منتهية — سجّل دخولك مرة أخرى"}), 401)
|
| 38 |
+
|
| 39 |
+
email = payload.get("sub", "")
|
| 40 |
+
user = _run(auth_db.get_user_by_email(email))
|
| 41 |
+
if not user:
|
| 42 |
+
return None, (jsonify({"detail": "المستخدم غير موجود"}), 401)
|
| 43 |
+
|
| 44 |
+
return user, None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _is_production():
|
| 48 |
+
return bool(os.getenv("RENDER") or os.getenv("ENVIRONMENT", "").lower() == "production")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@auth_blueprint.post("/signup")
|
| 52 |
+
def signup():
|
| 53 |
+
data = request.get_json(silent=True) or {}
|
| 54 |
+
email = (data.get("email") or "").strip().lower()
|
| 55 |
+
password = data.get("password") or ""
|
| 56 |
+
full_name = (data.get("full_name") or "").strip()
|
| 57 |
+
phone = (data.get("phone") or "").strip() or None
|
| 58 |
+
|
| 59 |
+
valid, message = validate_password_strength(password)
|
| 60 |
+
if not valid:
|
| 61 |
+
return jsonify({"detail": message}), 400
|
| 62 |
+
|
| 63 |
+
if "@" not in email or "." not in email:
|
| 64 |
+
return jsonify({"detail": "البريد الإلكتروني غير صحيح"}), 400
|
| 65 |
+
|
| 66 |
+
if not full_name:
|
| 67 |
+
return jsonify({"detail": "الاسم الكامل مطلوب"}), 400
|
| 68 |
+
|
| 69 |
+
created_user = _run(auth_db.create_user(email, password, full_name, phone=phone))
|
| 70 |
+
if not created_user:
|
| 71 |
+
return jsonify({"detail": "البريد الإلكتروني مسجل بالفعل"}), 400
|
| 72 |
+
|
| 73 |
+
otp_code = str(random.randint(100000, 999999))
|
| 74 |
+
_run(auth_db.store_otp(created_user["id"], otp_code, "telegram_verify", minutes=10))
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
from .config import auth_settings
|
| 78 |
+
from .nova_client import nova_client
|
| 79 |
+
|
| 80 |
+
if nova_client.is_configured:
|
| 81 |
+
_run(
|
| 82 |
+
nova_client.push_otp(
|
| 83 |
+
email=email,
|
| 84 |
+
code=otp_code,
|
| 85 |
+
app_id=auth_settings.APP_ID,
|
| 86 |
+
minutes=10,
|
| 87 |
+
)
|
| 88 |
+
)
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
return jsonify(
|
| 93 |
+
{
|
| 94 |
+
"status": "pending_verification",
|
| 95 |
+
"message": "تم إنشاء الحساب! فعّل حسابك عبر تليجرام بوت @robovainova_bot ← /verify",
|
| 96 |
+
"user": created_user,
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@auth_blueprint.post("/login")
|
| 102 |
+
def login():
|
| 103 |
+
data = request.get_json(silent=True) or {}
|
| 104 |
+
username_from_json = (data.get("username") or data.get("email") or "").strip()
|
| 105 |
+
password_from_json = data.get("password") or ""
|
| 106 |
+
|
| 107 |
+
username_from_form = (request.form.get("username") or "").strip()
|
| 108 |
+
password_from_form = request.form.get("password") or ""
|
| 109 |
+
|
| 110 |
+
username = username_from_json or username_from_form
|
| 111 |
+
password = password_from_json or password_from_form
|
| 112 |
+
|
| 113 |
+
if not username or not password:
|
| 114 |
+
return jsonify({"detail": "بيانات تسجيل الدخول غير مكتملة"}), 400
|
| 115 |
+
|
| 116 |
+
user = _run(auth_db.authenticate_user(username, password))
|
| 117 |
+
if not user:
|
| 118 |
+
return jsonify({"detail": "البريد الإلكتروني أو كلمة المرور غير صحيحة"}), 401
|
| 119 |
+
|
| 120 |
+
if not user.get("is_verified"):
|
| 121 |
+
return jsonify(
|
| 122 |
+
{
|
| 123 |
+
"detail": "الحساب غير مُفعّل. افتح بوت @robovainova_bot في تليجرام وأرسل /verify"
|
| 124 |
+
}
|
| 125 |
+
), 403
|
| 126 |
+
|
| 127 |
+
access_token = create_access_token(data={"sub": user["email"]})
|
| 128 |
+
expires_at = (datetime.now() + timedelta(days=1)).isoformat()
|
| 129 |
+
_run(auth_db.create_session(user["id"], access_token, expires_at))
|
| 130 |
+
|
| 131 |
+
response = make_response(
|
| 132 |
+
jsonify({"access_token": access_token, "token_type": "bearer"})
|
| 133 |
+
)
|
| 134 |
+
response.set_cookie(
|
| 135 |
+
key="access_token",
|
| 136 |
+
value=f"Bearer {access_token}",
|
| 137 |
+
httponly=True,
|
| 138 |
+
secure=_is_production(),
|
| 139 |
+
max_age=86400,
|
| 140 |
+
samesite="Lax",
|
| 141 |
+
)
|
| 142 |
+
return response
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@auth_blueprint.post("/logout")
|
| 146 |
+
def logout():
|
| 147 |
+
token = request.cookies.get("access_token", "")
|
| 148 |
+
if token.lower().startswith("bearer "):
|
| 149 |
+
token = token.split(" ", 1)[1].strip()
|
| 150 |
+
|
| 151 |
+
if token:
|
| 152 |
+
_run(auth_db.delete_session(token))
|
| 153 |
+
|
| 154 |
+
response = make_response(jsonify({"status": "success"}))
|
| 155 |
+
response.delete_cookie("access_token")
|
| 156 |
+
return response
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@auth_blueprint.get("/me")
|
| 160 |
+
def me():
|
| 161 |
+
user, error_response = _get_current_user_or_error()
|
| 162 |
+
if error_response:
|
| 163 |
+
return error_response
|
| 164 |
+
|
| 165 |
+
return jsonify(
|
| 166 |
+
{
|
| 167 |
+
"id": user["id"],
|
| 168 |
+
"email": user["email"],
|
| 169 |
+
"full_name": user.get("full_name", ""),
|
| 170 |
+
"role": user.get("role", "user"),
|
| 171 |
+
"balance": user.get("balance", 0),
|
| 172 |
+
}
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@auth_blueprint.post("/request-otp")
|
| 177 |
+
def request_otp():
|
| 178 |
+
data = request.get_json(silent=True) or {}
|
| 179 |
+
email = (data.get("email") or "").strip().lower()
|
| 180 |
+
if not email:
|
| 181 |
+
return jsonify({"detail": "البريد الإلكتروني مطلوب"}), 400
|
| 182 |
+
|
| 183 |
+
user = _run(auth_db.get_user_by_email_unverified(email))
|
| 184 |
+
if not user:
|
| 185 |
+
return jsonify({"detail": "لم يتم العثور على حساب بهذا البريد"}), 404
|
| 186 |
+
|
| 187 |
+
if user.get("is_verified"):
|
| 188 |
+
return jsonify({"status": "already_verified", "message": "الحساب مفعّل بالفعل ✅"})
|
| 189 |
+
|
| 190 |
+
otp_code = str(random.randint(100000, 999999))
|
| 191 |
+
_run(auth_db.store_otp(user["id"], otp_code, "telegram_verify", minutes=10))
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
from .config import auth_settings
|
| 195 |
+
from .nova_client import nova_client
|
| 196 |
+
|
| 197 |
+
if nova_client.is_configured:
|
| 198 |
+
_run(
|
| 199 |
+
nova_client.push_otp(
|
| 200 |
+
email=email,
|
| 201 |
+
code=otp_code,
|
| 202 |
+
app_id=auth_settings.APP_ID,
|
| 203 |
+
minutes=10,
|
| 204 |
+
)
|
| 205 |
+
)
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
return jsonify(
|
| 210 |
+
{
|
| 211 |
+
"status": "success",
|
| 212 |
+
"message": "تم إنشاء كود التحقق. افتح بوت RobovAI في تليجرام وأرسل /verify",
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@auth_blueprint.post("/verify-otp")
|
| 218 |
+
def verify_otp():
|
| 219 |
+
data = request.get_json(silent=True) or {}
|
| 220 |
+
email = (data.get("email") or "").strip().lower()
|
| 221 |
+
code = (data.get("code") or "").strip()
|
| 222 |
+
|
| 223 |
+
if not email or not code:
|
| 224 |
+
return jsonify({"detail": "email و code مطلوبان"}), 400
|
| 225 |
+
|
| 226 |
+
user = _run(auth_db.get_user_by_email_unverified(email))
|
| 227 |
+
if not user:
|
| 228 |
+
return jsonify({"detail": "المستخدم غير موجود"}), 404
|
| 229 |
+
|
| 230 |
+
if user.get("is_verified"):
|
| 231 |
+
return jsonify({"status": "already_verified", "message": "الحساب مفعّل بالفعل ✅"})
|
| 232 |
+
|
| 233 |
+
valid = _run(auth_db.verify_otp(user["id"], code, "telegram_verify"))
|
| 234 |
+
if not valid:
|
| 235 |
+
return jsonify({"detail": "كود التحقق غير صحيح أو منتهي الصلاحية"}), 400
|
| 236 |
+
|
| 237 |
+
_run(auth_db.set_user_verified(user["id"]))
|
| 238 |
+
|
| 239 |
+
access_token = create_access_token(data={"sub": email})
|
| 240 |
+
expires_at = (datetime.now() + timedelta(days=1)).isoformat()
|
| 241 |
+
_run(auth_db.create_session(user["id"], access_token, expires_at))
|
| 242 |
+
|
| 243 |
+
response = make_response(
|
| 244 |
+
jsonify(
|
| 245 |
+
{
|
| 246 |
+
"status": "success",
|
| 247 |
+
"message": "تم تفعيل الحساب بنجاح! ✅",
|
| 248 |
+
"access_token": access_token,
|
| 249 |
+
"user": {
|
| 250 |
+
"id": user["id"],
|
| 251 |
+
"email": email,
|
| 252 |
+
"full_name": user.get("full_name", ""),
|
| 253 |
+
},
|
| 254 |
+
}
|
| 255 |
+
)
|
| 256 |
+
)
|
| 257 |
+
response.set_cookie(
|
| 258 |
+
key="access_token",
|
| 259 |
+
value=f"Bearer {access_token}",
|
| 260 |
+
httponly=True,
|
| 261 |
+
secure=_is_production(),
|
| 262 |
+
max_age=86400,
|
| 263 |
+
samesite="Lax",
|
| 264 |
+
)
|
| 265 |
+
return response
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@auth_blueprint.get("/check-verified")
|
| 269 |
+
def check_verified():
|
| 270 |
+
email = (request.args.get("email") or "").strip().lower()
|
| 271 |
+
if not email:
|
| 272 |
+
return jsonify({"detail": "email مطلوب"}), 400
|
| 273 |
+
|
| 274 |
+
user = _run(auth_db.get_user_by_email_unverified(email))
|
| 275 |
+
if not user:
|
| 276 |
+
return jsonify({"detail": "المستخدم غير موجود"}), 404
|
| 277 |
+
|
| 278 |
+
if user.get("is_verified"):
|
| 279 |
+
return jsonify({"verified": True})
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
from .nova_client import nova_client
|
| 283 |
+
|
| 284 |
+
if nova_client.is_configured:
|
| 285 |
+
remote_verified = _run(nova_client.check_verified(email))
|
| 286 |
+
if remote_verified:
|
| 287 |
+
_run(auth_db.set_user_verified(user["id"]))
|
| 288 |
+
return jsonify({"verified": True})
|
| 289 |
+
except Exception:
|
| 290 |
+
pass
|
| 291 |
+
|
| 292 |
+
return jsonify({"verified": False})
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@auth_blueprint.delete("/delete-account")
|
| 296 |
+
def delete_account():
|
| 297 |
+
user, error_response = _get_current_user_or_error()
|
| 298 |
+
if error_response:
|
| 299 |
+
return error_response
|
| 300 |
+
|
| 301 |
+
_run(auth_db.delete_user_account(user["id"]))
|
| 302 |
+
response = make_response(
|
| 303 |
+
jsonify({"status": "success", "message": "تم حذف الحساب نهائياً"})
|
| 304 |
+
)
|
| 305 |
+
response.delete_cookie("access_token")
|
| 306 |
+
return response
|
static/css/style.css
CHANGED
|
@@ -2621,6 +2621,17 @@ body.no-waveform .waveform-canvas {
|
|
| 2621 |
.auth-footer {
|
| 2622 |
margin-top: 10px;
|
| 2623 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2624 |
|
| 2625 |
/* ============================================
|
| 2626 |
RESPONSIVE ADJUSTMENTS (Phase 3)
|
|
|
|
| 2621 |
.auth-footer {
|
| 2622 |
margin-top: 10px;
|
| 2623 |
}
|
| 2624 |
+
.auth-standalone {
|
| 2625 |
+
max-width: 460px;
|
| 2626 |
+
}
|
| 2627 |
+
.auth-page-panel {
|
| 2628 |
+
margin-top: 10px;
|
| 2629 |
+
}
|
| 2630 |
+
.auth-switch-line {
|
| 2631 |
+
margin-top: 14px;
|
| 2632 |
+
font-size: 0.82rem;
|
| 2633 |
+
color: var(--text-secondary);
|
| 2634 |
+
}
|
| 2635 |
|
| 2636 |
/* ============================================
|
| 2637 |
RESPONSIVE ADJUSTMENTS (Phase 3)
|
static/js/auth-pages.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
+
const $ = (id) => document.getElementById(id);
|
| 3 |
+
const toastEl = $('toast');
|
| 4 |
+
|
| 5 |
+
function showToast(msg, type = '') {
|
| 6 |
+
if (!toastEl) return;
|
| 7 |
+
toastEl.textContent = msg;
|
| 8 |
+
toastEl.className = 'toast' + (type ? ' ' + type : '');
|
| 9 |
+
toastEl.classList.add('visible');
|
| 10 |
+
setTimeout(() => toastEl.classList.remove('visible'), 3000);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async function authRequest(path, options = {}) {
|
| 14 |
+
const res = await fetch(path, {
|
| 15 |
+
credentials: 'include',
|
| 16 |
+
...options,
|
| 17 |
+
headers: {
|
| 18 |
+
'Content-Type': 'application/json',
|
| 19 |
+
...(options.headers || {}),
|
| 20 |
+
},
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
let payload = null;
|
| 24 |
+
try {
|
| 25 |
+
payload = await res.json();
|
| 26 |
+
} catch (e) {}
|
| 27 |
+
|
| 28 |
+
if (!res.ok) {
|
| 29 |
+
throw new Error(payload?.detail || payload?.error || 'Request failed');
|
| 30 |
+
}
|
| 31 |
+
return payload || {};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const loginBtn = $('auth-login-submit');
|
| 35 |
+
if (loginBtn) {
|
| 36 |
+
loginBtn.addEventListener('click', async () => {
|
| 37 |
+
try {
|
| 38 |
+
const email = ($('auth-login-email')?.value || '').trim();
|
| 39 |
+
const password = $('auth-login-password')?.value || '';
|
| 40 |
+
await authRequest('/auth/login', {
|
| 41 |
+
method: 'POST',
|
| 42 |
+
body: JSON.stringify({ email, password }),
|
| 43 |
+
});
|
| 44 |
+
showToast('Login successful', 'success');
|
| 45 |
+
setTimeout(() => {
|
| 46 |
+
window.location.href = '/';
|
| 47 |
+
}, 300);
|
| 48 |
+
} catch (e) {
|
| 49 |
+
showToast(e.message || 'Login failed', 'error');
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const signupBtn = $('auth-signup-submit');
|
| 55 |
+
if (signupBtn) {
|
| 56 |
+
signupBtn.addEventListener('click', async () => {
|
| 57 |
+
try {
|
| 58 |
+
const full_name = ($('auth-signup-name')?.value || '').trim();
|
| 59 |
+
const email = ($('auth-signup-email')?.value || '').trim();
|
| 60 |
+
const phone = ($('auth-signup-phone')?.value || '').trim();
|
| 61 |
+
const password = $('auth-signup-password')?.value || '';
|
| 62 |
+
await authRequest('/auth/signup', {
|
| 63 |
+
method: 'POST',
|
| 64 |
+
body: JSON.stringify({ full_name, email, phone, password }),
|
| 65 |
+
});
|
| 66 |
+
showToast('Account created. Continue verification.', 'success');
|
| 67 |
+
setTimeout(() => {
|
| 68 |
+
window.location.href = `/verify?email=${encodeURIComponent(email)}`;
|
| 69 |
+
}, 400);
|
| 70 |
+
} catch (e) {
|
| 71 |
+
showToast(e.message || 'Signup failed', 'error');
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const otpEmailInput = $('auth-otp-email');
|
| 77 |
+
if (otpEmailInput) {
|
| 78 |
+
const queryEmail = new URLSearchParams(window.location.search).get('email');
|
| 79 |
+
if (queryEmail) otpEmailInput.value = queryEmail;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const requestOtpBtn = $('auth-otp-request');
|
| 83 |
+
if (requestOtpBtn) {
|
| 84 |
+
requestOtpBtn.addEventListener('click', async () => {
|
| 85 |
+
try {
|
| 86 |
+
const email = ($('auth-otp-email')?.value || '').trim();
|
| 87 |
+
await authRequest('/auth/request-otp', {
|
| 88 |
+
method: 'POST',
|
| 89 |
+
body: JSON.stringify({ email }),
|
| 90 |
+
});
|
| 91 |
+
showToast('OTP requested. Check Telegram bot.', 'success');
|
| 92 |
+
} catch (e) {
|
| 93 |
+
showToast(e.message || 'OTP request failed', 'error');
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const verifyOtpBtn = $('auth-otp-verify');
|
| 99 |
+
if (verifyOtpBtn) {
|
| 100 |
+
verifyOtpBtn.addEventListener('click', async () => {
|
| 101 |
+
try {
|
| 102 |
+
const email = ($('auth-otp-email')?.value || '').trim();
|
| 103 |
+
const code = ($('auth-otp-code')?.value || '').trim();
|
| 104 |
+
await authRequest('/auth/verify-otp', {
|
| 105 |
+
method: 'POST',
|
| 106 |
+
body: JSON.stringify({ email, code }),
|
| 107 |
+
});
|
| 108 |
+
showToast('Verified successfully. Redirecting to login.', 'success');
|
| 109 |
+
setTimeout(() => {
|
| 110 |
+
window.location.href = '/login';
|
| 111 |
+
}, 400);
|
| 112 |
+
} catch (e) {
|
| 113 |
+
showToast(e.message || 'OTP verification failed', 'error');
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const checkBtn = $('auth-check-verified');
|
| 119 |
+
if (checkBtn) {
|
| 120 |
+
checkBtn.addEventListener('click', async () => {
|
| 121 |
+
try {
|
| 122 |
+
const email = ($('auth-otp-email')?.value || '').trim();
|
| 123 |
+
const data = await authRequest(
|
| 124 |
+
`/auth/check-verified?email=${encodeURIComponent(email)}`,
|
| 125 |
+
{ method: 'GET' },
|
| 126 |
+
);
|
| 127 |
+
if (data.verified) {
|
| 128 |
+
showToast('Account is verified. Go login.', 'success');
|
| 129 |
+
setTimeout(() => {
|
| 130 |
+
window.location.href = '/login';
|
| 131 |
+
}, 300);
|
| 132 |
+
} else {
|
| 133 |
+
showToast('Still not verified yet.', 'error');
|
| 134 |
+
}
|
| 135 |
+
} catch (e) {
|
| 136 |
+
showToast(e.message || 'Check failed', 'error');
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
}
|
| 140 |
+
});
|
static/js/script.js
CHANGED
|
@@ -960,7 +960,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 960 |
startBtn.addEventListener('click', () => {
|
| 961 |
if (!currentUser) {
|
| 962 |
showToast('Please login first', 'error');
|
| 963 |
-
|
|
|
|
|
|
|
| 964 |
return;
|
| 965 |
}
|
| 966 |
onboardingScreen.classList.add('hidden');
|
|
@@ -1812,7 +1814,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 1812 |
|
| 1813 |
function bindAuthUI() {
|
| 1814 |
authBtn?.addEventListener('click', () => {
|
| 1815 |
-
|
| 1816 |
});
|
| 1817 |
|
| 1818 |
$('auth-close')?.addEventListener('click', () => {
|
|
|
|
| 960 |
startBtn.addEventListener('click', () => {
|
| 961 |
if (!currentUser) {
|
| 962 |
showToast('Please login first', 'error');
|
| 963 |
+
setTimeout(() => {
|
| 964 |
+
window.location.href = '/login';
|
| 965 |
+
}, 300);
|
| 966 |
return;
|
| 967 |
}
|
| 968 |
onboardingScreen.classList.add('hidden');
|
|
|
|
| 1814 |
|
| 1815 |
function bindAuthUI() {
|
| 1816 |
authBtn?.addEventListener('click', () => {
|
| 1817 |
+
window.location.href = '/login';
|
| 1818 |
});
|
| 1819 |
|
| 1820 |
$('auth-close')?.addEventListener('click', () => {
|
templates/index.html
CHANGED
|
@@ -516,105 +516,6 @@
|
|
| 516 |
</div>
|
| 517 |
</div>
|
| 518 |
|
| 519 |
-
<!-- ============ AUTH MODAL ============ -->
|
| 520 |
-
<div class="modal-overlay hidden" id="auth-modal">
|
| 521 |
-
<div class="modal-card auth-card">
|
| 522 |
-
<div class="modal-header">
|
| 523 |
-
<h2>Account</h2>
|
| 524 |
-
<button class="modal-close" id="auth-close">×</button>
|
| 525 |
-
</div>
|
| 526 |
-
|
| 527 |
-
<div class="auth-tabs" id="auth-tabs">
|
| 528 |
-
<button class="auth-tab active" data-auth-tab="login">Login</button>
|
| 529 |
-
<button class="auth-tab" data-auth-tab="signup">Sign Up</button>
|
| 530 |
-
</div>
|
| 531 |
-
|
| 532 |
-
<div class="auth-panel" id="auth-login-panel">
|
| 533 |
-
<input
|
| 534 |
-
class="voice-input"
|
| 535 |
-
id="auth-login-email"
|
| 536 |
-
type="email"
|
| 537 |
-
placeholder="Email"
|
| 538 |
-
autocomplete="email"
|
| 539 |
-
/>
|
| 540 |
-
<input
|
| 541 |
-
class="voice-input"
|
| 542 |
-
id="auth-login-password"
|
| 543 |
-
type="password"
|
| 544 |
-
placeholder="Password"
|
| 545 |
-
autocomplete="current-password"
|
| 546 |
-
/>
|
| 547 |
-
<div class="auth-actions">
|
| 548 |
-
<button class="btn-primary" id="auth-login-submit">Login</button>
|
| 549 |
-
</div>
|
| 550 |
-
</div>
|
| 551 |
-
|
| 552 |
-
<div class="auth-panel hidden" id="auth-signup-panel">
|
| 553 |
-
<input
|
| 554 |
-
class="voice-input"
|
| 555 |
-
id="auth-signup-name"
|
| 556 |
-
type="text"
|
| 557 |
-
placeholder="Full name"
|
| 558 |
-
autocomplete="name"
|
| 559 |
-
/>
|
| 560 |
-
<input
|
| 561 |
-
class="voice-input"
|
| 562 |
-
id="auth-signup-email"
|
| 563 |
-
type="email"
|
| 564 |
-
placeholder="Email"
|
| 565 |
-
autocomplete="email"
|
| 566 |
-
/>
|
| 567 |
-
<input
|
| 568 |
-
class="voice-input"
|
| 569 |
-
id="auth-signup-phone"
|
| 570 |
-
type="text"
|
| 571 |
-
placeholder="Phone (optional)"
|
| 572 |
-
autocomplete="tel"
|
| 573 |
-
/>
|
| 574 |
-
<input
|
| 575 |
-
class="voice-input"
|
| 576 |
-
id="auth-signup-password"
|
| 577 |
-
type="password"
|
| 578 |
-
placeholder="Password"
|
| 579 |
-
autocomplete="new-password"
|
| 580 |
-
/>
|
| 581 |
-
<div class="auth-actions">
|
| 582 |
-
<button class="btn-primary" id="auth-signup-submit">Create account</button>
|
| 583 |
-
</div>
|
| 584 |
-
|
| 585 |
-
<div class="auth-otp-box">
|
| 586 |
-
<label class="voice-label" for="auth-otp-email">OTP verification</label>
|
| 587 |
-
<input
|
| 588 |
-
class="voice-input"
|
| 589 |
-
id="auth-otp-email"
|
| 590 |
-
type="email"
|
| 591 |
-
placeholder="Email for OTP"
|
| 592 |
-
autocomplete="email"
|
| 593 |
-
/>
|
| 594 |
-
<div class="auth-actions">
|
| 595 |
-
<button class="btn-secondary" id="auth-otp-request">Request OTP</button>
|
| 596 |
-
</div>
|
| 597 |
-
<input
|
| 598 |
-
class="voice-input"
|
| 599 |
-
id="auth-otp-code"
|
| 600 |
-
type="text"
|
| 601 |
-
placeholder="6-digit code"
|
| 602 |
-
maxlength="6"
|
| 603 |
-
inputmode="numeric"
|
| 604 |
-
/>
|
| 605 |
-
<div class="auth-actions">
|
| 606 |
-
<button class="btn-primary" id="auth-otp-verify">Verify OTP</button>
|
| 607 |
-
</div>
|
| 608 |
-
</div>
|
| 609 |
-
</div>
|
| 610 |
-
|
| 611 |
-
<div class="auth-current" id="auth-current"></div>
|
| 612 |
-
<div class="auth-actions auth-footer">
|
| 613 |
-
<button class="btn-secondary" id="auth-logout">Logout</button>
|
| 614 |
-
</div>
|
| 615 |
-
</div>
|
| 616 |
-
</div>
|
| 617 |
-
|
| 618 |
<!-- ============ SIDE PANEL (Grammar + Vocab) ============ -->
|
| 619 |
<div class="side-panel hidden" id="side-panel">
|
| 620 |
<div class="panel-header">
|
|
|
|
| 516 |
</div>
|
| 517 |
</div>
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
<!-- ============ SIDE PANEL (Grammar + Vocab) ============ -->
|
| 520 |
<div class="side-panel hidden" id="side-panel">
|
| 521 |
<div class="panel-header">
|
templates/login.html
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en" dir="ltr" data-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Login — Echo Tutor</title>
|
| 7 |
+
<link
|
| 8 |
+
rel="stylesheet"
|
| 9 |
+
href="{{ url_for('static', filename='css/style.css') }}"
|
| 10 |
+
/>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<canvas id="particles-canvas"></canvas>
|
| 14 |
+
<div class="toast" id="toast"></div>
|
| 15 |
+
|
| 16 |
+
<div class="onboarding-screen">
|
| 17 |
+
<div class="onboarding-content auth-standalone">
|
| 18 |
+
<h1 class="onboarding-title">Welcome <span class="accent">Back</span></h1>
|
| 19 |
+
<p class="onboarding-subtitle">Login to continue your sessions</p>
|
| 20 |
+
|
| 21 |
+
<div class="auth-panel auth-page-panel">
|
| 22 |
+
<input class="voice-input" id="auth-login-email" type="email" placeholder="Email" autocomplete="email" />
|
| 23 |
+
<input class="voice-input" id="auth-login-password" type="password" placeholder="Password" autocomplete="current-password" />
|
| 24 |
+
<div class="auth-actions">
|
| 25 |
+
<button class="btn-primary" id="auth-login-submit">Login</button>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<p class="auth-switch-line">
|
| 30 |
+
New here?
|
| 31 |
+
<a href="/signup" class="accent">Create account</a>
|
| 32 |
+
</p>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<script src="{{ url_for('static', filename='js/auth-pages.js') }}"></script>
|
| 37 |
+
</body>
|
| 38 |
+
</html>
|
templates/signup.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en" dir="ltr" data-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Sign Up — Echo Tutor</title>
|
| 7 |
+
<link
|
| 8 |
+
rel="stylesheet"
|
| 9 |
+
href="{{ url_for('static', filename='css/style.css') }}"
|
| 10 |
+
/>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<canvas id="particles-canvas"></canvas>
|
| 14 |
+
<div class="toast" id="toast"></div>
|
| 15 |
+
|
| 16 |
+
<div class="onboarding-screen">
|
| 17 |
+
<div class="onboarding-content auth-standalone">
|
| 18 |
+
<h1 class="onboarding-title">Create <span class="accent">Account</span></h1>
|
| 19 |
+
<p class="onboarding-subtitle">Step 1: registration</p>
|
| 20 |
+
|
| 21 |
+
<div class="auth-panel auth-page-panel">
|
| 22 |
+
<input class="voice-input" id="auth-signup-name" type="text" placeholder="Full name" autocomplete="name" />
|
| 23 |
+
<input class="voice-input" id="auth-signup-email" type="email" placeholder="Email" autocomplete="email" />
|
| 24 |
+
<input class="voice-input" id="auth-signup-phone" type="text" placeholder="Phone (optional)" autocomplete="tel" />
|
| 25 |
+
<input class="voice-input" id="auth-signup-password" type="password" placeholder="Password" autocomplete="new-password" />
|
| 26 |
+
<div class="auth-actions">
|
| 27 |
+
<button class="btn-primary" id="auth-signup-submit">Create account</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<p class="auth-switch-line">
|
| 32 |
+
Already have account?
|
| 33 |
+
<a href="/login" class="accent">Login</a>
|
| 34 |
+
</p>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<script src="{{ url_for('static', filename='js/auth-pages.js') }}"></script>
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
templates/verify.html
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en" dir="ltr" data-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Verify OTP — Echo Tutor</title>
|
| 7 |
+
<link
|
| 8 |
+
rel="stylesheet"
|
| 9 |
+
href="{{ url_for('static', filename='css/style.css') }}"
|
| 10 |
+
/>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<canvas id="particles-canvas"></canvas>
|
| 14 |
+
<div class="toast" id="toast"></div>
|
| 15 |
+
|
| 16 |
+
<div class="onboarding-screen">
|
| 17 |
+
<div class="onboarding-content auth-standalone">
|
| 18 |
+
<h1 class="onboarding-title">Verify <span class="accent">Account</span></h1>
|
| 19 |
+
<p class="onboarding-subtitle">Step 2: activation via Telegram OTP</p>
|
| 20 |
+
|
| 21 |
+
<div class="auth-panel auth-page-panel">
|
| 22 |
+
<input class="voice-input" id="auth-otp-email" type="email" placeholder="Email for OTP" autocomplete="email" />
|
| 23 |
+
<div class="auth-actions">
|
| 24 |
+
<button class="btn-secondary" id="auth-otp-request">Request OTP</button>
|
| 25 |
+
</div>
|
| 26 |
+
<input class="voice-input" id="auth-otp-code" type="text" placeholder="6-digit code" maxlength="6" inputmode="numeric" />
|
| 27 |
+
<div class="auth-actions">
|
| 28 |
+
<button class="btn-primary" id="auth-otp-verify">Verify OTP</button>
|
| 29 |
+
<button class="btn-secondary" id="auth-check-verified">Check status</button>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<p class="auth-switch-line">
|
| 34 |
+
After verification, go to
|
| 35 |
+
<a href="/login" class="accent">Login</a>
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<script src="{{ url_for('static', filename='js/auth-pages.js') }}"></script>
|
| 41 |
+
</body>
|
| 42 |
+
</html>
|