Compare commits

..

4 commits

24 changed files with 2108 additions and 98 deletions

View file

@ -12,6 +12,11 @@ class Config:
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
# Auth Security Configuration
MAX_FAILED_LOGIN_ATTEMPTS = int(os.environ.get("MAX_FAILED_LOGIN_ATTEMPTS", "5"))
ACCOUNT_LOCK_DURATION_MINUTES = int(os.environ.get("ACCOUNT_LOCK_DURATION_MINUTES", "30"))
LOCKOUT_STRATEGY = os.environ.get("LOCKOUT_STRATEGY", "timeout") # "timeout" | "permanent"
# Celery Configuration
CELERY = {
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
@ -162,4 +167,4 @@ config_by_name = {
"dev": DevelopmentConfig,
"test": TestingConfig,
"prod": ProductionConfig,
}
}

View file

@ -11,6 +11,7 @@ from app.models.epic import Epic
from app.models.file_attachment import FileAttachment
from app.models.label import Label
from app.models.list_model import List
from app.models.login_history import LoginHistory
from app.models.user import User
from app.models.wiki import Wiki, wiki_entity_links
@ -20,6 +21,7 @@ __all__ = [
"Board",
"List",
"Card",
"LoginHistory",
"Label",
"CardLabel",
"CardLink",

View 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}>"
)

View file

@ -1,4 +1,6 @@
from datetime import UTC, datetime
"""User model"""
from datetime import UTC, datetime, timezone
from werkzeug.security import check_password_hash, generate_password_hash
@ -18,6 +20,9 @@ class User(db.Model):
last_name = db.Column(db.String(50))
is_active = db.Column(db.Boolean, default=True)
is_admin = db.Column(db.Boolean, default=False)
failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
@ -29,6 +34,21 @@ class User(db.Model):
boards = db.relationship(
"Board", backref="user", cascade="all, delete-orphan", lazy="dynamic"
)
login_history = db.relationship(
"LoginHistory",
back_populates="user",
lazy="dynamic",
cascade="all, delete-orphan",
)
@property
def is_locked(self):
"""Check if account is currently locked (timeout or permanent)"""
if not self.is_active:
return True
if self.locked_until and self.locked_until > datetime.now(timezone.utc):
return True
return False
def set_password(self, password):
"""Hash and set password"""
@ -47,10 +67,12 @@ class User(db.Model):
"first_name": self.first_name,
"last_name": self.last_name,
"is_active": self.is_active,
"is_admin": self.is_admin,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"last_login_at": self.last_login_at
if self.last_login_at
else None,
"created_at": self.created_at if self.created_at else None,
"updated_at": self.updated_at if self.updated_at else None,
}
def __repr__(self):
return f"<User {self.username}>"
return f"<User {self.username}>"

View file

