This commit is contained in:
Bryce
2025-08-03 22:26:36 -07:00
parent fb3906a670
commit 9de5413e5a
16 changed files with 1179 additions and 85 deletions

View File

@@ -1,19 +1,34 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from config import config
from app.models import db, Base
from app.models import db, Base, User
from flask_migrate import Migrate
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'warning'
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize extensions
db.init_app(app)
migrate = Migrate(app, db)
login_manager.init_app(app)
# Register blueprints
from app.routes import main
from app.auth import auth
app.register_blueprint(main)
app.register_blueprint(auth, url_prefix='/auth')
# User loader for Flask-Login
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
return app

131
app/auth.py Normal file
View File

@@ -0,0 +1,131 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from app.models import User
from datetime import datetime
import re
auth = Blueprint('auth', __name__)
def validate_password(password):
"""Validate password strength."""
if len(password) < 8:
return False, "Password must be at least 8 characters long"
if not re.search(r'[A-Z]', password):
return False, "Password must contain at least one uppercase letter"
if not re.search(r'[a-z]', password):
return False, "Password must contain at least one lowercase letter"
if not re.search(r'\d', password):
return False, "Password must contain at least one digit"
return True, "Password is valid"
@auth.route('/login', methods=['GET', 'POST'])
def login():
"""Handle user login."""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
email = request.form.get('email')
password = request.form.get('password')
remember = request.form.get('remember', False)
# Validate input
if not email:
flash('Email is required', 'error')
return render_template('auth/login.html')
if not password:
flash('Password is required', 'error')
return render_template('auth/login.html')
# Find user by email
user = User.query.filter_by(email=email).first()
if user and user.check_password(password):
# Login successful
login_user(user, remember=remember)
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('main.index'))
else:
# Login failed
flash('Invalid email or password', 'error')
return render_template('auth/login.html')
@auth.route('/signup', methods=['GET', 'POST'])
def signup():
"""Handle user registration."""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
first_name = request.form.get('first_name')
last_name = request.form.get('last_name')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
# Validate input
errors = []
if not first_name:
errors.append('First name is required')
elif len(first_name) > 50:
errors.append('First name must be less than 50 characters')
if not last_name:
errors.append('Last name is required')
elif len(last_name) > 50:
errors.append('Last name must be less than 50 characters')
if not email:
errors.append('Email is required')
elif not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', email):
errors.append('Please enter a valid email address')
elif User.query.filter_by(email=email).first():
errors.append('Email already registered')
if not password:
errors.append('Password is required')
else:
is_valid, message = validate_password(password)
if not is_valid:
errors.append(message)
if not confirm_password:
errors.append('Please confirm your password')
elif password != confirm_password:
errors.append('Passwords do not match')
if errors:
return render_template('auth/signup.html', errors=errors,
first_name=first_name, last_name=last_name, email=email)
# Create new user
user = User(
first_name=first_name.strip(),
last_name=last_name.strip(),
email=email.strip()
)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Login the new user
login_user(user)
flash('Account created successfully!', 'success')
return redirect(url_for('main.index'))
return render_template('auth/signup.html')
@auth.route('/logout')
@login_required
def logout():
"""Handle user logout."""
logout_user()
flash('You have been logged out', 'info')
return redirect(url_for('auth.login'))

View File

