Compare commits
4 commits
5b95262681
...
292465a0fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 292465a0fe | |||
| 2b8fed9d6e | |||
| 8181058adf | |||
| e0caadaa9b |
24 changed files with 2108 additions and 98 deletions
|
|
@ -12,6 +12,11 @@ class Config:
|
||||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
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 Configuration
|
||||||
CELERY = {
|
CELERY = {
|
||||||
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
|
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
|
||||||
|
|
@ -162,4 +167,4 @@ config_by_name = {
|
||||||
"dev": DevelopmentConfig,
|
"dev": DevelopmentConfig,
|
||||||
"test": TestingConfig,
|
"test": TestingConfig,
|
||||||
"prod": ProductionConfig,
|
"prod": ProductionConfig,
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ from app.models.epic import Epic
|
||||||
from app.models.file_attachment import FileAttachment
|
from app.models.file_attachment import FileAttachment
|
||||||
from app.models.label import Label
|
from app.models.label import Label
|
||||||
from app.models.list_model import List
|
from app.models.list_model import List
|
||||||
|
from app.models.login_history import LoginHistory
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.wiki import Wiki, wiki_entity_links
|
from app.models.wiki import Wiki, wiki_entity_links
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ __all__ = [
|
||||||
"Board",
|
"Board",
|
||||||
"List",
|
"List",
|
||||||
"Card",
|
"Card",
|
||||||
|
"LoginHistory",
|
||||||
"Label",
|
"Label",
|
||||||
"CardLabel",
|
"CardLabel",
|
||||||
"CardLink",
|
"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
|
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))
|
last_name = db.Column(db.String(50))
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
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))
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
|
|
@ -29,6 +34,21 @@ class User(db.Model):
|
||||||
boards = db.relationship(
|
boards = db.relationship(
|
||||||
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
|
"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):
|
def set_password(self, password):
|
||||||
"""Hash and set password"""
|
"""Hash and set password"""
|
||||||
|
|
@ -47,10 +67,12 @@ class User(db.Model):
|
||||||
"first_name": self.first_name,
|
"first_name": self.first_name,
|
||||||
"last_name": self.last_name,
|
"last_name": self.last_name,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"is_admin": self.is_admin,
|
"last_login_at": self.last_login_at
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
if self.last_login_at
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
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):
|
def __repr__(self):
|
||||||
return f"<User {self.username}>"
|
return f"<User {self.username}>"
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
|
"""Auth and user management API routes"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from flask_jwt_extended import (create_access_token, create_refresh_token,
|
from flask_jwt_extended import (
|
||||||
get_jwt_identity, jwt_required)
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
get_jwt_identity,
|
||||||
|
jwt_required,
|
||||||
|
)
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import User
|
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__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
@ -12,63 +24,78 @@ api_bp = Blueprint("api", __name__)
|
||||||
@api_bp.route("/auth/register", methods=["POST"])
|
@api_bp.route("/auth/register", methods=["POST"])
|
||||||
def register():
|
def register():
|
||||||
"""Register a new user"""
|
"""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"):
|
if not data:
|
||||||
return jsonify({"error": "Email and password are required"}), 400
|
return jsonify({"error": "Request body is required"}), 400
|
||||||
|
|
||||||
if User.query.filter_by(email=data["email"]).first():
|
# Server-side validation: require confirm_password for all registrations
|
||||||
return jsonify({"error": "Email already exists"}), 400
|
confirm = data.get("confirm_password")
|
||||||
|
if not confirm:
|
||||||
|
return jsonify({"error": "confirm_password is required"}), 400
|
||||||
|
|
||||||
user = User(
|
# Use the auth service for registration with validation
|
||||||
email=data["email"],
|
user = register_user(data)
|
||||||
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"])
|
|
||||||
|
|
||||||
db.session.add(user)
|
# Generate tokens so user is logged in immediately
|
||||||
db.session.commit()
|
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"])
|
@api_bp.route("/auth/login", methods=["POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login user"""
|
"""Login user with account lockout and login history tracking"""
|
||||||
data = request.get_json()
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
if not data or not data.get("email") or not data.get("password"):
|
if not data or not data.get("email") or not data.get("password"):
|
||||||
return jsonify({"error": "Email and password are required"}), 400
|
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"]):
|
user, access_token, refresh_token = authenticate_user(
|
||||||
return jsonify({"error": "Invalid credentials"}), 401
|
email=data["email"],
|
||||||
|
password=data["password"],
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
if not user.is_active:
|
return (
|
||||||
return jsonify({"error": "Account is inactive"}), 401
|
jsonify(
|
||||||
|
{
|
||||||
|
"user": user.to_dict(),
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
access_token = create_access_token(identity=str(user.id))
|
except ValueError as e:
|
||||||
refresh_token = create_refresh_token(identity=str(user.id))
|
return jsonify({"error": str(e)}), 401
|
||||||
|
|
||||||
return (
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"user": user.to_dict(),
|
|
||||||
"access_token": access_token,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/users/me", methods=["GET"])
|
@api_bp.route("/users/me", methods=["GET"])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
"""Get current user"""
|
"""Get current user profile"""
|
||||||
user_id = int(get_jwt_identity())
|
user_id = int(get_jwt_identity())
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
|
|
@ -76,3 +103,53 @@ def get_current_user():
|
||||||
return jsonify({"error": "User not found"}), 404
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
return jsonify(user.to_dict()), 200
|
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
|
||||||
|
|
@ -27,7 +27,7 @@ class EpicCreateRequest(BaseModel):
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
|
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
|
||||||
description: Optional[str] = Field(None, description="Epic description")
|
description: Optional[str] = Field(None, description="Epic description")
|
||||||
content: Optional[Any] = Field(None, description="Rich text content")
|
content: Optional[Any] = Field(..., min_length=1, description="Rich text content")
|
||||||
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
|
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
|
||||||
pos: Optional[float] = Field(None, description="Position for ordering")
|
pos: Optional[float] = Field(None, description="Position for ordering")
|
||||||
depth_limit: Optional[int] = Field(
|
depth_limit: Optional[int] = Field(
|
||||||
|
|
|
||||||
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 ###
|
||||||
|
|
@ -92,7 +92,9 @@ class TestEpicRoutes:
|
||||||
self, client, db_session, auth_headers, test_board
|
self, client, db_session, auth_headers, test_board
|
||||||
):
|
):
|
||||||
"""Test creating epic with only required fields"""
|
"""Test creating epic with only required fields"""
|
||||||
epic_data = {"name": "Minimal Epic"}
|
|
||||||
|
new_content = [{"type": "heading", "children": [{"text": "Updated Content"}]}]
|
||||||
|
epic_data = {"name": "Minimal Epic", "content": new_content}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/boards/{test_board.id}/epics",
|
f"/api/boards/{test_board.id}/epics",
|
||||||
|
|
@ -113,13 +115,18 @@ class TestEpicRoutes:
|
||||||
):
|
):
|
||||||
"""Test creating epic with parent epic"""
|
"""Test creating epic with parent epic"""
|
||||||
# Create parent epic
|
# Create parent epic
|
||||||
parent_epic = Epic(name="Parent Epic", board_id=test_board.id)
|
parent_epic = Epic(
|
||||||
|
name="Parent Epic",
|
||||||
|
board_id=test_board.id,
|
||||||
|
content=[{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
|
)
|
||||||
db_session.add(parent_epic)
|
db_session.add(parent_epic)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
epic_data = {
|
epic_data = {
|
||||||
"name": "Child Epic",
|
"name": "Child Epic",
|
||||||
"parent_epic_id": parent_epic.id,
|
"parent_epic_id": parent_epic.id,
|
||||||
|
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
|
|
@ -139,8 +146,8 @@ class TestEpicRoutes:
|
||||||
epic_data = {
|
epic_data = {
|
||||||
"name": "Epic with Completed List",
|
"name": "Epic with Completed List",
|
||||||
"completed_list_id": test_list.id,
|
"completed_list_id": test_list.id,
|
||||||
|
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/boards/{test_board.id}/epics",
|
f"/api/boards/{test_board.id}/epics",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
|
|
@ -153,7 +160,10 @@ class TestEpicRoutes:
|
||||||
|
|
||||||
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
|
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
|
||||||
"""Test creating epic for non-existent board"""
|
"""Test creating epic for non-existent board"""
|
||||||
epic_data = {"name": "Test Epic"}
|
epic_data = {
|
||||||
|
"name": "Test Epic",
|
||||||
|
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
|
||||||
|
}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/boards/99999/epics",
|
"/api/boards/99999/epics",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class TestAuthRoutes:
|
||||||
json={
|
json={
|
||||||
"email": "newuser@example.com",
|
"email": "newuser@example.com",
|
||||||
"password": "password123",
|
"password": "password123",
|
||||||
|
"confirm_password": "password123",
|
||||||
"username": "newuser",
|
"username": "newuser",
|
||||||
"first_name": "New",
|
"first_name": "New",
|
||||||
"last_name": "User",
|
"last_name": "User",
|
||||||
|
|
@ -21,10 +22,38 @@ class TestAuthRoutes:
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data["email"] == "newuser@example.com"
|
assert data["user"]["email"] == "newuser@example.com"
|
||||||
assert data["username"] == "newuser"
|
assert data["user"]["username"] == "newuser"
|
||||||
assert "password" not in data
|
assert "password" not in data["user"]
|
||||||
assert "password_hash" not in data
|
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
|
@pytest.mark.auth
|
||||||
def test_register_missing_fields(self, client):
|
def test_register_missing_fields(self, client):
|
||||||
|
|
@ -42,7 +71,11 @@ class TestAuthRoutes:
|
||||||
"""Test registration with duplicate email"""
|
"""Test registration with duplicate email"""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/auth/register",
|
"/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
|
assert response.status_code == 400
|
||||||
|
|
@ -96,7 +129,7 @@ class TestAuthRoutes:
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert "inactive" in data["error"].lower()
|
assert "locked" in data["error"].lower()
|
||||||
|
|
||||||
@pytest.mark.auth
|
@pytest.mark.auth
|
||||||
def test_get_current_user(self, client, auth_headers, regular_user):
|
def test_get_current_user(self, client, auth_headers, regular_user):
|
||||||
|
|
@ -112,3 +145,201 @@ class TestAuthRoutes:
|
||||||
"""Test getting current user without authentication"""
|
"""Test getting current user without authentication"""
|
||||||
response = client.get("/api/users/me")
|
response = client.get("/api/users/me")
|
||||||
assert response.status_code == 401
|
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
|
||||||
|
|
|
||||||
850
docs/figma-design-prompt.md
Normal file
850
docs/figma-design-prompt.md
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
# Taskboard — Figma AI Designer Prompt
|
||||||
|
|
||||||
|
> Use this document as a design brief for generating UI flows, wireframes, and high-fidelity mockups in Figma with AI assistance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Application Overview
|
||||||
|
|
||||||
|
**Taskboard** is a Trello-like Kanban project management web application. Users create Boards containing Lists (columns) and Cards (tasks). Cards support labels, checklists, comments, file attachments, due dates, card-to-card linking, and epic grouping. Boards also have **Epics** (large features spanning multiple cards) and **Wikis** (rich-text documentation pages).
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Frontend**: React 18 + TypeScript, Tailwind CSS (dark theme), React Router, @dnd-kit (drag-and-drop), Slate.js (rich text editor)
|
||||||
|
- **Backend**: Flask + SQLAlchemy + PostgreSQL
|
||||||
|
- **Auth**: JWT-based authentication
|
||||||
|
|
||||||
|
### Design System Basics
|
||||||
|
- **Theme**: Dark mode by default
|
||||||
|
- **Background**: `gray-900` (#111827) for page backgrounds, `gray-800` (#1f2937) for card/panel surfaces, `gray-700` (#374151) for inputs and hover states
|
||||||
|
- **Accent color**: `blue-600` (#2563eb) primary, `blue-500` hover, `blue-400` for text links
|
||||||
|
- **Text**: `white` for headings, `gray-300` for body text, `gray-400` for secondary/muted text, `gray-500` for placeholders
|
||||||
|
- **Borders**: `gray-700` for subtle dividers
|
||||||
|
- **Destructive**: `red-500`/`red-600` for delete/danger actions
|
||||||
|
- **Success**: `green-500`/`green-600`
|
||||||
|
- **Border radius**: `rounded-lg` (8px) for cards and inputs, `rounded-md` (6px) for buttons, `rounded-full` for badges/chips
|
||||||
|
- **Spacing**: Tailwind's 4px base unit (p-4 = 16px, gap-6 = 24px)
|
||||||
|
- **Typography**: System font stack; headings bold (font-bold), body medium (font-medium)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Navigation & Information Architecture
|
||||||
|
|
||||||
|
### Global Navigation Bar (Navbar)
|
||||||
|
- **Logo**: "Taskboard" wordmark + icon (left side), links to `/boards`
|
||||||
|
- **Nav links** (desktop): "Home" → `/home`, "Boards" → `/boards` (only when logged in)
|
||||||
|
- **Auth section** (right side):
|
||||||
|
- Logged out: "Login" link + "Register" button (blue-600 filled)
|
||||||
|
- Logged in: Username display + "Logout" button
|
||||||
|
- **Mobile**: Hamburger menu icon that expands to show same links vertically
|
||||||
|
- **Height**: 64px (h-16), bg-gray-800 with bottom border-gray-700
|
||||||
|
|
||||||
|
### Board-Level Sidebar (BoardSidebar)
|
||||||
|
- Fixed position on the **right edge** of the viewport, vertically centered
|
||||||
|
- Contains links for the current board context:
|
||||||
|
- 📋 **Epics** → `/boards/:id/epics`
|
||||||
|
- 📚 **Wikis** → `/boards/:id/wikis`
|
||||||
|
- 📜 **History** → `/boards/:id/history`
|
||||||
|
- Active item has blue-600 background; inactive has gray-800 with hover-gray-700
|
||||||
|
- Pill-shaped tabs with rounded-l-lg (left rounded only, flush to right edge)
|
||||||
|
|
||||||
|
### Full Route Map
|
||||||
|
|
||||||
|
| Route | Page | Auth Required |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `/home` | Landing/Marketing page | No |
|
||||||
|
| `/login` | Login page | No |
|
||||||
|
| `/register` | Registration page | No |
|
||||||
|
| `/boards` | Board listing | Yes |
|
||||||
|
| `/boards/new` | Create new board | Yes |
|
||||||
|
| `/boards/:id` | Board detail (Kanban board view) | Yes |
|
||||||
|
| `/boards/:id/edit` | Edit board settings | Yes |
|
||||||
|
| `/boards/:id/epics` | Epics listing for board | Yes |
|
||||||
|
| `/boards/:id/epics/new` | Create new epic | Yes |
|
||||||
|
| `/boards/:id/epics/:epicId` | Epic detail page | Yes |
|
||||||
|
| `/boards/:id/epics/:epicId/edit` | Edit epic | Yes |
|
||||||
|
| `/boards/:id/wikis` | Wikis listing for board | Yes |
|
||||||
|
| `/boards/:id/wikis/new` | Create new wiki | Yes |
|
||||||
|
| `/boards/:id/wikis/:wikiId` | Wiki detail page | Yes |
|
||||||
|
| `/boards/:id/wikis/:wikiId/edit` | Edit wiki | Yes |
|
||||||
|
| `/boards/:id/cards/:cardId` | Card detail page | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Models & Their Fields
|
||||||
|
|
||||||
|
### User
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `email` | string (120) | Shown in auth forms |
|
||||||
|
| `username` | string (80) | Displayed in navbar, comments |
|
||||||
|
| `password_hash` | string | Never displayed |
|
||||||
|
| `first_name` | string (50) | Optional display |
|
||||||
|
| `last_name` | string (50) | Optional display |
|
||||||
|
| `is_active` | boolean | Account status |
|
||||||
|
| `is_admin` | boolean | Admin badge |
|
||||||
|
| `created_at` | datetime | Account creation date |
|
||||||
|
| `updated_at` | datetime | Last update |
|
||||||
|
|
||||||
|
### Board
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Board title — prominent heading |
|
||||||
|
| `description` | text | Subtitle/brief shown below title |
|
||||||
|
| `closed` | boolean | Archived indicator (badge or dimmed card) |
|
||||||
|
| `url` | string (500) | External URL reference |
|
||||||
|
| `short_link` | string (10) | Shareable short link |
|
||||||
|
| `short_url` | string (500) | Full short URL |
|
||||||
|
| `user_id` | FK → User | Board owner |
|
||||||
|
| `prefs` | JSONB | Board preferences (background, etc.) |
|
||||||
|
| `label_names` | JSONB | Label color→name mapping |
|
||||||
|
| `limits` | JSONB | Card/list limits |
|
||||||
|
| `date_last_activity` | datetime | "Last active" display |
|
||||||
|
| `date_last_view` | datetime | "Last viewed" display |
|
||||||
|
| `created_at` | datetime | Creation date |
|
||||||
|
| `updated_at` | datetime | Last update |
|
||||||
|
|
||||||
|
### List (Column)
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Column header title |
|
||||||
|
| `closed` | boolean | Archived indicator |
|
||||||
|
| `pos` | float | Horizontal sort order |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `created_at` | datetime | — |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### Card (Task)
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Card title — main heading on detail page |
|
||||||
|
| `description` | text | Multi-line description; editable inline |
|
||||||
|
| `closed` | boolean | Archived indicator |
|
||||||
|
| `due` | datetime | Due date — shown as badge on card, date picker in editor |
|
||||||
|
| `due_complete` | boolean | Checkbox to mark due as done |
|
||||||
|
| `pos` | float | Sort position within list |
|
||||||
|
| `id_short` | integer | Short ID for display |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `list_id` | FK → List | Current list (shown as "In list [name]") |
|
||||||
|
| `epic_id` | FK → Epic | Assigned epic (nullable) |
|
||||||
|
| `badges` | JSONB | Stats (checklist count, comment count, attachment count) |
|
||||||
|
| `cover` | JSONB | Cover image settings |
|
||||||
|
| `desc_data` | JSONB | Rich description data |
|
||||||
|
| `date_last_activity` | datetime | — |
|
||||||
|
| `created_at` | datetime | "Created [date]" display |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### Label
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (100) | Label text (shown on hover) |
|
||||||
|
| `color` | string (50) | Color name (green, red, blue, etc.) — rendered as colored chip/badge |
|
||||||
|
| `uses` | integer | Usage count |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Checklist heading |
|
||||||
|
| `pos` | float | Sort order among checklists |
|
||||||
|
| `card_id` | FK → Card | Parent card |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
|
||||||
|
### CheckItem
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (500) | Item text with checkbox |
|
||||||
|
| `pos` | float | Sort order |
|
||||||
|
| `state` | "complete" / "incomplete" | Checkbox state |
|
||||||
|
| `due` | datetime | Optional due date |
|
||||||
|
| `checklist_id` | FK → Checklist | Parent checklist |
|
||||||
|
| `user_id` | FK → User | Assigned user |
|
||||||
|
|
||||||
|
### Comment
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `text` | text | Comment body text |
|
||||||
|
| `card_id` | FK → Card | Parent card |
|
||||||
|
| `user_id` | FK → User | Author (display username + timestamp) |
|
||||||
|
| `created_at` | datetime | "Posted [relative time]" |
|
||||||
|
| `updated_at` | datetime | "Edited" indicator |
|
||||||
|
|
||||||
|
### FileAttachment
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `uuid` | string (36) | Public identifier |
|
||||||
|
| `filename` | string (255) | Storage filename |
|
||||||
|
| `original_name` | string (255) | Display name |
|
||||||
|
| `file_type` | string (50) | "image", "pdf", "document" — determines icon/preview |
|
||||||
|
| `mime_type` | string (100) | MIME type |
|
||||||
|
| `file_size` | integer | Display as "KB", "MB" |
|
||||||
|
| `attachable_type` | string (50) | "Card", "Comment", "Epic" — polymorphic parent |
|
||||||
|
| `attachable_id` | integer | ID of parent entity |
|
||||||
|
| `uploaded_by` | FK → User | Uploader |
|
||||||
|
| `thumbnail_url` | string | Thumbnail for images |
|
||||||
|
| `created_at` | datetime | Upload date |
|
||||||
|
|
||||||
|
### Epic
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Epic title |
|
||||||
|
| `description` | text | Brief description |
|
||||||
|
| `content` | JSONB | Rich text content (Slate.js JSON) |
|
||||||
|
| `color` | string (7) | Hex color for epic badge (e.g., "#FF5733") |
|
||||||
|
| `closed` | boolean | Completed/archived indicator |
|
||||||
|
| `pos` | float | Sort order |
|
||||||
|
| `depth_limit` | integer | Max nesting depth (default 5) |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `parent_epic_id` | FK → Epic (nullable) | Parent epic for hierarchy |
|
||||||
|
| `completed_list_id` | FK → List (nullable) | Which list counts as "done" |
|
||||||
|
| `metrics` | JSONB | `{ card_count: 10, completed_cards_count: 7 }` — progress bar |
|
||||||
|
| `date_last_activity` | datetime | — |
|
||||||
|
| `created_at` | datetime | — |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### Wiki
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `name` | string (200) | Wiki page title |
|
||||||
|
| `slug` | string (255) | URL-friendly identifier |
|
||||||
|
| `content` | JSONB | Rich text content (Slate.js JSON) |
|
||||||
|
| `summary` | text | Brief description/abstract |
|
||||||
|
| `category` | string (100) | Category grouping label |
|
||||||
|
| `board_id` | FK → Board | Parent board |
|
||||||
|
| `created_by` | FK → User | Author |
|
||||||
|
| `updated_by` | FK → User | Last editor |
|
||||||
|
| `tags` | JSONB | Array of tag strings: `["security", "api"]` |
|
||||||
|
| `created_at` | datetime | — |
|
||||||
|
| `updated_at` | datetime | — |
|
||||||
|
|
||||||
|
### CardLink (Card-to-Card Relationship)
|
||||||
|
| Field | Type | Display Notes |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `id` | integer | Internal |
|
||||||
|
| `parent_card_id` | FK → Card | Parent card |
|
||||||
|
| `child_card_id` | FK → Card | Child card |
|
||||||
|
| `created_by` | FK → User | Who linked them |
|
||||||
|
| `created_at` | datetime | When linked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Page-by-Page UI Specifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 1: Landing / Home Page (`/home`)
|
||||||
|
|
||||||
|
**Purpose**: Marketing landing page introducing Taskboard. Visible to all visitors.
|
||||||
|
|
||||||
|
**Layout**: Full-width centered layout
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- **Hero Section**
|
||||||
|
- Large heading: "Taskboard" with tagline (e.g., "Organize your work, your way")
|
||||||
|
- Two CTA buttons: "Get Started" → `/register` (blue-600 filled), "Login" → `/login` (outline/ghost)
|
||||||
|
- Optional illustration or screenshot mockup
|
||||||
|
- **Features Section** (optional)
|
||||||
|
- 3-4 feature cards in a grid: Kanban boards, Epics, Wikis, Card linking
|
||||||
|
- **Footer** with basic links
|
||||||
|
|
||||||
|
**Interactions**: Click CTAs to navigate to auth pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 2: Login Page (`/login`)
|
||||||
|
|
||||||
|
**Purpose**: User authentication
|
||||||
|
|
||||||
|
**Layout**: Narrow centered card on dark background
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- "Taskboard" logo/wordmark at top
|
||||||
|
- Heading: "Sign in to your account"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Email** — text input with label, placeholder "you@example.com"
|
||||||
|
- **Password** — password input with label, placeholder "••••••••"
|
||||||
|
- **Submit button**: "Sign In" (full width, blue-600)
|
||||||
|
- **Link**: "Don't have an account? Register" → `/register`
|
||||||
|
- Error message area (red text, shown on failed login)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 3: Register Page (`/register`)
|
||||||
|
|
||||||
|
**Purpose**: New user registration
|
||||||
|
|
||||||
|
**Layout**: Narrow centered card on dark background
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- "Taskboard" logo/wordmark at top
|
||||||
|
- Heading: "Create your account"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Username** — text input, placeholder "Choose a username"
|
||||||
|
- **Email** — text input, placeholder "you@example.com"
|
||||||
|
- **Password** — password input, placeholder "Create a password"
|
||||||
|
- **Confirm Password** — password input, placeholder "Confirm your password"
|
||||||
|
- **Submit button**: "Create Account" (full width, blue-600)
|
||||||
|
- **Link**: "Already have an account? Sign in" → `/login`
|
||||||
|
- Validation error messages per field
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 4: Boards List (`/boards`)
|
||||||
|
|
||||||
|
**Purpose**: View all boards the user owns/participates in
|
||||||
|
|
||||||
|
**Layout**: Wide page layout with padding
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- **Page header**:
|
||||||
|
- Heading: "My Boards"
|
||||||
|
- Button: "+ New Board" (blue-600) → navigates to `/boards/new`
|
||||||
|
- **Board grid** (responsive: 1 col mobile, 2 col tablet, 3-4 col desktop):
|
||||||
|
- Each **Board card** shows:
|
||||||
|
- Board name (bold, white text)
|
||||||
|
- Description snippet (gray-400, truncated)
|
||||||
|
- Metadata: "Last active: [relative date]" or "Created [date]"
|
||||||
|
- Card count indicator (optional badge)
|
||||||
|
- Closed/archived boards shown dimmed or with "Archived" badge
|
||||||
|
- Click on board card → navigates to `/boards/:id`
|
||||||
|
- **Empty state**: "No boards yet. Create your first board to get started!" with CTA button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 5: Create Board (`/boards/new`)
|
||||||
|
|
||||||
|
**Purpose**: Create a new Kanban board
|
||||||
|
|
||||||
|
**Layout**: Narrow centered form
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Boards" → `/boards`
|
||||||
|
- Heading: "Create New Board"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Board Name** — text input (required), placeholder "Enter board name"
|
||||||
|
- **Description** — textarea, placeholder "Describe your board (optional)"
|
||||||
|
- **Buttons**: "Create Board" (blue-600), "Cancel" (gray-600, navigates back)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 6: Edit Board (`/boards/:id/edit`)
|
||||||
|
|
||||||
|
**Purpose**: Edit board settings
|
||||||
|
|
||||||
|
**Layout**: Narrow centered form
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Heading: "Edit Board"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Board Name** — text input (pre-filled)
|
||||||
|
- **Description** — textarea (pre-filled)
|
||||||
|
- **Closed** — toggle/checkbox "Archive this board"
|
||||||
|
- **Buttons**: "Save Changes" (blue-600), "Cancel" (gray-600)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 7: Board Detail — Kanban View (`/boards/:id`) ★ PRIMARY PAGE
|
||||||
|
|
||||||
|
**Purpose**: The main Kanban board interface. This is the core of the application.
|
||||||
|
|
||||||
|
**Layout**: Full-width, horizontally scrollable. BoardSidebar on right edge.
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- **Top bar**:
|
||||||
|
- Breadcrumb: "← Back to Boards" → `/boards`
|
||||||
|
- Board name (h1, large, bold)
|
||||||
|
- Board description (gray-400, below name)
|
||||||
|
- Action buttons (right aligned):
|
||||||
|
- "Edit Board" (gray-700 button) → `/boards/:id/edit`
|
||||||
|
- "+ Add List" (blue-600 button)
|
||||||
|
- **Kanban board area** (horizontal flex container, scrollable):
|
||||||
|
- **List/Column** (each ~300px wide, bg-gray-800, rounded-lg):
|
||||||
|
- **Column header**: List name (bold), with dropdown menu (⋮) for: Rename list, Delete list
|
||||||
|
- **Card list** (vertical, scrollable):
|
||||||
|
- Each **KanbanCard** shows:
|
||||||
|
- Card name (white, medium weight)
|
||||||
|
- Label chips (colored dots or small pills below name)
|
||||||
|
- Badges row: checklist icon + count, comment icon + count, attachment icon + count
|
||||||
|
- Due date badge (if set): date text, red if overdue, green if complete
|
||||||
|
- Epic badge (if assigned): colored pill with epic name
|
||||||
|
- Parent card indicator: "Linked from: [card name]" (gray-400 text)
|
||||||
|
- **"Add card" button** at bottom of list: "+ Add a card" (text button, gray-400)
|
||||||
|
- **"Add another list" card**: Dashed border placeholder at end of columns
|
||||||
|
- **Drag & Drop**: Cards can be dragged between lists and reordered within lists. Lists can be reordered horizontally. Show ghost/overlay of dragged item.
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
- Click card → opens Card Preview Modal OR navigates to Card Detail
|
||||||
|
- Click "+ Add a card" → opens Create Card Modal
|
||||||
|
- Click "+ Add List" → opens Create List Modal
|
||||||
|
- Drag card/column to reorder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 8: Card Detail Page (`/boards/:id/cards/:cardId`) ★ KEY PAGE
|
||||||
|
|
||||||
|
**Purpose**: Full detailed view and editing of a single card
|
||||||
|
|
||||||
|
**Layout**: Narrow centered layout (max-width ~900px). Two-column grid: main content (2/3) + sidebar (1/3).
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
|
||||||
|
**Top Section**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Card name (h1, 3xl bold, inline-editable — click edit icon to rename)
|
||||||
|
- Subtitle: "In list [list name] • Created [date]"
|
||||||
|
- **Action dropdown** (⋮ icon, top-right): Edit Name, Delete Card, Create Linked Card, Link Existing Card
|
||||||
|
|
||||||
|
**Main Content (left 2/3)**:
|
||||||
|
|
||||||
|
1. **Description Section** (bg-gray-800 card)
|
||||||
|
- Heading "Description" + "Edit" button
|
||||||
|
- Display mode: rendered text or "No description added yet." placeholder
|
||||||
|
- Edit mode: textarea with Save/Cancel buttons
|
||||||
|
|
||||||
|
2. **Labels Section**
|
||||||
|
- Colored label chips attached to this card
|
||||||
|
- Button to add/remove labels (opens label picker dropdown)
|
||||||
|
- Label picker: list of board labels as colored rows, click to toggle
|
||||||
|
|
||||||
|
3. **Epic Section**
|
||||||
|
- If assigned: colored epic badge with name, click to navigate to epic
|
||||||
|
- Button to assign/change epic (opens epic picker dropdown)
|
||||||
|
|
||||||
|
4. **Linked Cards Section**
|
||||||
|
- List of linked parent/child cards with name and link icon
|
||||||
|
- Each link has "Unlink" button (×)
|
||||||
|
- Empty state: "No linked cards"
|
||||||
|
|
||||||
|
5. **Checklists Section**
|
||||||
|
- Multiple checklists, each with:
|
||||||
|
- Checklist name heading + delete button (trash icon)
|
||||||
|
- Progress bar (percentage of completed items)
|
||||||
|
- List of CheckItems, each with:
|
||||||
|
- Checkbox (complete/incomplete)
|
||||||
|
- Item name (strikethrough when complete)
|
||||||
|
- Due date (if set)
|
||||||
|
- Action menu: Edit, Convert to Card, Delete
|
||||||
|
- "Add item" input at bottom
|
||||||
|
- "Add Checklist" button
|
||||||
|
|
||||||
|
6. **Attachments Section**
|
||||||
|
- File upload area (drag & drop or click to browse)
|
||||||
|
- List of uploaded files:
|
||||||
|
- Image preview (thumbnail) for images
|
||||||
|
- File icon + name for documents/PDFs
|
||||||
|
- File name, size, upload date
|
||||||
|
- Actions: View, Download, Delete
|
||||||
|
|
||||||
|
7. **Comments Section**
|
||||||
|
- Comment input: textarea + "Save" button
|
||||||
|
- List of comments (newest first):
|
||||||
|
- Author username + avatar placeholder
|
||||||
|
- Comment text
|
||||||
|
- Timestamp (relative: "2h ago")
|
||||||
|
- Actions: Edit (pencil icon), Delete (trash icon)
|
||||||
|
- Edit mode: inline textarea with Save/Cancel
|
||||||
|
|
||||||
|
**Sidebar (right 1/3)** — CardSidebar component:
|
||||||
|
- **Due Date**: Date display, "Mark complete" checkbox, date picker to change
|
||||||
|
- **List**: Current list name, dropdown to move card to another list
|
||||||
|
- **Epic**: Current epic or "None", link to epic detail
|
||||||
|
- **Created Date**: Full datetime
|
||||||
|
- **Last Activity**: Relative datetime
|
||||||
|
- **Card ID**: Short ID display
|
||||||
|
|
||||||
|
**Modals accessible from this page**:
|
||||||
|
- Delete Card Modal
|
||||||
|
- Create Linked Card Modal (name + description fields)
|
||||||
|
- Link Existing Card Modal (search/select from board cards)
|
||||||
|
- Unlink Card Modal (confirmation)
|
||||||
|
- Create Checklist Modal
|
||||||
|
- Delete Checklist Modal
|
||||||
|
- Edit Check Item Modal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 9: Board Epics (`/boards/:id/epics`)
|
||||||
|
|
||||||
|
**Purpose**: View and manage all epics for a board
|
||||||
|
|
||||||
|
**Layout**: Wide page layout with BoardSidebar
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Heading: "Epics"
|
||||||
|
- Button: "+ New Epic" (blue-600) → `/boards/:id/epics/new`
|
||||||
|
- **Epics list/table**:
|
||||||
|
- Each epic row/card shows:
|
||||||
|
- **Color dot** (epic color)
|
||||||
|
- **Epic name** (bold, links to detail page)
|
||||||
|
- **Description** snippet (truncated)
|
||||||
|
- **Progress bar**: `completed_cards_count / card_count` with percentage
|
||||||
|
- **Status**: Open/Closed badge
|
||||||
|
- **Last activity**: Relative date
|
||||||
|
- **Actions**: Edit (pencil), Delete (trash)
|
||||||
|
- Nested epics shown indented under parent
|
||||||
|
- **Empty state**: "No epics yet. Create an epic to group related cards."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 10: Create Epic (`/boards/:id/epics/new`)
|
||||||
|
|
||||||
|
**Purpose**: Create a new epic
|
||||||
|
|
||||||
|
**Layout**: Narrow centered form
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Epics" → `/boards/:id/epics`
|
||||||
|
- Heading: "Create New Epic"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Name** — text input (required), placeholder "Epic name"
|
||||||
|
- **Description** — textarea, placeholder "Brief description"
|
||||||
|
- **Color** — color picker or preset color swatches (hex input)
|
||||||
|
- **Rich Text Content** — Slate.js rich text editor with toolbar (bold, italic, lists, headings, links, images)
|
||||||
|
- **Parent Epic** — dropdown select (nullable, "None" option)
|
||||||
|
- **Completed List** — dropdown select (which list means "done")
|
||||||
|
- **Buttons**: "Create Epic" (blue-600), "Cancel" (gray-600)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 11: Edit Epic (`/boards/:id/epics/:epicId/edit`)
|
||||||
|
|
||||||
|
**Purpose**: Edit an existing epic
|
||||||
|
|
||||||
|
**Layout**: Same as Create Epic, pre-filled with existing data
|
||||||
|
|
||||||
|
**Additional fields**:
|
||||||
|
- **Closed** — toggle "Mark epic as completed/closed"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 12: Epic Detail (`/boards/:id/epics/:epicId`)
|
||||||
|
|
||||||
|
**Purpose**: View full epic details with progress and linked cards
|
||||||
|
|
||||||
|
**Layout**: Narrow centered layout
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Epics"
|
||||||
|
- **Header**:
|
||||||
|
- Epic name (h1) with color dot
|
||||||
|
- Description (gray-300)
|
||||||
|
- Status badge: Open/Closed
|
||||||
|
- "Edit" button → edit page
|
||||||
|
- **Progress Section**:
|
||||||
|
- Progress bar showing `completed_cards_count / card_count`
|
||||||
|
- Percentage text
|
||||||
|
- **Rich Text Content**: Rendered Slate.js content (formatted text, images, links)
|
||||||
|
- **Linked Cards**:
|
||||||
|
- List of cards assigned to this epic
|
||||||
|
- Each card shows: name, current list, labels, due date
|
||||||
|
- Click card → navigates to card detail
|
||||||
|
- **Child Epics** (if any):
|
||||||
|
- Nested list of sub-epics with same info
|
||||||
|
- **Attachments** (if any):
|
||||||
|
- File list with preview/download
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 13: Board Wikis (`/boards/:id/wikis`)
|
||||||
|
|
||||||
|
**Purpose**: View and manage all wiki pages for a board
|
||||||
|
|
||||||
|
**Layout**: Wide page layout with BoardSidebar
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Board" → `/boards/:id`
|
||||||
|
- Heading: "Wikis"
|
||||||
|
- Button: "+ New Wiki" (blue-600) → `/boards/:id/wikis/new`
|
||||||
|
- **Wiki list/grid**:
|
||||||
|
- Each wiki card shows:
|
||||||
|
- **Wiki name** (bold, links to detail)
|
||||||
|
- **Summary** (truncated, gray-400)
|
||||||
|
- **Category** badge (if set)
|
||||||
|
- **Tags** as small pills/chips
|
||||||
|
- **Author**: Created by [username]
|
||||||
|
- **Last updated**: Relative date
|
||||||
|
- **Actions**: Edit (pencil), Delete (trash)
|
||||||
|
- **Empty state**: "No wiki pages yet. Create a wiki to document your project."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 14: Create Wiki (`/boards/:id/wikis/new`)
|
||||||
|
|
||||||
|
**Purpose**: Create a new wiki page
|
||||||
|
|
||||||
|
**Layout**: Wide page layout (to accommodate rich text editor)
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Wikis" → `/boards/:id/wikis`
|
||||||
|
- Heading: "Create New Wiki"
|
||||||
|
- **Form fields**:
|
||||||
|
- **Name** — text input (required), placeholder "Wiki page title"
|
||||||
|
- **Slug** — text input, auto-generated from name, editable, placeholder "url-friendly-slug"
|
||||||
|
- **Summary** — textarea, placeholder "Brief description"
|
||||||
|
- **Category** — text input, placeholder "Category (optional)"
|
||||||
|
- **Tags** — tag input (add/remove tags, shown as chips)
|
||||||
|
- **Content** — Slate.js rich text editor (full width):
|
||||||
|
- Toolbar: Bold, Italic, Underline, Strikethrough, Headings (H1-H3), Bulleted List, Numbered List, Link, Image, Code Block, Quote
|
||||||
|
- Large editable area
|
||||||
|
- **Buttons**: "Create Wiki" (blue-600), "Cancel" (gray-600)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 15: Edit Wiki (`/boards/:id/wikis/:wikiId/edit`)
|
||||||
|
|
||||||
|
**Purpose**: Edit an existing wiki page
|
||||||
|
|
||||||
|
**Layout**: Same as Create Wiki, pre-filled with existing data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PAGE 16: Wiki Detail (`/boards/:id/wikis/:wikiId`)
|
||||||
|
|
||||||
|
**Purpose**: View a wiki page's content
|
||||||
|
|
||||||
|
**Layout**: Narrow centered layout
|
||||||
|
|
||||||
|
**Elements**:
|
||||||
|
- Breadcrumb: "← Back to Wikis"
|
||||||
|
- **Header**:
|
||||||
|
- Wiki name (h1)
|
||||||
|
- Category badge (if set)
|
||||||
|
- Tags as colored chips
|
||||||
|
- Summary text (gray-300, italic)
|
||||||
|
- Metadata: "Created by [author] on [date] • Last updated [date] by [editor]"
|
||||||
|
- "Edit" button → edit page
|
||||||
|
- **Content**: Rendered Slate.js rich text (headings, paragraphs, lists, images, links, code blocks, quotes)
|
||||||
|
- **Linked Entities** (if any):
|
||||||
|
- "Linked Cards" section: list of linked cards with name and link
|
||||||
|
- "Linked Epics" section: list of linked epics with name and link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Modal Catalog
|
||||||
|
|
||||||
|
### Create List Modal
|
||||||
|
- **Triggered**: "+ Add List" button on board detail
|
||||||
|
- **Fields**: List Name (text input)
|
||||||
|
- **Actions**: "Create List" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Edit List Modal
|
||||||
|
- **Triggered**: Rename option from list dropdown menu
|
||||||
|
- **Fields**: List Name (text input, pre-filled)
|
||||||
|
- **Actions**: "Save" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Delete List Modal
|
||||||
|
- **Triggered**: Delete option from list dropdown menu
|
||||||
|
- **Content**: "Are you sure you want to delete '[list name]'? All cards in this list will be permanently deleted."
|
||||||
|
- **Actions**: "Delete" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Create Card Modal
|
||||||
|
- **Triggered**: "+ Add a card" button at bottom of a list
|
||||||
|
- **Fields**: Card Name (text input, required), Description (textarea, optional)
|
||||||
|
- **Actions**: "Create Card" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Edit Card Modal
|
||||||
|
- **Triggered**: Edit action on card
|
||||||
|
- **Fields**: Card Name (text input), Description (textarea)
|
||||||
|
- **Actions**: "Save" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Delete Card Modal
|
||||||
|
- **Triggered**: Delete action from card dropdown
|
||||||
|
- **Content**: "Are you sure you want to delete '[card name]'? This action cannot be undone."
|
||||||
|
- **Actions**: "Delete" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Card Preview Modal
|
||||||
|
- **Triggered**: Clicking a card on the Kanban board
|
||||||
|
- **Content**: Compact card view with name, description, labels, badges
|
||||||
|
- **Actions**: "View Full Details" (navigates to card detail), "Close"
|
||||||
|
|
||||||
|
### Create Linked Card Modal
|
||||||
|
- **Triggered**: "Create Linked Card" from card action dropdown
|
||||||
|
- **Purpose**: Create a new card and automatically link it as a child
|
||||||
|
- **Fields**: Card Name (text input), Description (textarea)
|
||||||
|
- **Actions**: "Create & Link" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Link Existing Card Modal
|
||||||
|
- **Triggered**: "Link Existing Card" from card action dropdown
|
||||||
|
- **Purpose**: Search and select an existing card on the board to link
|
||||||
|
- **Fields**: Search input, list of matching cards with radio buttons
|
||||||
|
- **Actions**: "Link Card" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Unlink Card Modal
|
||||||
|
- **Triggered**: "Unlink" button on a linked card
|
||||||
|
- **Content**: "Are you sure you want to unlink '[card name]' from '[parent card name]'?"
|
||||||
|
- **Actions**: "Unlink" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Create Checklist Modal
|
||||||
|
- **Triggered**: "Add Checklist" button in card checklists section
|
||||||
|
- **Fields**: Checklist Name (text input)
|
||||||
|
- **Actions**: "Add" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Delete Checklist Modal
|
||||||
|
- **Triggered**: Delete button on a checklist
|
||||||
|
- **Content**: "Are you sure you want to delete the checklist '[name]'? All items will be removed."
|
||||||
|
- **Actions**: "Delete" (red-600), "Cancel"
|
||||||
|
|
||||||
|
### Edit Check Item Modal
|
||||||
|
- **Triggered**: Edit action on a checklist item
|
||||||
|
- **Fields**: Item Name (text input)
|
||||||
|
- **Actions**: "Save" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
### Create Label Modal
|
||||||
|
- **Triggered**: "Create new label" option in label picker
|
||||||
|
- **Fields**: Label Name (text input), Color (color swatches picker)
|
||||||
|
- **Actions**: "Create" (blue-600), "Cancel"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key User Flows
|
||||||
|
|
||||||
|
### Flow 1: Registration & First Board
|
||||||
|
1. User visits `/home` → clicks "Get Started"
|
||||||
|
2. Fills registration form on `/register` → submits
|
||||||
|
3. Auto-logged in → redirected to `/boards`
|
||||||
|
4. Empty state shown → clicks "+ New Board"
|
||||||
|
5. Enters board name + description → clicks "Create Board"
|
||||||
|
6. Redirected to new board `/boards/:id` → empty board with no lists
|
||||||
|
7. Clicks "+ Add List" → enters list name (e.g., "To Do", "In Progress", "Done")
|
||||||
|
8. Repeats for more lists
|
||||||
|
|
||||||
|
### Flow 2: Creating and Managing Cards
|
||||||
|
1. On board view, clicks "+ Add a card" in a list
|
||||||
|
2. Enters card name → creates card
|
||||||
|
3. Clicks card → card preview modal opens
|
||||||
|
4. Clicks "View Full Details" → navigates to Card Detail page
|
||||||
|
5. Adds description, assigns labels, sets due date
|
||||||
|
6. Adds checklist with items, checks off items
|
||||||
|
7. Adds comments
|
||||||
|
8. Uploads file attachments
|
||||||
|
9. Returns to board → sees updated card with badges
|
||||||
|
|
||||||
|
### Flow 3: Card Linking
|
||||||
|
1. On Card Detail page, clicks action dropdown (⋮)
|
||||||
|
2. Selects "Create Linked Card" → enters name/description → creates
|
||||||
|
3. OR selects "Link Existing Card" → searches board cards → selects one → links
|
||||||
|
4. Linked cards shown in "Linked Cards" section
|
||||||
|
5. Can unlink via × button → confirms in Unlink Card Modal
|
||||||
|
|
||||||
|
### Flow 4: Epic Management
|
||||||
|
1. From board sidebar, clicks "Epics"
|
||||||
|
2. On epics page, clicks "+ New Epic"
|
||||||
|
3. Fills in name, description, color, rich content, assigns completed list
|
||||||
|
4. On board view, opens a card → assigns the epic
|
||||||
|
5. Epic metrics auto-update (card count, completion progress)
|
||||||
|
|
||||||
|
### Flow 5: Wiki Documentation
|
||||||
|
1. From board sidebar, clicks "Wikis"
|
||||||
|
2. Clicks "+ New Wiki"
|
||||||
|
3. Enters title, summary, category, tags
|
||||||
|
4. Writes rich text content in editor
|
||||||
|
5. Saves → views rendered wiki page
|
||||||
|
6. Can link wiki to cards/epics
|
||||||
|
|
||||||
|
### Flow 6: Drag & Drop on Board
|
||||||
|
1. User hovers over a card → cursor changes to grab
|
||||||
|
2. Drags card to new position in same list OR to different list
|
||||||
|
3. Ghost overlay follows cursor
|
||||||
|
4. On drop, card moves to new position/list
|
||||||
|
5. Lists can also be reordered by dragging column headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Component Inventory
|
||||||
|
|
||||||
|
### Reusable UI Components
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Navbar** | Top navigation with logo, links, auth controls |
|
||||||
|
| **BoardSidebar** | Fixed right-side nav for board sections |
|
||||||
|
| **KanbanColumn** | Vertical list container with header and card list |
|
||||||
|
| **SortableKanbanColumn** | Drag-enabled version of KanbanColumn |
|
||||||
|
| **KanbanCard** | Card summary shown in columns (name, badges, labels) |
|
||||||
|
| **BoardCard** | Board summary card for the boards listing page |
|
||||||
|
| **CardPreviewModal** | Quick card preview popup |
|
||||||
|
| **CardSidebar** | Right sidebar on card detail (due date, list, etc.) |
|
||||||
|
| **CardLabels** | Label chips display + picker |
|
||||||
|
| **LabelDropdown** | Dropdown for selecting/toggling labels |
|
||||||
|
| **CardEpics** | Epic assignment display + picker |
|
||||||
|
| **CardLinks** | Linked cards display with unlink action |
|
||||||
|
| **CardChecklists** | Checklists with checkable items, progress bars |
|
||||||
|
| **CardComments** | Comment thread with add/edit/delete |
|
||||||
|
| **CardAttachments** | File upload area + attachment list |
|
||||||
|
| **CardActionDropdown** | Context menu (⋮) for card actions |
|
||||||
|
| **RichTextEditor** | Slate.js rich text editor with formatting toolbar |
|
||||||
|
| **RichTextContent** | Read-only rich text renderer |
|
||||||
|
| **SecureImage** | Authenticated image component (MinIO-backed) |
|
||||||
|
| **WidePageLayout** | Full-width page wrapper with standard padding |
|
||||||
|
| **NarrowPageLayout** | Centered narrow content wrapper (~900px max) |
|
||||||
|
| **DeleteCardModal** | Confirmation dialog for card deletion |
|
||||||
|
| **DeleteListModal** | Confirmation dialog for list deletion |
|
||||||
|
| **CreateListModal** | Form modal for creating a new list |
|
||||||
|
| **EditListModal** | Form modal for editing a list name |
|
||||||
|
| **CreateCardModal** | Form modal for creating a new card |
|
||||||
|
| **EditCardModal** | Form modal for editing card name/description |
|
||||||
|
| **CreateLinkedCardModal** | Form modal for creating + linking a card |
|
||||||
|
| **LinkExistingCardModal** | Search/select modal for linking existing cards |
|
||||||
|
| **UnlinkCardModal** | Confirmation modal for unlinking cards |
|
||||||
|
| **CreateChecklistModal** | Form modal for adding a checklist |
|
||||||
|
| **DeleteChecklistModal** | Confirmation modal for deleting a checklist |
|
||||||
|
| **EditCheckItemModal** | Form modal for editing a checklist item |
|
||||||
|
| **CreateLabelModal** | Form modal for creating a new label |
|
||||||
|
| **ProtectedRoute** | Auth guard wrapper component |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. States to Design
|
||||||
|
|
||||||
|
For each page, please design these states:
|
||||||
|
|
||||||
|
1. **Loading state**: Skeleton/spinner while data loads
|
||||||
|
2. **Empty state**: Helpful message + CTA when no data exists
|
||||||
|
3. **Populated state**: Normal data display
|
||||||
|
4. **Error state**: Error message display (toast notification pattern)
|
||||||
|
5. **Mobile responsive**: Stack layouts vertically, hamburger nav, full-width cards
|
||||||
|
|
||||||
|
### Special States
|
||||||
|
- **Card dragging**: Ghost overlay of card at cursor position
|
||||||
|
- **Column dragging**: Ghost overlay of column at cursor position
|
||||||
|
- **Inline editing**: Input fields replacing display text (card name, description)
|
||||||
|
- **Modal overlay**: Dark backdrop with centered modal card
|
||||||
|
- **Toast notifications**: Top-right corner, auto-dismiss, types: success (green), error (red), info (blue)
|
||||||
|
- **Archived items**: Dimmed with "Archived" badge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Responsive Breakpoints
|
||||||
|
|
||||||
|
| Breakpoint | Width | Layout Changes |
|
||||||
|
|-----------|-------|----------------|
|
||||||
|
| Mobile | < 640px | Single column, hamburger nav, stacked cards, full-width modals |
|
||||||
|
| Tablet | 640-1024px | 2-column grids, collapsible sidebar |
|
||||||
|
| Desktop | > 1024px | Full kanban horizontal scroll, 3-column card detail, sidebar visible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Design Priorities
|
||||||
|
|
||||||
|
1. **Board Detail (Kanban View)** — This is the most-used page; prioritize its drag-and-drop UX
|
||||||
|
2. **Card Detail** — Second most important; dense information layout
|
||||||
|
3. **Card Detail Modals** — Many user workflows happen through modals
|
||||||
|
4. **Boards List** — Entry point after login
|
||||||
|
5. **Epic Detail & Wiki Detail** — Supporting features
|
||||||
|
6. **Auth Pages** — Simple but must look polished
|
||||||
|
7. **Forms (Create/Edit)** — Standard patterns, focus on rich text editor UX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of design brief. Use this document to generate complete UI flows and page designs for the Taskboard application.*
|
||||||
|
|
@ -11,6 +11,7 @@ import { Navbar } from './components/Navbar';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import { Register } from './pages/Register';
|
import { Register } from './pages/Register';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
import { Boards } from './pages/Boards';
|
import { Boards } from './pages/Boards';
|
||||||
import { BoardCreate } from './pages/BoardCreate';
|
import { BoardCreate } from './pages/BoardCreate';
|
||||||
|
|
@ -59,6 +60,14 @@ const App = () => {
|
||||||
<Route path="/home" element={<Home />} />
|
<Route path="/home" element={<Home />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Profile />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Protected Routes */}
|
{/* Protected Routes */}
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useAuth } from '../hooks/useAuth';
|
||||||
import { TaskboardLogo } from './TaskboardLogo';
|
import { TaskboardLogo } from './TaskboardLogo';
|
||||||
import MenuIcon from './icons/MenuIcon';
|
import MenuIcon from './icons/MenuIcon';
|
||||||
import CloseIcon from './icons/CloseIcon';
|
import CloseIcon from './icons/CloseIcon';
|
||||||
|
import UserIcon from './icons/UserIcon';
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { user } = useApp();
|
const { user } = useApp();
|
||||||
|
|
@ -44,32 +45,38 @@ export function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center gap-3">
|
<div className="hidden md:flex items-center gap-3">
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-300 px-3 py-2">{user.username}</span>
|
<Link
|
||||||
<button
|
to="/profile"
|
||||||
onClick={logout}
|
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"
|
||||||
className="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>
|
||||||
Logout
|
{user.username}
|
||||||
</button>
|
</Link>
|
||||||
</>
|
<button
|
||||||
) : (
|
onClick={logout}
|
||||||
<>
|
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
<Link
|
>
|
||||||
to="/login"
|
Logout
|
||||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
</button>
|
||||||
>
|
</>
|
||||||
Login
|
) : (
|
||||||
</Link>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/login"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Register
|
Login
|
||||||
</Link>
|
</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>
|
||||||
<div className="md:hidden flex items-center">
|
<div className="md:hidden flex items-center">
|
||||||
<button
|
<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;
|
||||||
|
|
@ -466,8 +466,11 @@ export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
||||||
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
|
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
|
||||||
return 'align' in element;
|
return 'align' in element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
|
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
|
||||||
switch (element.type) {
|
console.log('SlateRenderElement', element);
|
||||||
|
const elementType = element.type ? element.type.split(' ')[0] : '';
|
||||||
|
switch (elementType) {
|
||||||
case 'block-quote':
|
case 'block-quote':
|
||||||
return (
|
return (
|
||||||
<blockquote
|
<blockquote
|
||||||
|
|
@ -486,6 +489,15 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
|
||||||
<code>{children}</code>
|
<code>{children}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
case 'code-line':
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className="inline bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto my-4 font-mono text-sm border border-gray-700"
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
case 'bulleted-list':
|
case 'bulleted-list':
|
||||||
return (
|
return (
|
||||||
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
|
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
|
||||||
|
|
@ -512,7 +524,7 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
|
||||||
);
|
);
|
||||||
case 'list-item':
|
case 'list-item':
|
||||||
return (
|
return (
|
||||||
<li className="my-1 text-gray-200" {...attributes}>
|
<li className="my-1 mx-2 text-gray-200" {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
@ -523,11 +535,7 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
|
||||||
</ol>
|
</ol>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return <div {...attributes}>{children}</div>;
|
||||||
<div className="inline my-2 mr-2 leading-relaxed text-gray-200" {...attributes}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { RegisterData, UserData, AuthResponse } from '../types';
|
import { RegisterData, UserData, AuthResponse, LoginHistoryEntry, ChangePasswordData } from '../types';
|
||||||
import {
|
import {
|
||||||
Board,
|
Board,
|
||||||
BoardWithDetails,
|
BoardWithDetails,
|
||||||
|
|
@ -291,6 +291,16 @@ export function useApi() {
|
||||||
await api.delete(`/cards/${cardId}/epics/${epicId}`);
|
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
|
// Card Links
|
||||||
getCardLinks: async (cardId: number): Promise<any> => {
|
getCardLinks: async (cardId: number): Promise<any> => {
|
||||||
const response = await api.get(`/cards/${cardId}/links`);
|
const response = await api.get(`/cards/${cardId}/links`);
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,33 @@ function useEpics(boardId: string) {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
return newEpic;
|
return newEpic;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
|
||||||
setError(err instanceof Error ? err : new Error(errorMessage));
|
setError(err instanceof Error ? err : new Error(errorMessage));
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
if (err.status === 400 && err.response?.data?.validation_error?.body_params) {
|
||||||
title: 'Error Creating Epic',
|
const validationErrors = err.response.data.validation_error.body_params;
|
||||||
message: errorMessage,
|
|
||||||
duration: 5000,
|
for (let index = 0; index < validationErrors.length; index++) {
|
||||||
});
|
const validationError = validationErrors[index];
|
||||||
|
|
||||||
|
const validationMessage = `${validationError.loc.join(', ')} ${validationError.msg}`;
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating Epic',
|
||||||
|
message: validationMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error Creating Epic',
|
||||||
|
message: errorMessage,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
// This shouldn't happen due to disabled button, but handle it gracefully
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleRegister({
|
await handleRegister({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
|
confirm_password: formData.confirmPassword,
|
||||||
first_name: formData.first_name,
|
first_name: formData.first_name,
|
||||||
last_name: formData.last_name,
|
last_name: formData.last_name,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface LoginData {
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
confirm_password: string;
|
||||||
username: string;
|
username: string;
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
|
|
@ -26,5 +27,21 @@ export interface RegisterData {
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
user: UserData;
|
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