@ -1,9 +1,21 @@
"""Auth and user management API routes"""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import (create_access_token, create_refresh_token,
get_jwt_identity, jwt_required)
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
get_jwt_identity,
jwt_required,
)
from app import db
from app.models import User
from app.services.auth_service import (
authenticate_user,
change_password,
get_login_history,
register_user,
)
api_bp = Blueprint("api", __name__)
@ -12,63 +24,78 @@ api_bp = Blueprint("api", __name__)
@api_bp.route("/auth/register", methods=["POST"])
def register():
"""Register a new user"""
data = request.get_json()
try:
data = request.get_json()
if not data or not data.get("email") or not data.get("password"):
return jsonify({"error": "Email and password are required"}), 400
if not data:
return jsonify({"error": "Request body is required"}), 400
if User.query.filter_by(email=data["email"]).first():
return jsonify({"error": "Email already exists"}), 400
# Server-side validation: require confirm_password for all registrations
confirm = data.get("confirm_password")
if not confirm:
return jsonify({"error": "confirm_password is required"}), 400
user = User(
email=data["email"],
username=data.get("username", data["email"].split("@")[0]),
first_name=data.get("first_name"),
last_name=data.get("last_name"),
)
user.set_password(data["password"])
# Use the auth service for registration with validation
user = register_user(data)
db.session.add(user)
db.session.commit()
# Generate tokens so user is logged in immediately
access_token = create_access_token(identity=str(user.id))
refresh_token = create_refresh_token(identity=str(user.id))
return jsonify(user.to_dict()), 201
return (
jsonify(
{
"user": user.to_dict(),
"access_token": access_token,
"refresh_token": refresh_token,
}
),
201,
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
@api_bp.route("/auth/login", methods=["POST"])
def login():
"""Login user"""
data = request.get_json()
"""Login user with account lockout and login history tracking"""
try:
data = request.get_json()
if not data or not data.get("email") or not data.get("password"):
return jsonify({"error": "Email and password are required"}), 400
if not data or not data.get("email") or not data.get("password"):
return jsonify({"error": "Email and password are required"}), 400
user = User.query.filter_by(email=data["email"]).first()
# Capture IP and user agent for login history
ip_address = request.remote_addr or "unknown"
user_agent = request.headers.get("User-Agent", "unknown")
if not user or not user.check_password(data["password"]):
return jsonify({"error": "Invalid credentials"}), 401
user, access_token, refresh_token = authenticate_user(
email=data["email"],
password=data["password"],
ip_address=ip_address,
user_agent=user_agent,
)
if not user.is_active:
return jsonify({"error": "Account is inactive"}), 401
return (
jsonify(
{
"user": user.to_dict(),
"access_token": access_token,
"refresh_token": refresh_token,
}
),
200,
)
access_token = create_access_token(identity=str(user.id))
refresh_token = create_refresh_token(identity=str(user.id))
return (
jsonify(
{
"user": user.to_dict(),
"access_token": access_token,
"refresh_token": refresh_token,
}
),
200,
)
except ValueError as e:
return jsonify({"error": str(e)}), 401
@api_bp.route("/users/me", methods=["GET"])
@jwt_required()
def get_current_user():
"""Get current user"""
"""Get current user profile"""
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
@ -76,3 +103,53 @@ def get_current_user():
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200
@api_bp.route("/users/me/password", methods=["PUT"])
@jwt_required()
def update_password():
"""Change current user's password"""
try:
data = request.get_json()
if not data:
return jsonify({"error": "Request body is required"}), 400
current_password = data.get("current_password")
new_password = data.get("new_password")
confirm_password = data.get("confirm_password")
if not all([current_password, new_password, confirm_password]):
return jsonify(
{
"error": "current_password, new_password, and confirm_password are required"
}
), 400
user_id = int(get_jwt_identity())
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
change_password(user, current_password, new_password, confirm_password)
return jsonify({"message": "Password updated successfully"}), 200
except ValueError as e:
return jsonify({"error": str(e)}), 400
@api_bp.route("/users/me/login-history", methods=["GET"])
@jwt_required()
def get_user_login_history():
"""Get login history for current user"""
user_id = int(get_jwt_identity())
limit = request.args.get("limit", 20, type=int)
# Sanity check on limit
limit = min(max(limit, 1), 100)
history = get_login_history(user_id, limit=limit)
return jsonify(history), 200

View file

@ -27,7 +27,7 @@ class EpicCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
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")
pos: Optional[float] = Field(None, description="Position for ordering")
depth_limit: Optional[int] = Field(

View 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()

View file

@ -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 ###

View file

@ -92,7 +92,9 @@ class TestEpicRoutes:
self, client, db_session, auth_headers, test_board
):
"""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(
f"/api/boards/{test_board.id}/epics",
@ -113,13 +115,18 @@ class TestEpicRoutes:
):
"""Test creating epic with 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.commit()
epic_data = {
"name": "Child Epic",
"parent_epic_id": parent_epic.id,
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
}
response = client.post(
@ -139,8 +146,8 @@ class TestEpicRoutes:
epic_data = {
"name": "Epic with Completed List",
"completed_list_id": test_list.id,
"content": [{"type": "heading", "children": [{"text": "Updated Content"}]}],
}
response = client.post(
f"/api/boards/{test_board.id}/epics",
headers=auth_headers,
@ -153,7 +160,10 @@ class TestEpicRoutes:
def test_create_epic_board_not_found(self, client, db_session, auth_headers):
"""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(
"/api/boards/99999/epics",

View file

@ -13,6 +13,7 @@ class TestAuthRoutes:
json={
"email": "newuser@example.com",
"password": "password123",
"confirm_password": "password123",
"username": "newuser",
"first_name": "New",
"last_name": "User",
@ -21,10 +22,38 @@ class TestAuthRoutes:
assert response.status_code == 201
data = response.get_json()
assert data["email"] == "newuser@example.com"
assert data["username"] == "newuser"
assert "password" not in data
assert "password_hash" not in data
assert data["user"]["email"] == "newuser@example.com"
assert data["user"]["username"] == "newuser"
assert "password" not in data["user"]
assert "password_hash" not in data["user"]
@pytest.mark.auth
def test_register_password_mismatch(self, client):
"""Test registration with mismatched passwords"""
response = client.post(
"/api/auth/register",
json={
"email": "mismatch@example.com",
"password": "password123",
"confirm_password": "differentpassword",
"username": "mismatch",
},
)
assert response.status_code == 400
data = response.get_json()
assert "do not match" in data["error"].lower()
@pytest.mark.auth
def test_register_missing_confirm_password(self, client):
"""Test registration without confirm_password"""
response = client.post(
"/api/auth/register", json={"email": "noconfirm@example.com", "password": "password123"}
)
assert response.status_code == 400
data = response.get_json()
assert "confirm_password" in data["error"].lower()
@pytest.mark.auth
def test_register_missing_fields(self, client):
@ -42,7 +71,11 @@ class TestAuthRoutes:
"""Test registration with duplicate email"""
response = client.post(
"/api/auth/register",
json={"email": regular_user.email, "password": "password123"},
json={
"email": regular_user.email,
"password": "password123",
"confirm_password": "password123",
},
)
assert response.status_code == 400
@ -96,7 +129,7 @@ class TestAuthRoutes:
assert response.status_code == 401
data = response.get_json()
assert "inactive" in data["error"].lower()
assert "locked" in data["error"].lower()
@pytest.mark.auth
def test_get_current_user(self, client, auth_headers, regular_user):
@ -112,3 +145,201 @@ class TestAuthRoutes:
"""Test getting current user without authentication"""
response = client.get("/api/users/me")
assert response.status_code == 401
@pytest.mark.auth
def test_change_password_success(self, client, auth_headers):
"""Test successful password change"""
response = client.put(
"/api/users/me/password",
headers=auth_headers,
json={
"current_password": "password123",
"new_password": "newpassword456",
"confirm_password": "newpassword456",
},
)
assert response.status_code == 200
data = response.get_json()
assert "Password updated" in data["message"]
@pytest.mark.auth
def test_change_password_wrong_current(self, client, auth_headers):
"""Test password change with wrong current password"""
response = client.put(
"/api/users/me/password",
headers=auth_headers,
json={
"current_password": "wrongpassword",
"new_password": "newpassword456",
"confirm_password": "newpassword456",
},
)
assert response.status_code == 400
data = response.get_json()
assert "incorrect" in data["error"].lower()
@pytest.mark.auth
def test_change_password_mismatch(self, client, auth_headers):
"""Test password change with mismatched new passwords"""
response = client.put(
"/api/users/me/password",
headers=auth_headers,
json={
"current_password": "password123",
"new_password": "newpassword456",
"confirm_password": "different456",
},
)
assert response.status_code == 400
data = response.get_json()
assert "do not match" in data["error"].lower()
@pytest.mark.auth
def test_get_login_history(self, client, auth_headers, regular_user):
"""Test getting login history"""
# Login first to create history
client.post(
"/api/auth/login",
json={"email": regular_user.email, "password": "password123"},
)
response = client.get(
"/api/users/me/login-history", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert isinstance(data, list)
@pytest.mark.auth
def test_login_lockout_after_five_attempts(self, client, regular_user):
"""Test account lockout after 5 failed attempts (default timeout strategy)"""
# First 4 attempts should return generic invalid error
for i in range(4):
response = client.post(
"/api/auth/login",
json={
"email": regular_user.email,
"password": "wrongpassword",
},
)
assert response.status_code == 401
data = response.get_json()
assert "Invalid" in data["error"]
# 5th attempt should trigger lockout
response = client.post(
"/api/auth/login",
json={
"email": regular_user.email,
"password": "wrongpassword",
},
)
assert response.status_code == 401
data = response.get_json()
assert "locked" in data["error"].lower()
@pytest.mark.auth
def test_login_lockout_increments_counter(self, client, regular_user):
"""Test that failed_login_attempts increments correctly"""
from app import db
# First attempt
client.post(
"/api/auth/login",
json={
"email": regular_user.email,
"password": "wrongpassword",
},
)
db.session.refresh(regular_user)
assert regular_user.failed_login_attempts == 1
# Second attempt
client.post(
"/api/auth/login",
json={
"email": regular_user.email,
"password": "wrongpassword",
},
)
db.session.refresh(regular_user)
assert regular_user.failed_login_attempts == 2
@pytest.mark.auth
def test_login_resets_counter_on_success(self, client, regular_user):
"""Test that successful login resets failed_login_attempts"""
from app import db
# 3 failed attempts
for _ in range(3):
client.post(
"/api/auth/login",
json={
"email": regular_user.email,
"password": "wrongpassword",
},
)
db.session.refresh(regular_user)
assert regular_user.failed_login_attempts == 3
# Successful login resets counter
response = client.post(
"/api/auth/login",
json={
"email": regular_user.email,
"password": "password123",
},
)
assert response.status_code == 200
db.session.refresh(regular_user)
assert regular_user.failed_login_attempts == 0
@pytest.mark.auth
def test_change_password_unauthorized(self, client):
"""Test password change without authentication"""
response = client.put(
"/api/users/me/password",
json={
"current_password": "password123",
"new_password": "newpassword456",
"confirm_password": "newpassword456",
},
)
assert response.status_code == 401
@pytest.mark.auth
def test_get_login_history_unauthorized(self, client):
"""Test login history without authentication"""
response = client.get("/api/users/me/login-history")
assert response.status_code == 401
@pytest.mark.auth
def test_get_login_history_success(self, client, auth_headers):
"""Test getting login history"""
response = client.get(
"/api/users/me/login-history", headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert isinstance(data, list)
@pytest.mark.auth
def test_login_history_recorded(self, client, regular_user):
"""Test that login history is recorded"""
# Log in
client.post(
"/api/auth/login",
json={"email": regular_user.email, "password": "password123"},
)
# Check history
from app import db
from app.models import LoginHistory
entries = LoginHistory.query.filter_by(user_id=regular_user.id).all()
assert len(entries) >= 1

850
docs/figma-design-prompt.md Normal file
View 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.*

View file

@ -11,6 +11,7 @@ import { Navbar } from './components/Navbar';
import { Home } from './pages/Home';
import Login from './pages/Login';
import { Register } from './pages/Register';
import Profile from './pages/Profile';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate';
@ -59,6 +60,14 @@ const App = () => {
<Route path="/home" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
{/* Protected Routes */}
<Route

View file

@ -5,6 +5,7 @@ import { useAuth } from '../hooks/useAuth';
import { TaskboardLogo } from './TaskboardLogo';
import MenuIcon from './icons/MenuIcon';
import CloseIcon from './icons/CloseIcon';
import UserIcon from './icons/UserIcon';
export function Navbar() {
const { user } = useApp();
@ -44,32 +45,38 @@ export function Navbar() {
</div>
</div>
<div className="hidden md:flex items-center gap-3">
{user ? (
<>
<span className="text-gray-300 px-3 py-2">{user.username}</span>
<button
onClick={logout}
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Login
</Link>
<Link
to="/register"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
>
Register
</Link>
</>
)}
{user ? (
<>
<Link
to="/profile"
className="flex items-center gap-1.5 text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
<span className="w-4 h-4"><UserIcon /></span>
{user.username}
</Link>
<button
onClick={logout}
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Login
</Link>
<Link
to="/register"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
>
Register
</Link>
</>
)}
</div>
<div className="md:hidden flex items-center">
<button

View 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;

View 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;

View 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;

View 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;

View file

@ -466,8 +466,11 @@ export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
return 'align' in element;
};
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':
return (
<blockquote
@ -486,6 +489,15 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
<code>{children}</code>
</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':
return (
<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':
return (
<li className="my-1 text-gray-200" {...attributes}>
<li className="my-1 mx-2 text-gray-200" {...attributes}>
{children}
</li>
);
@ -523,11 +535,7 @@ export const SlateRenderElement = ({ attributes, children, element }: RenderElem
</ol>
);
default:
return (
<div className="inline my-2 mr-2 leading-relaxed text-gray-200" {...attributes}>
{children}
</div>
);
return <div {...attributes}>{children}</div>;
}
};

View file

@ -1,5 +1,5 @@
import axios from 'axios';
import { RegisterData, UserData, AuthResponse } from '../types';
import { RegisterData, UserData, AuthResponse, LoginHistoryEntry, ChangePasswordData } from '../types';
import {
Board,
BoardWithDetails,
@ -291,6 +291,16 @@ export function useApi() {
await api.delete(`/cards/${cardId}/epics/${epicId}`);
},
// Auth - Password & History
changePassword: async (data: ChangePasswordData): Promise<{ message: string }> => {
const response = await api.put<{ message: string }>('/users/me/password', data);
return response.data;
},
getLoginHistory: async (limit: number = 20): Promise<LoginHistoryEntry[]> => {
const response = await api.get<LoginHistoryEntry[]>(`/users/me/login-history?limit=${limit}`);
return response.data;
},
// Card Links
getCardLinks: async (cardId: number): Promise<any> => {
const response = await api.get(`/cards/${cardId}/links`);

View file

@ -52,15 +52,33 @@ function useEpics(boardId: string) {
duration: 3000,
});
return newEpic;
} catch (err) {
} catch (err: any) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Creating Epic',
message: errorMessage,
duration: 5000,
});
if (err.status === 400 && err.response?.data?.validation_error?.body_params) {
const validationErrors = err.response.data.validation_error.body_params;
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;
}
},

View 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,
}
}

View 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

View file

@ -41,11 +41,17 @@ export function Register() {
return;
}
if (formData.password !== formData.confirmPassword) {
// This shouldn't happen due to disabled button, but handle it gracefully
return;
}
try {
await handleRegister({
email: formData.email,
username: formData.username,
password: formData.password,
confirm_password: formData.confirmPassword,
first_name: formData.first_name,
last_name: formData.last_name,
});

View file

@ -19,6 +19,7 @@ export interface LoginData {
export interface RegisterData {
email: string;
password: string;
confirm_password: string;
username: string;
first_name?: string;
last_name?: string;
@ -26,5 +27,21 @@ export interface RegisterData {
export interface AuthResponse {
access_token: string;
refresh_token?: string;
user: UserData;
}
export interface LoginHistoryEntry {
id: number;
user_id: number;
ip_address: string;
user_agent: string;
success: boolean;
created_at: string;
}
export interface ChangePasswordData {
current_password: string;
new_password: string;
confirm_password: string;
}