add user profile and login history
This commit is contained in:
parent
2b8fed9d6e
commit
c8aa1e016c
20 changed files with 1216 additions and 79 deletions
|
|
@ -12,6 +12,11 @@ class Config:
|
|||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
||||
|
||||
# Auth Security Configuration
|
||||
MAX_FAILED_LOGIN_ATTEMPTS = int(os.environ.get("MAX_FAILED_LOGIN_ATTEMPTS", "5"))
|
||||
ACCOUNT_LOCK_DURATION_MINUTES = int(os.environ.get("ACCOUNT_LOCK_DURATION_MINUTES", "30"))
|
||||
LOCKOUT_STRATEGY = os.environ.get("LOCKOUT_STRATEGY", "timeout") # "timeout" | "permanent"
|
||||
|
||||
# Celery Configuration
|
||||
CELERY = {
|
||||
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from app.models.epic import Epic
|
|||
from app.models.file_attachment import FileAttachment
|
||||
from app.models.label import Label
|
||||
from app.models.list_model import List
|
||||
from app.models.login_history import LoginHistory
|
||||
from app.models.user import User
|
||||
from app.models.wiki import Wiki, wiki_entity_links
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ __all__ = [
|
|||
"Board",
|
||||
"List",
|
||||
"Card",
|
||||
"LoginHistory",
|
||||
"Label",
|
||||
"CardLabel",
|
||||
"CardLink",
|
||||
|
|
|
|||
42
backend/app/models/login_history.py
Normal file
42
backend/app/models/login_history.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Login History model for tracking authentication events"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class LoginHistory(db.Model):
|
||||
"""Tracks login attempts (both success and failure)"""
|
||||
|
||||
__tablename__ = "login_history"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.String(512), nullable=True)
|
||||
success = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(
|
||||
db.DateTime, default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship("User", back_populates="login_history")
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert login history to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"ip_address": self.ip_address,
|
||||
"user_agent": self.user_agent,
|
||||
"success": self.success,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<LoginHistory id={self.id} user_id={self.user_id} "
|
||||
f"success={self.success}>"
|
||||
)
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
from datetime import UTC, datetime
|
||||
"""User model"""
|
||||
|
||||
from datetime import UTC, datetime, timezone
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
|
|
@ -18,6 +20,9 @@ class User(db.Model):
|
|||
last_name = db.Column(db.String(50))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime, nullable=True)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
|
|
@ -29,6 +34,21 @@ class User(db.Model):
|
|||
boards = db.relationship(
|
||||
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
|
||||
)
|
||||
login_history = db.relationship(
|
||||
"LoginHistory",
|
||||
back_populates="user",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_locked(self):
|
||||
"""Check if account is currently locked (timeout or permanent)"""
|
||||
if not self.is_active:
|
||||
return True
|
||||
if self.locked_until and self.locked_until > datetime.now(timezone.utc):
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_password(self, password):
|
||||
"""Hash and set password"""
|
||||
|
|
@ -47,9 +67,11 @@ class User(db.Model):
|
|||
"first_name": self.first_name,
|
||||
"last_name": self.last_name,
|
||||
"is_active": self.is_active,
|
||||
"is_admin": self.is_admin,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"last_login_at": self.last_login_at
|
||||
if self.last_login_at
|
||||
else None,
|
||||
"created_at": self.created_at if self.created_at else None,
|
||||
"updated_at": self.updated_at if self.updated_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
"""Auth and user management API routes"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_jwt_extended import (create_access_token, create_refresh_token,
|
||||
get_jwt_identity, jwt_required)
|
||||
from flask_jwt_extended import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_jwt_identity,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
from app import db
|
||||
from app.models import User
|
||||
from app.services.auth_service import (
|
||||
authenticate_user,
|
||||
change_password,
|
||||
get_login_history,
|
||||
register_user,
|
||||
)
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
|
@ -12,63 +24,78 @@ api_bp = Blueprint("api", __name__)
|
|||
@api_bp.route("/auth/register", methods=["POST"])
|
||||
def register():
|
||||
"""Register a new user"""
|
||||
data = request.get_json()
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get("email") or not data.get("password"):
|
||||
return jsonify({"error": "Email and password are required"}), 400
|
||||
if not data:
|
||||
return jsonify({"error": "Request body is required"}), 400
|
||||
|
||||
if User.query.filter_by(email=data["email"]).first():
|
||||
return jsonify({"error": "Email already exists"}), 400
|
||||
# Server-side validation: require confirm_password for all registrations
|
||||
confirm = data.get("confirm_password")
|
||||
if not confirm:
|
||||
return jsonify({"error": "confirm_password is required"}), 400
|
||||
|
||||
user = User(
|
||||
email=data["email"],
|
||||
username=data.get("username", data["email"].split("@")[0]),
|
||||
first_name=data.get("first_name"),
|
||||
last_name=data.get("last_name"),
|
||||
)
|
||||
user.set_password(data["password"])
|
||||
# Use the auth service for registration with validation
|
||||
user = register_user(data)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
# Generate tokens so user is logged in immediately
|
||||
access_token = create_access_token(identity=str(user.id))
|
||||
refresh_token = create_refresh_token(identity=str(user.id))
|
||||
|
||||
return jsonify(user.to_dict()), 201
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"user": user.to_dict(),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@api_bp.route("/auth/login", methods=["POST"])
|
||||
def login():
|
||||
"""Login user"""
|
||||
data = request.get_json()
|
||||
"""Login user with account lockout and login history tracking"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get("email") or not data.get("password"):
|
||||
return jsonify({"error": "Email and password are required"}), 400
|
||||
if not data or not data.get("email") or not data.get("password"):
|
||||
return jsonify({"error": "Email and password are required"}), 400
|
||||
|
||||
user = User.query.filter_by(email=data["email"]).first()
|
||||
# Capture IP and user agent for login history
|
||||
ip_address = request.remote_addr or "unknown"
|
||||
user_agent = request.headers.get("User-Agent", "unknown")
|
||||
|
||||
if not user or not user.check_password(data["password"]):
|
||||
return jsonify({"error": "Invalid credentials"}), 401
|
||||
user, access_token, refresh_token = authenticate_user(
|
||||
email=data["email"],
|
||||
password=data["password"],
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
return jsonify({"error": "Account is inactive"}), 401
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"user": user.to_dict(),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
access_token = create_access_token(identity=str(user.id))
|
||||
refresh_token = create_refresh_token(identity=str(user.id))
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"user": user.to_dict(),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 401
|
||||
|
||||
|
||||
@api_bp.route("/users/me", methods=["GET"])
|
||||
@jwt_required()
|
||||
def get_current_user():
|
||||
"""Get current user"""
|
||||
"""Get current user profile"""
|
||||
user_id = int(get_jwt_identity())
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
|
|
@ -76,3 +103,53 @@ def get_current_user():
|
|||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
return jsonify(user.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route("/users/me/password", methods=["PUT"])
|
||||
@jwt_required()
|
||||
def update_password():
|
||||
"""Change current user's password"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"error": "Request body is required"}), 400
|
||||
|
||||
current_password = data.get("current_password")
|
||||
new_password = data.get("new_password")
|
||||
confirm_password = data.get("confirm_password")
|
||||
|
||||
if not all([current_password, new_password, confirm_password]):
|
||||
return jsonify(
|
||||
{
|
||||
"error": "current_password, new_password, and confirm_password are required"
|
||||
}
|
||||
), 400
|
||||
|
||||
user_id = int(get_jwt_identity())
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
change_password(user, current_password, new_password, confirm_password)
|
||||
|
||||
return jsonify({"message": "Password updated successfully"}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@api_bp.route("/users/me/login-history", methods=["GET"])
|
||||
@jwt_required()
|
||||
def get_user_login_history():
|
||||
"""Get login history for current user"""
|
||||
user_id = int(get_jwt_identity())
|
||||
|
||||
limit = request.args.get("limit", 20, type=int)
|
||||
# Sanity check on limit
|
||||
limit = min(max(limit, 1), 100)
|
||||
|
||||
history = get_login_history(user_id, limit=limit)
|
||||
|
||||
return jsonify(history), 200
|
||||
208
backend/app/services/auth_service.py
Normal file
208
backend/app/services/auth_service.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""Authentication service for user registration, login, password management, and lockout"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
from flask import current_app
|
||||
from flask_jwt_extended import create_access_token, create_refresh_token
|
||||
|
||||
from app import db
|
||||
from app.models import LoginHistory, User
|
||||
|
||||
|
||||
def register_user(data):
|
||||
"""Register a new user with server-side password confirmation validation.
|
||||
|
||||
Args:
|
||||
data: dict with email, password, confirm_password, username, etc.
|
||||
|
||||
Returns:
|
||||
User object on success
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
email = data.get("email", "").strip().lower()
|
||||
password = data.get("password", "")
|
||||
confirm_password = data.get("confirm_password", "")
|
||||
username = data.get("username", email.split("@")[0] if email else "")
|
||||
|
||||
if not email:
|
||||
raise ValueError("Email is required")
|
||||
|
||||
if not password:
|
||||
raise ValueError("Password is required")
|
||||
|
||||
if password != confirm_password:
|
||||
raise ValueError("Passwords do not match")
|
||||
|
||||
if len(password) < 6:
|
||||
raise ValueError("Password must be at least 6 characters")
|
||||
|
||||
existing = User.query.filter_by(email=email).first()
|
||||
if existing:
|
||||
raise ValueError("Email already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
first_name=data.get("first_name"),
|
||||
last_name=data.get("last_name"),
|
||||
)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(email, password, ip_address=None, user_agent=None):
|
||||
"""Authenticate user with account lockout logic.
|
||||
|
||||
Implements two lockout strategies:
|
||||
- "timeout": locks account for ACCOUNT_LOCK_DURATION_MINUTES
|
||||
- "permanent": sets is_active to False (requires admin intervention)
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
password: Plain text password
|
||||
ip_address: Request IP address (for logging)
|
||||
user_agent: Request user agent (for logging)
|
||||
|
||||
Returns:
|
||||
(user, access_token, refresh_token) on success
|
||||
|
||||
Raises:
|
||||
ValueError: With appropriate error message on failure
|
||||
"""
|
||||
max_attempts = current_app.config["MAX_FAILED_LOGIN_ATTEMPTS"]
|
||||
lock_duration = current_app.config["ACCOUNT_LOCK_DURATION_MINUTES"]
|
||||
lockout_strategy = current_app.config["LOCKOUT_STRATEGY"]
|
||||
|
||||
user = User.query.filter_by(email=email.strip().lower()).first()
|
||||
|
||||
if not user:
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Check permanent lock (is_active=False)
|
||||
if not user.is_active:
|
||||
_record_login_history(user, ip_address, user_agent, success=False)
|
||||
raise ValueError(
|
||||
"Account has been locked. Contact an administrator."
|
||||
)
|
||||
|
||||
# Check timeout lock
|
||||
if user.locked_until and user.locked_until > datetime.now(timezone.utc):
|
||||
remaining_minutes = int(
|
||||
(user.locked_until - datetime.now(timezone.utc)).total_seconds() / 60
|
||||
)
|
||||
_record_login_history(user, ip_address, user_agent, success=False)
|
||||
raise ValueError(
|
||||
f"Account is locked. Try again in {remaining_minutes} minute(s)."
|
||||
)
|
||||
|
||||
if user.check_password(password):
|
||||
# Success: reset lockout counters
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
_record_login_history(user, ip_address, user_agent, success=True)
|
||||
|
||||
access_token = create_access_token(identity=str(user.id))
|
||||
refresh_token = create_refresh_token(identity=str(user.id))
|
||||
|
||||
return user, access_token, refresh_token
|
||||
|
||||
# Failed login
|
||||
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
|
||||
_record_login_history(user, ip_address, user_agent, success=False)
|
||||
|
||||
if user.failed_login_attempts >= max_attempts:
|
||||
if lockout_strategy == "permanent":
|
||||
user.is_active = False
|
||||
user.locked_until = None
|
||||
db.session.commit()
|
||||
raise ValueError(
|
||||
"Too many failed attempts. Account has been permanently locked. "
|
||||
"Contact an administrator."
|
||||
)
|
||||
else:
|
||||
# Default: timeout
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=lock_duration
|
||||
)
|
||||
db.session.commit()
|
||||
raise ValueError(
|
||||
f"Too many failed attempts. Account locked for "
|
||||
f"{lock_duration} minute(s)."
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
|
||||
def change_password(user, current_password, new_password, confirm_password):
|
||||
"""Change a user's password after verifying current password.
|
||||
|
||||
Args:
|
||||
user: User object
|
||||
current_password: Current password for verification
|
||||
new_password: New password
|
||||
confirm_password: Confirmation of new password
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
if not user.check_password(current_password):
|
||||
raise ValueError("Current password is incorrect")
|
||||
|
||||
if new_password != confirm_password:
|
||||
raise ValueError("New passwords do not match")
|
||||
|
||||
if len(new_password) < 6:
|
||||
raise ValueError("New password must be at least 6 characters")
|
||||
|
||||
if current_password == new_password:
|
||||
raise ValueError("New password must be different from current password")
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_login_history(user_id, limit=20):
|
||||
"""Get recent login history for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
limit: Maximum number of entries to return
|
||||
|
||||
Returns:
|
||||
List of LoginHistory dicts, most recent first
|
||||
"""
|
||||
entries = (
|
||||
LoginHistory.query.filter_by(user_id=user_id)
|
||||
.order_by(LoginHistory.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [entry.to_dict() for entry in entries]
|
||||
|
||||
|
||||
def _record_login_history(user, ip_address, user_agent, success):
|
||||
"""Record a login attempt in the login history.
|
||||
|
||||
Args:
|
||||
user: User object
|
||||
ip_address: Request IP address
|
||||
user_agent: Request user agent
|
||||
success: Whether the login was successful
|
||||
"""
|
||||
entry = LoginHistory(
|
||||
user_id=user.id,
|
||||
ip_address=ip_address or "unknown",
|
||||
user_agent=user_agent or "unknown",
|
||||
success=success,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""add login history model and auth lockout fields
|
||||
|
||||
Revision ID: 9975b2cbb113
|
||||
Revises: 7a0cfda486e1
|
||||
Create Date: 2026-07-24 19:56:44.795127
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '9975b2cbb113'
|
||||
down_revision = '7a0cfda486e1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('login_history',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.String(length=512), nullable=True),
|
||||
sa.Column('success', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('login_history', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_login_history_user_id'), ['user_id'], unique=False)
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('failed_login_attempts', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('locked_until', sa.DateTime(), nullable=True))
|
||||
batch_op.add_column(sa.Column('last_login_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('last_login_at')
|
||||
batch_op.drop_column('locked_until')
|
||||
batch_op.drop_column('failed_login_attempts')
|
||||
|
||||
with op.batch_alter_table('login_history', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_login_history_user_id'))
|
||||
|
||||
op.drop_table('login_history')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -13,6 +13,7 @@ class TestAuthRoutes:
|
|||
json={
|
||||
"email": "newuser@example.com",
|
||||
"password": "password123",
|
||||
"confirm_password": "password123",
|
||||
"username": "newuser",
|
||||
"first_name": "New",
|
||||
"last_name": "User",
|
||||
|
|
@ -21,10 +22,38 @@ class TestAuthRoutes:
|
|||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data["email"] == "newuser@example.com"
|
||||
assert data["username"] == "newuser"
|
||||
assert "password" not in data
|
||||
assert "password_hash" not in data
|
||||
assert data["user"]["email"] == "newuser@example.com"
|
||||
assert data["user"]["username"] == "newuser"
|
||||
assert "password" not in data["user"]
|
||||
assert "password_hash" not in data["user"]
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_register_password_mismatch(self, client):
|
||||
"""Test registration with mismatched passwords"""
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "mismatch@example.com",
|
||||
"password": "password123",
|
||||
"confirm_password": "differentpassword",
|
||||
"username": "mismatch",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert "do not match" in data["error"].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_register_missing_confirm_password(self, client):
|
||||
"""Test registration without confirm_password"""
|
||||
response = client.post(
|
||||
"/api/auth/register", json={"email": "noconfirm@example.com", "password": "password123"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert "confirm_password" in data["error"].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_register_missing_fields(self, client):
|
||||
|
|
@ -42,7 +71,11 @@ class TestAuthRoutes:
|
|||
"""Test registration with duplicate email"""
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": regular_user.email, "password": "password123"},
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "password123",
|
||||
"confirm_password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
|
@ -96,7 +129,7 @@ class TestAuthRoutes:
|
|||
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert "inactive" in data["error"].lower()
|
||||
assert "locked" in data["error"].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_get_current_user(self, client, auth_headers, regular_user):
|
||||
|
|
@ -112,3 +145,201 @@ class TestAuthRoutes:
|
|||
"""Test getting current user without authentication"""
|
||||
response = client.get("/api/users/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_change_password_success(self, client, auth_headers):
|
||||
"""Test successful password change"""
|
||||
response = client.put(
|
||||
"/api/users/me/password",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"current_password": "password123",
|
||||
"new_password": "newpassword456",
|
||||
"confirm_password": "newpassword456",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert "Password updated" in data["message"]
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_change_password_wrong_current(self, client, auth_headers):
|
||||
"""Test password change with wrong current password"""
|
||||
response = client.put(
|
||||
"/api/users/me/password",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"current_password": "wrongpassword",
|
||||
"new_password": "newpassword456",
|
||||
"confirm_password": "newpassword456",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert "incorrect" in data["error"].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_change_password_mismatch(self, client, auth_headers):
|
||||
"""Test password change with mismatched new passwords"""
|
||||
response = client.put(
|
||||
"/api/users/me/password",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"current_password": "password123",
|
||||
"new_password": "newpassword456",
|
||||
"confirm_password": "different456",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert "do not match" in data["error"].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_get_login_history(self, client, auth_headers, regular_user):
|
||||
"""Test getting login history"""
|
||||
# Login first to create history
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": regular_user.email, "password": "password123"},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/users/me/login-history", headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login_lockout_after_five_attempts(self, client, regular_user):
|
||||
"""Test account lockout after 5 failed attempts (default timeout strategy)"""
|
||||
# First 4 attempts should return generic invalid error
|
||||
for i in range(4):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert "Invalid" in data["error"]
|
||||
|
||||
# 5th attempt should trigger lockout
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert "locked" in data["error"].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login_lockout_increments_counter(self, client, regular_user):
|
||||
"""Test that failed_login_attempts increments correctly"""
|
||||
from app import db
|
||||
|
||||
# First attempt
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
db.session.refresh(regular_user)
|
||||
assert regular_user.failed_login_attempts == 1
|
||||
|
||||
# Second attempt
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
db.session.refresh(regular_user)
|
||||
assert regular_user.failed_login_attempts == 2
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login_resets_counter_on_success(self, client, regular_user):
|
||||
"""Test that successful login resets failed_login_attempts"""
|
||||
from app import db
|
||||
|
||||
# 3 failed attempts
|
||||
for _ in range(3):
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
db.session.refresh(regular_user)
|
||||
assert regular_user.failed_login_attempts == 3
|
||||
|
||||
# Successful login resets counter
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"email": regular_user.email,
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
db.session.refresh(regular_user)
|
||||
assert regular_user.failed_login_attempts == 0
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_change_password_unauthorized(self, client):
|
||||
"""Test password change without authentication"""
|
||||
response = client.put(
|
||||
"/api/users/me/password",
|
||||
json={
|
||||
"current_password": "password123",
|
||||
"new_password": "newpassword456",
|
||||
"confirm_password": "newpassword456",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_get_login_history_unauthorized(self, client):
|
||||
"""Test login history without authentication"""
|
||||
response = client.get("/api/users/me/login-history")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_get_login_history_success(self, client, auth_headers):
|
||||
"""Test getting login history"""
|
||||
response = client.get(
|
||||
"/api/users/me/login-history", headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login_history_recorded(self, client, regular_user):
|
||||
"""Test that login history is recorded"""
|
||||
# Log in
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": regular_user.email, "password": "password123"},
|
||||
)
|
||||
|
||||
# Check history
|
||||
from app import db
|
||||
from app.models import LoginHistory
|
||||
|
||||
entries = LoginHistory.query.filter_by(user_id=regular_user.id).all()
|
||||
assert len(entries) >= 1
|
||||
|
|
|
|||
|
|
@ -4,16 +4,29 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Add real IP logging to Nginx access logs
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:5000;
|
||||
|
||||
# Forward the real IP
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Add these headers too
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
location /health {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Navbar } from './components/Navbar';
|
|||
import { Home } from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import { Register } from './pages/Register';
|
||||
import Profile from './pages/Profile';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { Boards } from './pages/Boards';
|
||||
import { BoardCreate } from './pages/BoardCreate';
|
||||
|
|
@ -59,6 +60,14 @@ const App = () => {
|
|||
<Route path="/home" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useAuth } from '../hooks/useAuth';
|
|||
import { TaskboardLogo } from './TaskboardLogo';
|
||||
import MenuIcon from './icons/MenuIcon';
|
||||
import CloseIcon from './icons/CloseIcon';
|
||||
import UserIcon from './icons/UserIcon';
|
||||
|
||||
export function Navbar() {
|
||||
const { user } = useApp();
|
||||
|
|
@ -44,32 +45,38 @@ export function Navbar() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{user ? (
|
||||
<>
|
||||
<span className="text-gray-300 px-3 py-2">{user.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{user ? (
|
||||
<>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-1.5 text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<span className="w-4 h-4"><UserIcon /></span>
|
||||
{user.username}
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:hidden flex items-center">
|
||||
<button
|
||||
|
|
|
|||
8
frontend/src/components/icons/HistoryIcon.tsx
Normal file
8
frontend/src/components/icons/HistoryIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const HistoryIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default HistoryIcon;
|
||||
8
frontend/src/components/icons/LockIcon.tsx
Normal file
8
frontend/src/components/icons/LockIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const LockIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default LockIcon;
|
||||
7
frontend/src/components/icons/ShieldIcon.tsx
Normal file
7
frontend/src/components/icons/ShieldIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const ShieldIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ShieldIcon;
|
||||
8
frontend/src/components/icons/UserIcon.tsx
Normal file
8
frontend/src/components/icons/UserIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const UserIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default UserIcon;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import axios from 'axios';
|
||||
import { RegisterData, UserData, AuthResponse } from '../types';
|
||||
import { RegisterData, UserData, AuthResponse, LoginHistoryEntry, ChangePasswordData } from '../types';
|
||||
import {
|
||||
Board,
|
||||
BoardWithDetails,
|
||||
|
|
@ -291,6 +291,16 @@ export function useApi() {
|
|||
await api.delete(`/cards/${cardId}/epics/${epicId}`);
|
||||
},
|
||||
|
||||
// Auth - Password & History
|
||||
changePassword: async (data: ChangePasswordData): Promise<{ message: string }> => {
|
||||
const response = await api.put<{ message: string }>('/users/me/password', data);
|
||||
return response.data;
|
||||
},
|
||||
getLoginHistory: async (limit: number = 20): Promise<LoginHistoryEntry[]> => {
|
||||
const response = await api.get<LoginHistoryEntry[]>(`/users/me/login-history?limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Card Links
|
||||
getCardLinks: async (cardId: number): Promise<any> => {
|
||||
const response = await api.get(`/cards/${cardId}/links`);
|
||||
|
|
|
|||
74
frontend/src/hooks/useProfile.ts
Normal file
74
frontend/src/hooks/useProfile.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState } from "react"
|
||||
import { useApi } from "./useApi"
|
||||
import { useLoader } from "../context/loaders/useLoader"
|
||||
import { useToast } from "../context/toasts/useToast"
|
||||
import { LoginHistoryEntry, ChangePasswordData } from "../types"
|
||||
|
||||
export function useProfile() {
|
||||
const [loginHistory, setLoginHistory] = useState<LoginHistoryEntry[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { changePassword: changePasswordApi, getLoginHistory: getLoginHistoryApi } = useApi()
|
||||
const { withLoader } = useLoader()
|
||||
const { addNotification } = useToast()
|
||||
|
||||
const fetchLoginHistory = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const data = await withLoader(
|
||||
() => getLoginHistoryApi(20),
|
||||
"Loading login history..."
|
||||
)
|
||||
if (data) {
|
||||
setLoginHistory(data)
|
||||
}
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to load login history"
|
||||
setError(errorMessage)
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: errorMessage,
|
||||
duration: 5000,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async (data: ChangePasswordData) => {
|
||||
try {
|
||||
setError(null)
|
||||
await withLoader(
|
||||
() => changePasswordApi(data),
|
||||
"Changing password..."
|
||||
)
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Password Changed",
|
||||
message: "Your password has been updated successfully.",
|
||||
duration: 3000,
|
||||
})
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } }; message?: string }
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.error || axiosError.message || "Failed to change password"
|
||||
setError(errorMessage)
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: errorMessage,
|
||||
duration: 5000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loginHistory,
|
||||
error,
|
||||
fetchLoginHistory,
|
||||
changePassword: handleChangePassword,
|
||||
}
|
||||
}
|
||||
330
frontend/src/pages/Profile.tsx
Normal file
330
frontend/src/pages/Profile.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { useApi } from "../hooks/useApi"
|
||||
import { useProfile } from "../hooks/useProfile"
|
||||
import { useToast } from "../context/toasts/useToast"
|
||||
import { formatRelativeTime, formatDateTime } from "../utils/dateFormat"
|
||||
import UserIcon from "../components/icons/UserIcon"
|
||||
import ShieldIcon from "../components/icons/ShieldIcon"
|
||||
import HistoryIcon from "../components/icons/HistoryIcon"
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
username: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
is_active: boolean
|
||||
is_admin: boolean
|
||||
is_locked: boolean
|
||||
failed_login_attempts: number
|
||||
last_login_at?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Change password form state
|
||||
const [currentPassword, setCurrentPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const { getCurrentUser } = useApi()
|
||||
const { fetchLoginHistory, changePassword, loginHistory } = useProfile()
|
||||
const { addNotification } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getCurrentUser()
|
||||
setProfile(data as unknown as UserProfile)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load profile"
|
||||
setError(msg)
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: msg,
|
||||
duration: 5000,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadProfile()
|
||||
}, [getCurrentUser, addNotification])
|
||||
|
||||
const handleToggleHistory = () => {
|
||||
if (!showHistory) {
|
||||
fetchLoginHistory()
|
||||
}
|
||||
setShowHistory(!showHistory)
|
||||
}
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Validation Error",
|
||||
message: "New passwords do not match.",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Validation Error",
|
||||
message: "Password must be at least 6 characters.",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const success = await changePassword({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword,
|
||||
})
|
||||
|
||||
if (success) {
|
||||
setCurrentPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
setShowPasswordForm(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-gray-400 text-center py-12">Loading profile...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-gray-400 text-center py-12">Profile not found.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-2xl space-y-8 text-gray-100">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="w-8 h-8">
|
||||
<UserIcon />
|
||||
</span>
|
||||
<h1 className="text-3xl font-bold">Profile</h1>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="bg-gray-800 rounded-lg p-6 shadow-lg">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="w-5 h-5">
|
||||
<UserIcon />
|
||||
</span>
|
||||
Account Information
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-gray-400 text-sm">Username</span>
|
||||
<p className="text-white font-medium">{profile.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400 text-sm">Email</span>
|
||||
<p className="text-white font-medium">{profile.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400 text-sm">Name</span>
|
||||
<p className="text-white font-medium">
|
||||
{[profile.first_name, profile.last_name].filter(Boolean).join(" ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400 text-sm">Member Since</span>
|
||||
<p className="text-white font-medium">
|
||||
{profile.created_at ? formatDateTime(profile.created_at) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400 text-sm">Last Login</span>
|
||||
<p className="text-white font-medium">
|
||||
{profile.last_login_at ? formatRelativeTime(profile.last_login_at) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400 text-sm">Account Status</span>
|
||||
<p className={`font-medium ${profile.is_active && !profile.is_locked ? "text-green-400" : "text-red-400"}`}>
|
||||
{profile.is_active && !profile.is_locked ? "Active" : profile.is_locked ? "Locked" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.failed_login_attempts > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<span className="text-gray-400 text-sm">Failed Login Attempts</span>
|
||||
<p className="text-yellow-400 font-medium">{profile.failed_login_attempts}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password Section */}
|
||||
<div className="bg-gray-800 rounded-lg p-6 shadow-lg">
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="w-5 h-5">
|
||||
<ShieldIcon />
|
||||
</span>
|
||||
Change Password
|
||||
</h2>
|
||||
<span className={`transform transition-transform ${showPasswordForm ? "rotate-180" : ""}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showPasswordForm && (
|
||||
<form onSubmit={handleChangePassword} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newPassword !== confirmPassword && confirmPassword && (
|
||||
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded text-sm">
|
||||
Passwords do not match
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={newPassword !== confirmPassword || newPassword.length < 6 || !currentPassword}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login History Section */}
|
||||
<div className="bg-gray-800 rounded-lg p-6 shadow-lg">
|
||||
<button
|
||||
onClick={handleToggleHistory}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="w-5 h-5">
|
||||
<HistoryIcon />
|
||||
</span>
|
||||
Login History
|
||||
</h2>
|
||||
<span className={`transform transition-transform ${showHistory ? "rotate-180" : ""}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-4">
|
||||
{loginHistory.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">No login history found.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{loginHistory.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-700 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
entry.success ? "bg-green-400" : "bg-red-400"
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm text-gray-200">
|
||||
{entry.success ? "Successful login" : "Failed login"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{entry.ip_address} — {entry.user_agent?.slice(0, 60)}
|
||||
{entry.user_agent?.length > 60 ? "..." : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap ml-4">
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
|
|
@ -41,11 +41,17 @@ export function Register() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
// This shouldn't happen due to disabled button, but handle it gracefully
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleRegister({
|
||||
email: formData.email,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
confirm_password: formData.confirmPassword,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface LoginData {
|
|||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
username: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
|
|
@ -26,5 +27,21 @@ export interface RegisterData {
|
|||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
user: UserData;
|
||||
}
|
||||
|
||||
export interface LoginHistoryEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
success: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordData {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue