إنجاز: تفعيل خاصية استعادة كلمة المرور وإعادة تعيينها

- إضافة نقاط نهاية لخاصيتي استعادة كلمة المرور وإعادة تعيينها في الواجهة الخلفية.

- إنشاء اختبارات لخاصية إعادة تعيين كلمة المرور لضمان كفاءتها وأمانها.

- تطوير صفحات واجهة المستخدم لخاصيتي استعادة كلمة المرور وإعادة تعيينها مع معالجة النماذج.

- دمج حدود تحميل ديناميكية لأنواع ملفات مختلفة بناءً على خطط المستخدمين.

- تقديم أداة جديدة لتغيير حجم الصور مع إمكانية تعديل الأبعاد وإعدادات الجودة.

- تحديث نظام التوجيه والتنقل ليشمل أدوات جديدة وميزات مصادقة.

- تحسين تجربة المستخدم من خلال معالجة الأخطاء ورسائل التغذية الراجعة المناسبة.

- إضافة دعم التدويل للميزات الجديدة باللغات الإنجليزية والعربية والفرنسية.
This commit is contained in:
Your Name
2026-03-07 14:23:50 +02:00
parent 0ad2ba0f02
commit 71f7d0382d
27 changed files with 1460 additions and 7 deletions

View File

@@ -5,7 +5,7 @@ import logging
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from flask import current_app
from werkzeug.security import check_password_hash, generate_password_hash
@@ -160,6 +160,35 @@ def init_account_db():
"ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''"
)
# Password reset tokens
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_prt_token_hash
ON password_reset_tokens(token_hash);
CREATE TABLE IF NOT EXISTS file_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
file_path TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_file_events_created
ON file_events(created_at DESC);
"""
)
def create_user(email: str, password: str) -> dict:
"""Create a new user and return the public record."""
@@ -515,3 +544,99 @@ def has_task_access(user_id: int, source: str, task_id: str) -> bool:
).fetchone()
return row is not None
# ---------------------------------------------------------------------------
# Password reset tokens
# ---------------------------------------------------------------------------
def get_user_by_email(email: str) -> dict | None:
"""Fetch a public user record by email."""
email = _normalize_email(email)
with _connect() as conn:
row = conn.execute(
"SELECT id, email, plan, created_at FROM users WHERE email = ?",
(email,),
).fetchone()
return _serialize_user(row)
def create_password_reset_token(user_id: int) -> str:
"""Generate a password-reset token (returned raw) and store its hash."""
raw_token = secrets.token_urlsafe(48)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = _utc_now()
# Expire in 1 hour
expires = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
with _connect() as conn:
# Invalidate any previous unused tokens for this user
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL",
(now, user_id),
)
conn.execute(
"""
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, token_hash, expires, now),
)
return raw_token
def verify_and_consume_reset_token(raw_token: str) -> int | None:
"""Verify a reset token. Returns user_id if valid, else None. Marks it used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = _utc_now()
with _connect() as conn:
row = conn.execute(
"""
SELECT id, user_id, expires_at
FROM password_reset_tokens
WHERE token_hash = ? AND used_at IS NULL
""",
(token_hash,),
).fetchone()
if row is None:
return None
# Check expiry
if row["expires_at"] < now:
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
return None
# Mark used
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
return row["user_id"]
def update_user_password(user_id: int, new_password: str) -> bool:
"""Update a user's password hash."""
now = _utc_now()
password_hash = generate_password_hash(new_password)
with _connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
(password_hash, now, user_id),
)
return True
def log_file_event(event_type: str, file_path: str | None = None, detail: str | None = None) -> None:
"""Record a file lifecycle event (upload, download, cleanup, etc.)."""
with _connect() as conn:
conn.execute(
"INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)",
(event_type, file_path, detail, _utc_now()),
)

View File

@@ -0,0 +1,72 @@
"""Email service — sends transactional emails via SMTP."""
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import current_app
logger = logging.getLogger(__name__)
def _get_smtp_config() -> dict:
"""Read SMTP settings from Flask config."""
return {
"host": current_app.config.get("SMTP_HOST", ""),
"port": current_app.config.get("SMTP_PORT", 587),
"user": current_app.config.get("SMTP_USER", ""),
"password": current_app.config.get("SMTP_PASSWORD", ""),
"from_addr": current_app.config.get("SMTP_FROM", "noreply@saas-pdf.com"),
"use_tls": current_app.config.get("SMTP_USE_TLS", True),
}
def send_email(to: str, subject: str, html_body: str) -> bool:
"""Send an HTML email. Returns True on success."""
cfg = _get_smtp_config()
if not cfg["host"]:
logger.warning("SMTP not configured — email to %s suppressed.", to)
return False
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = cfg["from_addr"]
msg["To"] = to
msg.attach(MIMEText(html_body, "html"))
try:
if cfg["use_tls"]:
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
server.starttls()
else:
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
if cfg["user"]:
server.login(cfg["user"], cfg["password"])
server.sendmail(cfg["from_addr"], [to], msg.as_string())
server.quit()
logger.info("Email sent to %s: %s", to, subject)
return True
except Exception:
logger.exception("Failed to send email to %s", to)
return False
def send_password_reset_email(to: str, token: str) -> bool:
"""Send a password reset link."""
frontend = current_app.config.get("FRONTEND_URL", "http://localhost:5173")
reset_link = f"{frontend}/reset-password?token={token}"
html = f"""
<div style="font-family: sans-serif; max-width: 480px; margin: auto;">
<h2>Password Reset</h2>
<p>You requested a password reset for your SaaS-PDF account.</p>
<p><a href="{reset_link}" style="display:inline-block;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;">
Reset Password
</a></p>
<p style="color:#666;font-size:14px;">This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p>
</div>
"""
return send_email(to, "Reset your SaaS-PDF password", html)