@@ -1,19 +1,36 @@
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import declarative_base
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from flask_login import UserMixin
import uuid
Base = declarative_base()
db = SQLAlchemy(model_class=Base)
class User(Base):
class User(Base, UserMixin):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
first_name = db.Column(db.String(255), nullable=False)
last_name = db.Column(db.String(255), nullable=False)
email = db.Column(db.String(255), unique=True, nullable=False)
# Placeholders for Milestone 1
password_hash = db.Column(db.LargeBinary)
password_hash = db.Column(db.String(2048), nullable=False)
imap_config = db.Column(db.JSON) # Using db.JSON instead of db.JSONB for compatibility
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def set_password(self, password):
"""Hash and set the password."""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Check if the provided password matches the stored hash."""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.first_name} {self.last_name} ({self.email})>'
class Folder(Base):
__tablename__ = 'folders'

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, jsonify, make_response
from flask import Blueprint, render_template, request, jsonify, make_response, flash, redirect, url_for
from flask_login import login_required, current_user
from app import db
from app.models import Folder, User
import uuid
@@ -6,22 +7,15 @@ import logging
main = Blueprint('main', __name__)
# For prototype, use a fixed user ID
MOCK_USER_ID = 1
@main.route('/')
@login_required
def index():
# Ensure the mock user exists
user = db.session.get(User, MOCK_USER_ID)
if not user:
user = User(id=MOCK_USER_ID, email='prototype@example.com')
db.session.add(user)
db.session.commit()
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
# Get folders for the current authenticated user
folders = Folder.query.filter_by(user_id=current_user.id).all()
return render_template('index.html', folders=folders)
@main.route('/api/folders/new', methods=['GET'])
@login_required
def new_folder_modal():
# Return the add folder modal
response = make_response(render_template('partials/folder_modal.html'))
@@ -29,6 +23,7 @@ def new_folder_modal():
return response
@main.route('/api/folders', methods=['POST'])
@login_required
def add_folder():
try:
# Get form data instead of JSON
@@ -59,9 +54,9 @@ def add_folder():
response.headers['HX-Reswap'] = 'outerHTML'
return response
# Create new folder
# Create new folder for the current user
folder = Folder(
user_id=MOCK_USER_ID,
user_id=current_user.id,
name=name.strip(),
rule_text=rule_text.strip(),
priority=int(priority) if priority else 0
@@ -70,8 +65,8 @@ def add_folder():
db.session.add(folder)
db.session.commit()
# Get updated list of folders
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
# Get updated list of folders for the current user
folders = Folder.query.filter_by(user_id=current_user.id).all()
# Return the updated folders list HTML
response = make_response(render_template('partials/folders_list.html', folders=folders))
@@ -91,22 +86,23 @@ def add_folder():
return response
@main.route('/api/folders/<folder_id>', methods=['DELETE'])
@login_required
def delete_folder(folder_id):
try:
# Find the folder by ID
folder = Folder.query.filter_by(id=folder_id, user_id=MOCK_USER_ID).first()
# Find the folder by ID and ensure it belongs to the current user
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
if not folder:
# Folder not found
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
folders = Folder.query.filter_by(user_id=current_user.id).all()
return render_template('partials/folders_list.html', folders=folders)
# Delete the folder
db.session.delete(folder)
db.session.commit()
# Get updated list of folders
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
# Get updated list of folders for the current user
folders = Folder.query.filter_by(user_id=current_user.id).all()
# Return the updated folders list HTML
return render_template('partials/folders_list.html', folders=folders)
@@ -116,14 +112,15 @@ def delete_folder(folder_id):
logging.exception("Error deleting folder: %s", e)
db.session.rollback()
# Return the folders list unchanged
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
folders = Folder.query.filter_by(user_id=current_user.id).all()
return render_template('partials/folders_list.html', folders=folders)
@main.route('/api/folders/<folder_id>/edit', methods=['GET'])
@login_required
def edit_folder_modal(folder_id):
try:
# Find the folder by ID
folder = Folder.query.filter_by(id=folder_id, user_id=MOCK_USER_ID).first()
# Find the folder by ID and ensure it belongs to the current user
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
if not folder:
return jsonify({'error': 'Folder not found'}), 404
@@ -139,14 +136,15 @@ def edit_folder_modal(folder_id):
return jsonify({'error': 'Error retrieving folder'}), 500
@main.route('/api/folders/<folder_id>', methods=['PUT'])
@login_required
def update_folder(folder_id):
try:
# Find the folder by ID
folder = Folder.query.filter_by(id=folder_id, user_id=MOCK_USER_ID).first()
# Find the folder by ID and ensure it belongs to the current user
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
if not folder:
# Folder not found
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
folders = Folder.query.filter_by(user_id=current_user.id).all()
return render_template('partials/folders_list.html', folders=folders)
# Get form data
@@ -184,8 +182,8 @@ def update_folder(folder_id):
db.session.commit()
# Get updated list of folders
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
# Get updated list of folders for the current user
folders = Folder.query.filter_by(user_id=current_user.id).all()
response = make_response(render_template('partials/folders_list.html', folders=folders))
response.headers['HX-Trigger'] = 'close-modal'

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Email Organizer</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen flex bg-base-200">
<div class="flex flex-col justify-center items-center min-h-screen w-full p-4">
<div class="card bg-base-100 shadow-xl w-full max-w-md">
<div class="card-body">
<div class="text-center mb-6">
<h1 class="text-3xl font-bold text-primary">
<i class="fas fa-envelope mr-2"></i>
Email Organizer
</h1>
<p class="text-base-content/70 mt-2">Sign in to your account</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }} mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ message }}</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email" name="email" class="input input-bordered w-full"
placeholder="Enter your email" required>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Password</span>
</label>
<input type="password" name="password" class="input input-bordered w-full"
placeholder="Enter your password" required>
</div>
<div class="form-control mb-6">
<label class="label cursor-pointer">
<span class="label-text">Remember me</span>
<input type="checkbox" name="remember" class="checkbox checkbox-primary">
</label>
</div>
<div class="form-control">
<button type="submit" class="btn btn-primary w-full">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign In
</button>
</div>
</form>
<div class="text-center mt-6">
<p class="text-base-content/70">
Don't have an account?
<a href="{{ url_for('auth.signup') }}" class="link link-primary">
Sign up
</a>
</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en" data-theme="cupcake">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Account - Email Organizer</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen flex bg-base-200">
<div class="flex flex-col justify-center items-center min-h-screen w-full p-4">
<div class="card bg-base-100 shadow-xl w-full max-w-md">
<div class="card-body">
<div class="text-center mb-6">
<h1 class="text-3xl font-bold text-primary">
<i class="fas fa-envelope mr-2"></i>
Email Organizer
</h1>
<p class="text-base-content/70 mt-2">Create your account</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }} mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ message }}</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if errors %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Please fix the following errors:</span>
</div>
<ul class="text-error text-sm mb-4">
{% for error in errors %}
<li>• {{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<form method="POST" action="{{ url_for('auth.signup') }}">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">First Name</span>
</label>
<input type="text" name="first_name" class="input input-bordered w-full"
placeholder="Enter your first name" value="{{ first_name or '' }}" required>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Last Name</span>
</label>
<input type="text" name="last_name" class="input input-bordered w-full"
placeholder="Enter your last name" value="{{ last_name or '' }}" required>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email" name="email" class="input input-bordered w-full"
placeholder="Enter your email" value="{{ email or '' }}" required>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Password</span>
</label>
<input type="password" name="password" class="input input-bordered w-full"
placeholder="Create a password" required>
<label class="label">
<span class="label-text-alt text-xs text-base-content/50">
Password must be at least 8 characters with uppercase, lowercase, and numbers
</span>
</label>
</div>
<div class="form-control mb-6">
<label class="label">
<span class="label-text">Confirm Password</span>
</label>
<input type="password" name="confirm_password" class="input input-bordered w-full"
placeholder="Confirm your password" required>
</div>
<div class="form-control mb-6">
<label class="label cursor-pointer">
<input type="checkbox" name="terms" class="checkbox checkbox-primary" required>
<span class="label-text">
I agree to the
<a href="#" class="link link-primary">Terms of Service</a>
and
<a href="#" class="link link-primary">Privacy Policy</a>
</span>
</label>
</div>
<div class="form-control">
<button type="submit" class="btn btn-primary w-full">
<i class="fas fa-user-plus mr-2"></i>
Create Account
</button>
</div>
</form>
<div class="text-center mt-6">
<p class="text-base-content/70">
Already have an account?
<a href="{{ url_for('auth.login') }}" class="link link-primary">
Sign in
</a>
</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -70,7 +70,7 @@
<div class="mt-auto pt-4 border-t border-base-300">
<div>
<a class="btn btn-ghost justify-start">
<a href="{{ url_for('auth.logout') }}" class="btn btn-ghost justify-start">
<i class="fas fa-sign-out-alt mr-3 text-error"></i>
Logout
</a>
@@ -96,9 +96,9 @@
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button id="user-menu-button" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-base-300" @click="open = !open">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
<span class="font-semibold text-primary-content">U</span>
<span class="font-semibold text-primary-content">{{ current_user.first_name[0] }}{{ current_user.last_name[0] }}</span>
</div>
<span class="hidden md:inline">User Name</span>
<span class="hidden md:inline">{{ current_user.first_name }} {{ current_user.last_name }}</span>
<i class="fas fa-chevron-down"></i>
</button>
@@ -106,7 +106,7 @@
<div id="user-dropdown" class="user-dropdown absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-base-100 z-10" x-show="open">
<a href="#" class="block px-4 py-2 text-sm hover:bg-base-300">Profile</a>
<a href="#" class="block px-4 py-2 text-sm hover:bg-base-300">Settings</a>
<a href="#" class="block px-4 py-2 text-sm hover:bg-base-300">Logout</a>
<a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-sm hover:bg-base-300">Logout</a>
</div>
</div>
</div>