إنجاز: تفعيل خاصية استعادة كلمة المرور وإعادة تعيينها
- إضافة نقاط نهاية لخاصيتي استعادة كلمة المرور وإعادة تعيينها في الواجهة الخلفية. - إنشاء اختبارات لخاصية إعادة تعيين كلمة المرور لضمان كفاءتها وأمانها. - تطوير صفحات واجهة المستخدم لخاصيتي استعادة كلمة المرور وإعادة تعيينها مع معالجة النماذج. - دمج حدود تحميل ديناميكية لأنواع ملفات مختلفة بناءً على خطط المستخدمين. - تقديم أداة جديدة لتغيير حجم الصور مع إمكانية تعديل الأبعاد وإعدادات الجودة. - تحديث نظام التوجيه والتنقل ليشمل أدوات جديدة وميزات مصادقة. - تحسين تجربة المستخدم من خلال معالجة الأخطاء ورسائل التغذية الراجعة المناسبة. - إضافة دعم التدويل للميزات الجديدة باللغات الإنجليزية والعربية والفرنسية.
This commit is contained in:
@@ -89,6 +89,7 @@ def create_app(config_name=None):
|
||||
from app.routes.pdf_tools import pdf_tools_bp
|
||||
from app.routes.flowchart import flowchart_bp
|
||||
from app.routes.v1.tools import v1_bp
|
||||
from app.routes.config import config_bp
|
||||
|
||||
app.register_blueprint(health_bp, url_prefix="/api")
|
||||
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
||||
@@ -104,5 +105,6 @@ def create_app(config_name=None):
|
||||
app.register_blueprint(tasks_bp, url_prefix="/api/tasks")
|
||||
app.register_blueprint(download_bp, url_prefix="/api/download")
|
||||
app.register_blueprint(v1_bp, url_prefix="/api/v1")
|
||||
app.register_blueprint(config_bp, url_prefix="/api/config")
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Flask extensions initialization."""
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from flask_cors import CORS
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
@@ -33,6 +34,14 @@ def init_celery(app):
|
||||
"app.tasks.flowchart_tasks.*": {"queue": "flowchart"},
|
||||
}
|
||||
|
||||
# Celery Beat — periodic tasks
|
||||
celery.conf.beat_schedule = {
|
||||
"cleanup-expired-files": {
|
||||
"task": "app.tasks.maintenance_tasks.cleanup_expired_files",
|
||||
"schedule": crontab(minute="*/30"),
|
||||
},
|
||||
}
|
||||
|
||||
class ContextTask(celery.Task):
|
||||
"""Make Celery tasks work with Flask app context."""
|
||||
abstract = True
|
||||
|
||||
@@ -8,7 +8,12 @@ from app.services.account_service import (
|
||||
authenticate_user,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_email,
|
||||
create_password_reset_token,
|
||||
verify_and_consume_reset_token,
|
||||
update_user_password,
|
||||
)
|
||||
from app.services.email_service import send_password_reset_email
|
||||
from app.utils.auth import (
|
||||
get_current_user_id,
|
||||
login_user_session,
|
||||
@@ -98,3 +103,48 @@ def me_route():
|
||||
return jsonify({"authenticated": False, "user": None}), 200
|
||||
|
||||
return jsonify({"authenticated": True, "user": user}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/forgot-password", methods=["POST"])
|
||||
@limiter.limit("5/hour")
|
||||
def forgot_password_route():
|
||||
"""Send a password reset email if the account exists.
|
||||
|
||||
Always returns 200 to avoid leaking whether an email is registered.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = str(data.get("email", "")).strip().lower()
|
||||
|
||||
if not email or not EMAIL_PATTERN.match(email):
|
||||
return jsonify({"message": "If that email is registered, a reset link has been sent."}), 200
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is not None:
|
||||
token = create_password_reset_token(user["id"])
|
||||
send_password_reset_email(email, token)
|
||||
|
||||
return jsonify({"message": "If that email is registered, a reset link has been sent."}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/reset-password", methods=["POST"])
|
||||
@limiter.limit("10/hour")
|
||||
def reset_password_route():
|
||||
"""Consume a reset token and set a new password."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = str(data.get("token", "")).strip()
|
||||
password = str(data.get("password", ""))
|
||||
|
||||
if not token:
|
||||
return jsonify({"error": "Reset token is required."}), 400
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters."}), 400
|
||||
if len(password) > MAX_PASSWORD_LENGTH:
|
||||
return jsonify({"error": f"Password must be {MAX_PASSWORD_LENGTH} characters or less."}), 400
|
||||
|
||||
user_id = verify_and_consume_reset_token(token)
|
||||
if user_id is None:
|
||||
return jsonify({"error": "Invalid or expired reset token."}), 400
|
||||
|
||||
update_user_password(user_id, password)
|
||||
return jsonify({"message": "Password updated successfully. You can now sign in."}), 200
|
||||
|
||||
32
backend/app/routes/config.py
Normal file
32
backend/app/routes/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Public configuration endpoint — returns dynamic upload limits."""
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from app.services.policy_service import (
|
||||
get_effective_file_size_limits_mb,
|
||||
get_usage_summary_for_user,
|
||||
resolve_web_actor,
|
||||
FREE_PLAN,
|
||||
)
|
||||
|
||||
config_bp = Blueprint("config", __name__)
|
||||
|
||||
|
||||
@config_bp.route("", methods=["GET"])
|
||||
def get_config():
|
||||
"""Return dynamic upload limits and (if logged-in) usage summary.
|
||||
|
||||
Anonymous callers get free-plan limits.
|
||||
Authenticated callers get plan-aware limits + quota usage.
|
||||
"""
|
||||
actor = resolve_web_actor()
|
||||
file_limits_mb = get_effective_file_size_limits_mb(actor.plan)
|
||||
|
||||
payload: dict = {
|
||||
"file_limits_mb": file_limits_mb,
|
||||
"max_upload_mb": max(file_limits_mb.values()),
|
||||
}
|
||||
|
||||
if actor.user_id is not None:
|
||||
payload["usage"] = get_usage_summary_for_user(actor.user_id, actor.plan)
|
||||
|
||||
return jsonify(payload), 200
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
72
backend/app/services/email_service.py
Normal file
72
backend/app/services/email_service.py
Normal 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)
|
||||
92
backend/app/tasks/maintenance_tasks.py
Normal file
92
backend/app/tasks/maintenance_tasks.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Periodic maintenance tasks — file cleanup and logging."""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from app.extensions import celery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.task(name="app.tasks.maintenance_tasks.cleanup_expired_files")
|
||||
def cleanup_expired_files():
|
||||
"""Remove upload/output directories older than FILE_EXPIRY_SECONDS.
|
||||
|
||||
Runs as a Celery Beat periodic task.
|
||||
Logs a summary of scanned/deleted/freed counts.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
expiry = current_app.config.get("FILE_EXPIRY_SECONDS", 1800)
|
||||
upload_dir = current_app.config.get("UPLOAD_FOLDER", "/tmp/uploads")
|
||||
output_dir = current_app.config.get("OUTPUT_FOLDER", "/tmp/outputs")
|
||||
|
||||
total_stats = {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
|
||||
|
||||
for target_dir in [upload_dir, output_dir]:
|
||||
stats = _cleanup_dir(target_dir, expiry)
|
||||
for key in total_stats:
|
||||
total_stats[key] += stats[key]
|
||||
|
||||
logger.info(
|
||||
"Cleanup complete: scanned=%d deleted=%d freed=%.1fMB errors=%d",
|
||||
total_stats["scanned"],
|
||||
total_stats["deleted"],
|
||||
total_stats["freed_bytes"] / (1024 * 1024),
|
||||
total_stats["errors"],
|
||||
)
|
||||
|
||||
# Log cleanup event
|
||||
try:
|
||||
from app.services.account_service import log_file_event
|
||||
|
||||
log_file_event(
|
||||
"cleanup",
|
||||
detail=f"deleted={total_stats['deleted']} freed={total_stats['freed_bytes']} errors={total_stats['errors']}",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not log file_event for cleanup")
|
||||
|
||||
return total_stats
|
||||
|
||||
|
||||
def _cleanup_dir(directory: str, expiry_seconds: int) -> dict:
|
||||
"""Scan one directory and remove expired sub-directories."""
|
||||
stats = {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
return stats
|
||||
|
||||
now = time.time()
|
||||
|
||||
for entry in os.listdir(directory):
|
||||
full_path = os.path.join(directory, entry)
|
||||
if not os.path.isdir(full_path):
|
||||
continue
|
||||
|
||||
stats["scanned"] += 1
|
||||
try:
|
||||
mod_time = os.path.getmtime(full_path)
|
||||
except OSError:
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
if (now - mod_time) <= expiry_seconds:
|
||||
continue
|
||||
|
||||
try:
|
||||
dir_size = sum(
|
||||
os.path.getsize(os.path.join(dp, f))
|
||||
for dp, _, filenames in os.walk(full_path)
|
||||
for f in filenames
|
||||
)
|
||||
shutil.rmtree(full_path)
|
||||
stats["deleted"] += 1
|
||||
stats["freed_bytes"] += dir_size
|
||||
logger.debug("Deleted expired: %s (%.1fKB)", entry, dir_size / 1024)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete %s", full_path)
|
||||
stats["errors"] += 1
|
||||
|
||||
return stats
|
||||
@@ -11,3 +11,4 @@ import app.tasks.image_tasks # noqa: F401
|
||||
import app.tasks.video_tasks # noqa: F401
|
||||
import app.tasks.pdf_tools_tasks # noqa: F401
|
||||
import app.tasks.flowchart_tasks # noqa: F401
|
||||
import app.tasks.maintenance_tasks # noqa: F401
|
||||
|
||||
@@ -86,6 +86,18 @@ class BaseConfig:
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"
|
||||
)
|
||||
|
||||
# SMTP (for password reset emails)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@saas-pdf.com")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||
|
||||
# Feature flags
|
||||
FEATURE_EDITOR = os.getenv("FEATURE_EDITOR", "false").lower() == "true"
|
||||
|
||||
|
||||
class DevelopmentConfig(BaseConfig):
|
||||
"""Development configuration."""
|
||||
|
||||
53
backend/tests/test_config.py
Normal file
53
backend/tests/test_config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for GET /api/config — dynamic upload limits."""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestConfigEndpoint:
|
||||
"""Tests for the public config endpoint."""
|
||||
|
||||
def test_anonymous_gets_free_limits(self, client):
|
||||
"""Anonymous users receive free-plan file limits."""
|
||||
resp = client.get("/api/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
|
||||
assert "file_limits_mb" in data
|
||||
assert "max_upload_mb" in data
|
||||
limits = data["file_limits_mb"]
|
||||
assert limits["pdf"] == 20
|
||||
assert limits["word"] == 15
|
||||
assert limits["image"] == 10
|
||||
assert limits["video"] == 50
|
||||
assert limits["homepageSmartUpload"] == 50
|
||||
# No usage section for anon
|
||||
assert "usage" not in data
|
||||
|
||||
def test_authenticated_free_user_gets_usage(self, client, app):
|
||||
"""Logged-in free user receives limits + usage summary."""
|
||||
# Register + login
|
||||
client.post("/api/auth/register", json={
|
||||
"email": "config_test@example.com",
|
||||
"password": "TestPassword123!",
|
||||
})
|
||||
client.post("/api/auth/login", json={
|
||||
"email": "config_test@example.com",
|
||||
"password": "TestPassword123!",
|
||||
})
|
||||
|
||||
resp = client.get("/api/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
|
||||
assert data["file_limits_mb"]["pdf"] == 20
|
||||
assert "usage" in data
|
||||
usage = data["usage"]
|
||||
assert usage["plan"] == "free"
|
||||
assert "web_quota" in usage
|
||||
assert "api_quota" in usage
|
||||
|
||||
def test_max_upload_mb_is_correct(self, client):
|
||||
"""max_upload_mb should equal the largest single-type limit."""
|
||||
resp = client.get("/api/config")
|
||||
data = resp.get_json()
|
||||
limits = data["file_limits_mb"]
|
||||
assert data["max_upload_mb"] == max(limits.values())
|
||||
116
backend/tests/test_maintenance_tasks.py
Normal file
116
backend/tests/test_maintenance_tasks.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for the cleanup_expired_files periodic maintenance task."""
|
||||
import os
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.tasks.maintenance_tasks import _cleanup_dir
|
||||
|
||||
|
||||
class TestCleanupDir:
|
||||
"""Tests for _cleanup_dir helper."""
|
||||
|
||||
def test_returns_zeros_for_missing_directory(self):
|
||||
stats = _cleanup_dir("/no/such/path", 1800)
|
||||
assert stats == {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
|
||||
|
||||
def test_skips_files_in_root(self, tmp_path):
|
||||
"""Regular files in the root should be ignored (only dirs scanned)."""
|
||||
(tmp_path / "regular.txt").write_text("hello")
|
||||
stats = _cleanup_dir(str(tmp_path), 1800)
|
||||
assert stats["scanned"] == 0
|
||||
assert stats["deleted"] == 0
|
||||
|
||||
def test_keeps_recent_directory(self, tmp_path):
|
||||
"""Directories younger than expiry should remain untouched."""
|
||||
sub = tmp_path / "recent_job"
|
||||
sub.mkdir()
|
||||
(sub / "file.pdf").write_bytes(b"%PDF-1.4 test")
|
||||
stats = _cleanup_dir(str(tmp_path), 1800)
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["deleted"] == 0
|
||||
assert sub.exists()
|
||||
|
||||
def test_deletes_expired_directory(self, tmp_path):
|
||||
"""Directories older than expiry should be removed."""
|
||||
sub = tmp_path / "old_job"
|
||||
sub.mkdir()
|
||||
(sub / "file.pdf").write_bytes(b"%PDF-1.4 test")
|
||||
# Set mtime to 1 hour ago
|
||||
old_time = time.time() - 3600
|
||||
os.utime(str(sub), (old_time, old_time))
|
||||
|
||||
stats = _cleanup_dir(str(tmp_path), 1800)
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["deleted"] == 1
|
||||
assert stats["freed_bytes"] > 0
|
||||
assert not sub.exists()
|
||||
|
||||
def test_counts_freed_bytes(self, tmp_path):
|
||||
"""Freed bytes should approximately match the size of deleted files."""
|
||||
sub = tmp_path / "old_job"
|
||||
sub.mkdir()
|
||||
content = b"A" * 4096
|
||||
(sub / "data.bin").write_bytes(content)
|
||||
old_time = time.time() - 3600
|
||||
os.utime(str(sub), (old_time, old_time))
|
||||
|
||||
stats = _cleanup_dir(str(tmp_path), 1800)
|
||||
assert stats["freed_bytes"] >= 4096
|
||||
|
||||
def test_mixed_old_and_new(self, tmp_path):
|
||||
"""Only expired directories are deleted, recent ones kept."""
|
||||
old = tmp_path / "expired_dir"
|
||||
old.mkdir()
|
||||
(old / "a.txt").write_text("old")
|
||||
old_time = time.time() - 7200
|
||||
os.utime(str(old), (old_time, old_time))
|
||||
|
||||
recent = tmp_path / "fresh_dir"
|
||||
recent.mkdir()
|
||||
(recent / "b.txt").write_text("new")
|
||||
|
||||
stats = _cleanup_dir(str(tmp_path), 1800)
|
||||
assert stats["scanned"] == 2
|
||||
assert stats["deleted"] == 1
|
||||
assert not old.exists()
|
||||
assert recent.exists()
|
||||
|
||||
|
||||
class TestCleanupExpiredFilesTask:
|
||||
"""Integration test for the Celery task via direct invocation."""
|
||||
|
||||
def test_task_runs_and_returns_stats(self, app):
|
||||
"""Task should return a summary dict."""
|
||||
# Create an expired directory in uploads
|
||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
||||
expired = os.path.join(upload_dir, "expired_session")
|
||||
os.makedirs(expired, exist_ok=True)
|
||||
with open(os.path.join(expired, "test.pdf"), "wb") as f:
|
||||
f.write(b"%PDF-TEST")
|
||||
old_time = time.time() - 7200
|
||||
os.utime(expired, (old_time, old_time))
|
||||
|
||||
with app.app_context():
|
||||
from app.tasks.maintenance_tasks import cleanup_expired_files
|
||||
result = cleanup_expired_files()
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["deleted"] >= 1
|
||||
assert result["freed_bytes"] > 0
|
||||
assert not os.path.exists(expired)
|
||||
|
||||
def test_task_leaves_recent_alone(self, app):
|
||||
"""Task should not delete recent directories."""
|
||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
||||
recent = os.path.join(upload_dir, "recent_session")
|
||||
os.makedirs(recent, exist_ok=True)
|
||||
with open(os.path.join(recent, "test.pdf"), "wb") as f:
|
||||
f.write(b"%PDF-TEST")
|
||||
|
||||
with app.app_context():
|
||||
from app.tasks.maintenance_tasks import cleanup_expired_files
|
||||
result = cleanup_expired_files()
|
||||
|
||||
assert result["deleted"] == 0
|
||||
assert os.path.exists(recent)
|
||||
132
backend/tests/test_password_reset.py
Normal file
132
backend/tests/test_password_reset.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Tests for forgot-password and reset-password endpoints."""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestForgotPassword:
|
||||
"""Tests for POST /api/auth/forgot-password."""
|
||||
|
||||
def test_forgot_password_returns_200_for_unknown_email(self, client):
|
||||
"""Should always return 200 to avoid leaking registration info."""
|
||||
resp = client.post("/api/auth/forgot-password", json={
|
||||
"email": "doesnotexist@example.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "message" in resp.get_json()
|
||||
|
||||
def test_forgot_password_returns_200_for_registered_email(self, client):
|
||||
"""Should return 200 and trigger email sending."""
|
||||
client.post("/api/auth/register", json={
|
||||
"email": "reset_user@example.com",
|
||||
"password": "TestPassword123!",
|
||||
})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
with patch("app.routes.auth.send_password_reset_email") as mock_send:
|
||||
mock_send.return_value = True
|
||||
resp = client.post("/api/auth/forgot-password", json={
|
||||
"email": "reset_user@example.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_send.assert_called_once()
|
||||
|
||||
def test_forgot_password_bad_email_format(self, client):
|
||||
"""Still returns 200 even for bad email format (no info leak)."""
|
||||
resp = client.post("/api/auth/forgot-password", json={
|
||||
"email": "not-an-email",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestResetPassword:
|
||||
"""Tests for POST /api/auth/reset-password."""
|
||||
|
||||
def test_reset_password_missing_token(self, client):
|
||||
"""Should reject when token is empty."""
|
||||
resp = client.post("/api/auth/reset-password", json={
|
||||
"token": "",
|
||||
"password": "NewPassword123!",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_reset_password_invalid_token(self, client):
|
||||
"""Should reject unknown token."""
|
||||
resp = client.post("/api/auth/reset-password", json={
|
||||
"token": "totally-invalid-token",
|
||||
"password": "NewPassword123!",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_reset_password_short_password(self, client):
|
||||
"""Should reject short passwords."""
|
||||
resp = client.post("/api/auth/reset-password", json={
|
||||
"token": "some-token",
|
||||
"password": "short",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_reset_password_full_flow(self, client, app):
|
||||
"""Register → forgot → get token → reset → login with new password."""
|
||||
# Register
|
||||
client.post("/api/auth/register", json={
|
||||
"email": "fullreset@example.com",
|
||||
"password": "OldPassword123!",
|
||||
})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
# Create reset token directly
|
||||
from app.services.account_service import get_user_by_email, create_password_reset_token
|
||||
|
||||
with app.app_context():
|
||||
user = get_user_by_email("fullreset@example.com")
|
||||
token = create_password_reset_token(user["id"])
|
||||
|
||||
# Reset
|
||||
resp = client.post("/api/auth/reset-password", json={
|
||||
"token": token,
|
||||
"password": "NewPassword123!",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Login with new password
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"email": "fullreset@example.com",
|
||||
"password": "NewPassword123!",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Old password should fail
|
||||
client.post("/api/auth/logout")
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"email": "fullreset@example.com",
|
||||
"password": "OldPassword123!",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_reset_token_cannot_be_reused(self, client, app):
|
||||
"""A reset token should be consumed on use and fail on second use."""
|
||||
client.post("/api/auth/register", json={
|
||||
"email": "reuse@example.com",
|
||||
"password": "OldPassword123!",
|
||||
})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
from app.services.account_service import get_user_by_email, create_password_reset_token
|
||||
|
||||
with app.app_context():
|
||||
user = get_user_by_email("reuse@example.com")
|
||||
token = create_password_reset_token(user["id"])
|
||||
|
||||
# First use — should succeed
|
||||
resp = client.post("/api/auth/reset-password", json={
|
||||
"token": token,
|
||||
"password": "NewPassword123!",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Second use — should fail
|
||||
resp = client.post("/api/auth/reset-password", json={
|
||||
"token": token,
|
||||
"password": "AnotherPassword123!",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
@@ -67,6 +67,28 @@ services:
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# --- Celery Beat (Scheduled Tasks) ---
|
||||
celery_beat:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
command: >
|
||||
celery -A celery_worker.celery beat
|
||||
--loglevel=info
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# --- React Frontend (Vite Dev) ---
|
||||
frontend:
|
||||
build:
|
||||
|
||||
124
docs/feature-editor.md
Normal file
124
docs/feature-editor.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Feature: Critical Maintenance & Editor Foundation
|
||||
|
||||
Branch: `feature/critical-maintenance-and-editor`
|
||||
|
||||
---
|
||||
|
||||
## Block A — Critical Maintenance (Sprint 1)
|
||||
|
||||
### A1 — Dynamic Upload Limits (`/api/config`)
|
||||
|
||||
**Backend:**
|
||||
- `GET /api/config` returns plan-aware file-size limits and usage summary.
|
||||
- Registered as `config_bp` at `/api/config`.
|
||||
- Anonymous users receive free-tier limits; authenticated users receive limits according to their plan plus a usage summary.
|
||||
|
||||
**Frontend:**
|
||||
- `useConfig` hook (`src/hooks/useConfig.ts`) fetches limits from the config endpoint with a fallback to the hardcoded `TOOL_LIMITS_MB`.
|
||||
- `HeroUploadZone` and `PdfEditor` consume dynamic limits via `useConfig`.
|
||||
|
||||
### A2 — Image Resize Tool
|
||||
|
||||
**Frontend page:** `src/components/tools/ImageResize.tsx`
|
||||
**Route:** `/tools/image-resize`
|
||||
**Backend endpoint:** `POST /api/image/resize` (already existed)
|
||||
|
||||
Features:
|
||||
- Width / height inputs with lock-aspect-ratio toggle.
|
||||
- Quality slider (1–100, default 85).
|
||||
- Accepts files from the homepage smart-upload handoff (via `fileStore`).
|
||||
- i18n keys added for `en`, `ar`, `fr`.
|
||||
|
||||
### A3 — SMTP & Forgot / Reset Password
|
||||
|
||||
**Config keys** (set via environment variables):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | `""` | SMTP server hostname |
|
||||
| `SMTP_PORT` | `587` | SMTP server port |
|
||||
| `SMTP_USER` | `""` | SMTP login |
|
||||
| `SMTP_PASSWORD` | `""` | SMTP password |
|
||||
| `SMTP_FROM` | `"noreply@example.com"` | Sender address |
|
||||
| `SMTP_USE_TLS` | `true` | Use STARTTLS |
|
||||
| `FRONTEND_URL` | `http://localhost:5173` | Used in reset-email link |
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Method | Path | Rate limit | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/auth/forgot-password` | 5/hour | Sends reset email (always returns 200) |
|
||||
| `POST` | `/api/auth/reset-password` | 10/hour | Consumes token, sets new password |
|
||||
|
||||
**Database tables added:**
|
||||
- `password_reset_tokens` — stores hashed tokens with 1-hour expiry.
|
||||
- `file_events` — audit log for file-lifecycle events (see A4).
|
||||
|
||||
**Frontend pages:**
|
||||
- `/forgot-password` — email form
|
||||
- `/reset-password?token=…` — new-password form
|
||||
|
||||
### A4 — Celery Beat Cleanup Task
|
||||
|
||||
**Task:** `app.tasks.maintenance_tasks.cleanup_expired_files`
|
||||
**Schedule:** Every 30 minutes via Celery Beat (`crontab(minute="*/30")`).
|
||||
**Behaviour:** Scans `UPLOAD_FOLDER` and `OUTPUT_FOLDER` for sub-directories older than `FILE_EXPIRY_SECONDS` (default 1800 s). Deletes them and logs a cleanup event to `file_events`.
|
||||
|
||||
**Docker:** A `celery_beat` service was added to `docker-compose.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Feature Flag
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `FEATURE_EDITOR` | `false` | Gates Block-B editor features (OCR, Remove BG, PDF Editor). Not used by Block-A features. |
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| File | Tests | Status |
|
||||
|---|---|---|
|
||||
| `test_config.py` | 3 | ✅ Passed |
|
||||
| `test_password_reset.py` | 8 | ✅ Passed |
|
||||
| `test_maintenance_tasks.py` | 8 | ✅ Passed |
|
||||
| **Full suite** | **158** | **✅ All passed** |
|
||||
|
||||
---
|
||||
|
||||
## Files Changed / Created
|
||||
|
||||
### Backend — New
|
||||
- `app/routes/config.py`
|
||||
- `app/services/email_service.py`
|
||||
- `app/tasks/maintenance_tasks.py`
|
||||
- `tests/test_config.py`
|
||||
- `tests/test_password_reset.py`
|
||||
- `tests/test_maintenance_tasks.py`
|
||||
|
||||
### Backend — Modified
|
||||
- `app/__init__.py` — registered `config_bp`
|
||||
- `config/__init__.py` — SMTP settings, `FRONTEND_URL`, `FEATURE_EDITOR`
|
||||
- `app/extensions.py` — Celery Beat schedule
|
||||
- `app/routes/auth.py` — forgot/reset password endpoints
|
||||
- `app/services/account_service.py` — reset-token & file-event helpers, new tables
|
||||
- `celery_worker.py` — imports `maintenance_tasks`
|
||||
|
||||
### Frontend — New
|
||||
- `src/hooks/useConfig.ts`
|
||||
- `src/components/tools/ImageResize.tsx`
|
||||
- `src/pages/ForgotPasswordPage.tsx`
|
||||
- `src/pages/ResetPasswordPage.tsx`
|
||||
|
||||
### Frontend — Modified
|
||||
- `src/App.tsx` — 3 new routes
|
||||
- `src/components/shared/HeroUploadZone.tsx` — uses `useConfig`
|
||||
- `src/components/tools/PdfEditor.tsx` — uses `useConfig`
|
||||
- `src/pages/HomePage.tsx` — Image Resize tool card
|
||||
- `src/pages/AccountPage.tsx` — "Forgot password?" link
|
||||
- `src/utils/fileRouting.ts` — imageResize in tool list
|
||||
- `src/i18n/en.json`, `ar.json`, `fr.json` — new keys
|
||||
|
||||
### Infrastructure
|
||||
- `docker-compose.yml` — `celery_beat` service
|
||||
@@ -13,6 +13,8 @@ const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
|
||||
const TermsPage = lazy(() => import('@/pages/TermsPage'));
|
||||
const AccountPage = lazy(() => import('@/pages/AccountPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||
|
||||
// Tool Pages
|
||||
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord'));
|
||||
@@ -33,6 +35,7 @@ const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf'));
|
||||
const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers'));
|
||||
const PdfEditor = lazy(() => import('@/components/tools/PdfEditor'));
|
||||
const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart'));
|
||||
const ImageResize = lazy(() => import('@/components/tools/ImageResize'));
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
@@ -67,6 +70,8 @@ export default function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
|
||||
@@ -88,6 +93,7 @@ export default function App() {
|
||||
|
||||
{/* Image Tools */}
|
||||
<Route path="/tools/image-converter" element={<ImageConverter />} />
|
||||
<Route path="/tools/image-resize" element={<ImageResize />} />
|
||||
|
||||
{/* Video Tools */}
|
||||
<Route path="/tools/video-to-gif" element={<VideoToGif />} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
|
||||
import type { ToolOption } from '@/utils/fileRouting';
|
||||
import { TOOL_LIMITS_MB } from '@/config/toolLimits';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
|
||||
/**
|
||||
* The MIME types we accept on the homepage smart upload zone.
|
||||
@@ -28,6 +28,7 @@ export default function HeroUploadZone() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const setStoreFile = useFileStore((s) => s.setFile);
|
||||
const { limits } = useConfig();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [matchedTools, setMatchedTools] = useState<ToolOption[]>([]);
|
||||
const [fileTypeLabel, setFileTypeLabel] = useState('');
|
||||
@@ -63,11 +64,11 @@ export default function HeroUploadZone() {
|
||||
onDrop,
|
||||
accept: ACCEPTED_TYPES,
|
||||
maxFiles: 1,
|
||||
maxSize: TOOL_LIMITS_MB.homepageSmartUpload * 1024 * 1024,
|
||||
maxSize: limits.homepageSmartUpload * 1024 * 1024,
|
||||
onDropRejected: (rejections) => {
|
||||
const rejection = rejections[0];
|
||||
if (rejection?.errors[0]?.code === 'file-too-large') {
|
||||
setError(t('common.maxSize', { size: TOOL_LIMITS_MB.homepageSmartUpload }));
|
||||
setError(t('common.maxSize', { size: limits.homepageSmartUpload }));
|
||||
} else {
|
||||
setError(t('home.unsupportedFile'));
|
||||
}
|
||||
|
||||
231
frontend/src/components/tools/ImageResize.tsx
Normal file
231
frontend/src/components/tools/ImageResize.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Scaling } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
|
||||
export default function ImageResize() {
|
||||
const { t } = useTranslation();
|
||||
const { limits } = useConfig();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [width, setWidth] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [quality, setQuality] = useState(85);
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/image/resize',
|
||||
maxSizeMB: limits.image,
|
||||
acceptedTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
extraData: {
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {}),
|
||||
quality: quality.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
// Accept file from homepage smart upload
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
useEffect(() => {
|
||||
if (storeFile) {
|
||||
selectFile(storeFile);
|
||||
clearStoreFile();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!width && !height) return;
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
setWidth('');
|
||||
setHeight('');
|
||||
};
|
||||
|
||||
const dimensionValid = width || height;
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.imageResize.title'),
|
||||
description: t('tools.imageResize.description'),
|
||||
url: `${window.location.origin}/tools/image-resize`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.imageResize.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.imageResize.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/image-resize`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-100 dark:bg-teal-900/30">
|
||||
<Scaling className="h-8 w-8 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.imageResize.title')}</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.imageResize.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/webp': ['.webp'],
|
||||
}}
|
||||
maxSizeMB={limits.image}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="Images (PNG, JPG, WebP)"
|
||||
/>
|
||||
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
{/* Dimensions */}
|
||||
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('tools.imageResize.dimensions')}
|
||||
</span>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lockAspect}
|
||||
onChange={(e) => setLockAspect(e.target.checked)}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{t('tools.imageResize.lockAspect')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('tools.imageResize.width')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="e.g. 800"
|
||||
value={width}
|
||||
onChange={(e) => {
|
||||
setWidth(e.target.value);
|
||||
if (lockAspect) setHeight('');
|
||||
}}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('tools.imageResize.height')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="e.g. 600"
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
setHeight(e.target.value);
|
||||
if (lockAspect) setWidth('');
|
||||
}}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{lockAspect && (
|
||||
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
{t('tools.imageResize.aspectHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quality Slider */}
|
||||
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<label className="mb-2 flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
<span>{t('tools.imageResize.quality')}</span>
|
||||
<span className="text-primary-600">{quality}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full accent-primary-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!dimensionValid}
|
||||
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('tools.imageResize.shortDesc')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,11 @@ import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { TOOL_LIMITS_MB } from '@/config/toolLimits';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
|
||||
export default function PdfEditor() {
|
||||
const { t } = useTranslation();
|
||||
const { limits } = useConfig();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
|
||||
const {
|
||||
@@ -33,7 +34,7 @@ export default function PdfEditor() {
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/compress/pdf',
|
||||
maxSizeMB: TOOL_LIMITS_MB.pdf,
|
||||
maxSizeMB: limits.pdf,
|
||||
acceptedTypes: ['pdf'],
|
||||
extraData: { quality: 'high' },
|
||||
});
|
||||
@@ -100,7 +101,7 @@ export default function PdfEditor() {
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={TOOL_LIMITS_MB.pdf}
|
||||
maxSizeMB={limits.pdf}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
|
||||
45
frontend/src/hooks/useConfig.ts
Normal file
45
frontend/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { TOOL_LIMITS_MB } from '@/config/toolLimits';
|
||||
|
||||
interface FileLimitsMb {
|
||||
pdf: number;
|
||||
word: number;
|
||||
image: number;
|
||||
video: number;
|
||||
homepageSmartUpload: number;
|
||||
}
|
||||
|
||||
interface ConfigData {
|
||||
file_limits_mb: FileLimitsMb;
|
||||
max_upload_mb: number;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
/**
|
||||
* Fetches dynamic upload limits from /api/config.
|
||||
* Falls back to the hardcoded TOOL_LIMITS_MB on error.
|
||||
*/
|
||||
export function useConfig() {
|
||||
const [limits, setLimits] = useState<FileLimitsMb>(TOOL_LIMITS_MB);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/config`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('config fetch failed');
|
||||
const data: ConfigData = await res.json();
|
||||
setLimits(data.file_limits_mb);
|
||||
} catch {
|
||||
// Keep hardcoded fallback
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
return { limits, loading, refetch: fetchConfig };
|
||||
}
|
||||
@@ -25,6 +25,25 @@
|
||||
"darkMode": "الوضع الداكن",
|
||||
"lightMode": "الوضع الفاتح"
|
||||
},
|
||||
"auth": {
|
||||
"forgotPassword": {
|
||||
"title": "نسيت كلمة المرور",
|
||||
"subtitle": "أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.",
|
||||
"submit": "إرسال رابط التعيين",
|
||||
"sent": "إذا كان هذا البريد مسجلاً، فقد تم إرسال رابط إعادة التعيين. تحقق من بريدك.",
|
||||
"error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
|
||||
"link": "نسيت كلمة المرور؟"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "إعادة تعيين كلمة المرور",
|
||||
"newPassword": "كلمة المرور الجديدة",
|
||||
"submit": "إعادة التعيين",
|
||||
"success": "تم تحديث كلمة المرور بنجاح! جارٍ التوجيه لتسجيل الدخول...",
|
||||
"error": "فشل إعادة التعيين. قد يكون الرابط منتهي الصلاحية.",
|
||||
"tooShort": "يجب أن تكون كلمة المرور 8 أحرف على الأقل.",
|
||||
"noToken": "رابط غير صالح. يرجى طلب رابط جديد."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة",
|
||||
"heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.",
|
||||
@@ -80,6 +99,17 @@
|
||||
"description": "حوّل الصور بين صيغ JPG و PNG و WebP فوراً.",
|
||||
"shortDesc": "تحويل الصور"
|
||||
},
|
||||
"imageResize": {
|
||||
"title": "تغيير حجم الصورة",
|
||||
"description": "غيّر أبعاد الصور بدقة مع الحفاظ على الجودة.",
|
||||
"shortDesc": "تغيير الحجم",
|
||||
"dimensions": "الأبعاد المطلوبة",
|
||||
"width": "العرض (بكسل)",
|
||||
"height": "الارتفاع (بكسل)",
|
||||
"quality": "الجودة",
|
||||
"lockAspect": "قفل نسبة العرض للارتفاع",
|
||||
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع."
|
||||
},
|
||||
"videoToGif": {
|
||||
"title": "فيديو إلى GIF",
|
||||
"description": "أنشئ صور GIF متحركة من مقاطع الفيديو. خصّص وقت البداية والمدة والجودة.",
|
||||
|
||||
@@ -25,6 +25,25 @@
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode"
|
||||
},
|
||||
"auth": {
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
"subtitle": "Enter your email and we'll send you a reset link.",
|
||||
"submit": "Send Reset Link",
|
||||
"sent": "If that email is registered, a reset link has been sent. Check your inbox.",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"link": "Forgot your password?"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset Password",
|
||||
"newPassword": "New Password",
|
||||
"submit": "Reset Password",
|
||||
"success": "Password updated successfully! Redirecting to sign in...",
|
||||
"error": "Failed to reset password. The link may have expired.",
|
||||
"tooShort": "Password must be at least 8 characters.",
|
||||
"noToken": "Invalid reset link. Please request a new one."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"hero": "Everything You Need to Work with PDF Files — Instantly",
|
||||
"heroSub": "Upload or drag & drop your file, and we'll auto-detect its type and suggest the right tools — edit, convert, compress, and more. No registration required.",
|
||||
@@ -80,6 +99,17 @@
|
||||
"description": "Convert images between JPG, PNG, and WebP formats instantly.",
|
||||
"shortDesc": "Convert Images"
|
||||
},
|
||||
"imageResize": {
|
||||
"title": "Image Resize",
|
||||
"description": "Resize images to exact dimensions while maintaining quality.",
|
||||
"shortDesc": "Resize Image",
|
||||
"dimensions": "Target Dimensions",
|
||||
"width": "Width (px)",
|
||||
"height": "Height (px)",
|
||||
"quality": "Quality",
|
||||
"lockAspect": "Lock aspect ratio",
|
||||
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio."
|
||||
},
|
||||
"videoToGif": {
|
||||
"title": "Video to GIF",
|
||||
"description": "Create animated GIFs from video clips. Customize start time, duration, and quality.",
|
||||
|
||||
@@ -25,6 +25,25 @@
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair"
|
||||
},
|
||||
"auth": {
|
||||
"forgotPassword": {
|
||||
"title": "Mot de passe oublié",
|
||||
"subtitle": "Entrez votre email et nous vous enverrons un lien de réinitialisation.",
|
||||
"submit": "Envoyer le lien",
|
||||
"sent": "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Vérifiez votre boîte de réception.",
|
||||
"error": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"link": "Mot de passe oublié ?"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Réinitialiser le mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"submit": "Réinitialiser",
|
||||
"success": "Mot de passe mis à jour avec succès ! Redirection vers la connexion...",
|
||||
"error": "Échec de la réinitialisation. Le lien a peut-être expiré.",
|
||||
"tooShort": "Le mot de passe doit contenir au moins 8 caractères.",
|
||||
"noToken": "Lien invalide. Veuillez en demander un nouveau."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"hero": "Tout ce dont vous avez besoin pour vos fichiers PDF — instantanément",
|
||||
"heroSub": "Déposez votre fichier ici, nous détecterons automatiquement son type et proposerons les outils adaptés — édition, conversion, compression et plus. Aucune inscription requise.",
|
||||
@@ -80,6 +99,17 @@
|
||||
"description": "Convertissez instantanément des images entre les formats JPG, PNG et WebP.",
|
||||
"shortDesc": "Convertir des images"
|
||||
},
|
||||
"imageResize": {
|
||||
"title": "Redimensionner l'image",
|
||||
"description": "Redimensionnez vos images aux dimensions exactes tout en préservant la qualité.",
|
||||
"shortDesc": "Redimensionner",
|
||||
"dimensions": "Dimensions cibles",
|
||||
"width": "Largeur (px)",
|
||||
"height": "Hauteur (px)",
|
||||
"quality": "Qualité",
|
||||
"lockAspect": "Verrouiller le rapport d'aspect",
|
||||
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect."
|
||||
},
|
||||
"videoToGif": {
|
||||
"title": "Vidéo en GIF",
|
||||
"description": "Créez des GIFs animés à partir de clips vidéo. Personnalisez le temps de début, la durée et la qualité.",
|
||||
|
||||
@@ -633,6 +633,14 @@ export default function AccountPage() {
|
||||
<button type="submit" className="btn-primary w-full" disabled={authLoading}>
|
||||
{mode === 'login' ? t('account.submitLogin') : t('account.submitRegister')}
|
||||
</button>
|
||||
|
||||
{mode === 'login' && (
|
||||
<p className="text-center text-sm">
|
||||
<a href="/forgot-password" className="text-primary-600 hover:underline dark:text-primary-400">
|
||||
{t('auth.forgotPassword.link')}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
95
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
95
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Request failed');
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError(t('auth.forgotPassword.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('auth.forgotPassword.title')} — {t('common.appName')}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<Mail className="h-8 w-8 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{t('auth.forgotPassword.title')}
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('auth.forgotPassword.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl bg-green-50 p-6 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{t('auth.forgotPassword.sent')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('account.emailPlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('common.loading') : t('auth.forgotPassword.submit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ListOrdered,
|
||||
PenLine,
|
||||
GitBranch,
|
||||
Scaling,
|
||||
} from 'lucide-react';
|
||||
import ToolCard from '@/components/shared/ToolCard';
|
||||
import HeroUploadZone from '@/components/shared/HeroUploadZone';
|
||||
@@ -50,6 +51,7 @@ const pdfTools: ToolInfo[] = [
|
||||
|
||||
const otherTools: ToolInfo[] = [
|
||||
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
|
||||
{ key: 'imageResize', path: '/tools/image-resize', icon: <Scaling className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' },
|
||||
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
|
||||
{ key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
|
||||
{ key: 'textCleaner', path: '/tools/text-cleaner', icon: <Eraser className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
|
||||
|
||||
130
frontend/src/pages/ResetPasswordPage.tsx
Normal file
130
frontend/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < 8) {
|
||||
setError(t('auth.resetPassword.tooShort'));
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError(t('account.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Reset failed');
|
||||
setSuccess(true);
|
||||
setTimeout(() => navigate('/account'), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('auth.resetPassword.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<p className="text-slate-500 dark:text-slate-400">{t('auth.resetPassword.noToken')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('auth.resetPassword.title')} — {t('common.appName')}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<KeyRound className="h-8 w-8 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{t('auth.resetPassword.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="rounded-2xl bg-green-50 p-6 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{t('auth.resetPassword.success')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('auth.resetPassword.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('account.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('common.loading') : t('auth.resetPassword.submit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Film,
|
||||
PenLine,
|
||||
GitBranch,
|
||||
Scaling,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
@@ -50,6 +51,7 @@ const pdfTools: ToolOption[] = [
|
||||
/** Image tools available when an image is uploaded */
|
||||
const imageTools: ToolOption[] = [
|
||||
{ key: 'imageConvert', path: '/tools/image-converter', icon: ImageIcon, bgColor: 'bg-purple-100 dark:bg-purple-900/30', iconColor: 'text-purple-600 dark:text-purple-400' },
|
||||
{ key: 'imageResize', path: '/tools/image-resize', icon: Scaling, bgColor: 'bg-teal-100 dark:bg-teal-900/30', iconColor: 'text-teal-600 dark:text-teal-400' },
|
||||
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: FileImage, bgColor: 'bg-lime-100 dark:bg-lime-900/30', iconColor: 'text-lime-600 dark:text-lime-400' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user