Add in auth however there are no restrictions currently
This commit is contained in:
21
app.py
21
app.py
@@ -1,10 +1,12 @@
|
||||
from datetime import date
|
||||
import os
|
||||
from flask import Flask, abort, render_template, redirect, request, url_for
|
||||
from flask_login import LoginManager, current_user
|
||||
import jinja_partials
|
||||
from jinja2_fragments import render_block
|
||||
from decorators import validate_person, validate_topset, validate_workout
|
||||
from db import DataBase
|
||||
from routes.auth import auth, Person
|
||||
from extensions import db
|
||||
from utils import convert_str_to_date, generate_plot
|
||||
from flask_htmx import HTMX
|
||||
import minify_html
|
||||
@@ -17,9 +19,23 @@ if os.environ.get('FLASK_ENV') != 'production':
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_pyfile('config.py')
|
||||
app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f')
|
||||
jinja_partials.register_extensions(app)
|
||||
db = DataBase(app)
|
||||
htmx = HTMX(app)
|
||||
login_manager = LoginManager(app)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(person_id):
|
||||
row = db.execute("""SELECT person_id, name, email, password_hash from Person WHERE person_id=%s""", [person_id], one=True)
|
||||
if row:
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
||||
return None
|
||||
|
||||
app.register_blueprint(auth, url_prefix='/auth')
|
||||
|
||||
|
||||
|
||||
@app.after_request
|
||||
@@ -115,7 +131,6 @@ def person_overview(person_id):
|
||||
return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
||||
|
||||
@ app.route("/person/<int:person_id>/calendar")
|
||||
@ validate_person
|
||||
def get_calendar(person_id):
|
||||
selected_date = convert_str_to_date(request.args.get(
|
||||
'date'), '%Y-%m-%d') or date.today()
|
||||
|
||||
2
db.py
2
db.py
@@ -18,7 +18,7 @@ from utils import get_exercise_graph_model
|
||||
|
||||
|
||||
class DataBase():
|
||||
def __init__(self, app):
|
||||
def __init__(self, app=None):
|
||||
self.calendar = Calendar(self.execute)
|
||||
self.stats = Stats(self.execute)
|
||||
self.workout = Workout(self.execute)
|
||||
|
||||
4
extensions.py
Normal file
4
extensions.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from db import DataBase
|
||||
|
||||
|
||||
db = DataBase()
|
||||
17
forms/login.py
Normal file
17
forms/login.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[
|
||||
DataRequired(message="Email is required."),
|
||||
Email(message="Enter a valid email address.")
|
||||
]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired(message="Password is required.")]
|
||||
)
|
||||
submit = SubmitField('Login')
|
||||
34
forms/signup.py
Normal file
34
forms/signup.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, EqualTo, Email
|
||||
|
||||
class SignupForm(FlaskForm):
|
||||
name = StringField(
|
||||
'Name',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100, message="Name must be between 2 and 100 characters.")
|
||||
]
|
||||
)
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Email(message="Enter a valid email address.")
|
||||
]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(min=6, message="Password must be at least 6 characters long.")
|
||||
]
|
||||
)
|
||||
confirm_password = PasswordField(
|
||||
'Confirm Password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message="Passwords must match.")
|
||||
]
|
||||
)
|
||||
submit = SubmitField('Sign Up')
|
||||
@@ -12,3 +12,8 @@ numpy==1.19.5
|
||||
pandas==1.3.1
|
||||
python-dotenv==1.0.1
|
||||
plotly==5.24.1
|
||||
wtforms==3.2.1
|
||||
flask-wtf==1.2.2
|
||||
Flask-Login==0.6.3
|
||||
Flask-Bcrypt==1.0.1
|
||||
email-validator==2.2.0
|
||||
124
routes/auth.py
Normal file
124
routes/auth.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import login_user, login_required, logout_user
|
||||
from forms.login import LoginForm
|
||||
from forms.signup import SignupForm
|
||||
from extensions import db
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
|
||||
class Person:
|
||||
"""
|
||||
Simple Person class compatible with Flask-Login.
|
||||
"""
|
||||
def __init__(self, person_id, name, email, password_hash):
|
||||
self.id = person_id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.password_hash = password_hash
|
||||
|
||||
def get_id(self):
|
||||
"""Required by Flask-Login to get a unique user identifier."""
|
||||
return str(self.id)
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Database helper functions
|
||||
# -------------------------
|
||||
|
||||
def get_person_by_id(person_id):
|
||||
"""
|
||||
Fetch a person record by person_id and return a Person object.
|
||||
"""
|
||||
sql = """
|
||||
SELECT person_id, name, email, password_hash
|
||||
FROM person
|
||||
WHERE person_id = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
row = db.execute(sql, [person_id], one=True)
|
||||
if row:
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
||||
return None
|
||||
|
||||
|
||||
def get_person_by_email(email):
|
||||
"""
|
||||
Fetch a person record by email and return a Person object.
|
||||
"""
|
||||
sql = """
|
||||
SELECT person_id, name, email, password_hash
|
||||
FROM person
|
||||
WHERE email = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
row = db.execute(sql, [email], one=True)
|
||||
if row:
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
||||
return None
|
||||
|
||||
|
||||
def create_person(name, email, password_hash):
|
||||
"""
|
||||
Insert a new person into the database; return the new person's ID.
|
||||
"""
|
||||
sql = """
|
||||
INSERT INTO person (name, email, password_hash)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING person_id AS person_id
|
||||
"""
|
||||
row = db.execute(sql, [name, email, password_hash], commit=True, one=True)
|
||||
return row['person_id']
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Blueprint endpoints
|
||||
# ---------------------
|
||||
|
||||
@auth.route('/signup', methods=['GET', 'POST'])
|
||||
def signup():
|
||||
form = SignupForm()
|
||||
if form.validate_on_submit():
|
||||
hashed_password = generate_password_hash(form.password.data)
|
||||
create_person(
|
||||
name=form.name.data,
|
||||
email=form.email.data,
|
||||
password_hash=hashed_password
|
||||
)
|
||||
flash("Account created successfully. Please log in.", "success")
|
||||
return redirect(url_for('auth.login'))
|
||||
return render_template('auth/signup.html', form=form)
|
||||
|
||||
|
||||
@auth.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
person = get_person_by_email(form.email.data)
|
||||
if person and check_password_hash(person.password_hash, form.password.data):
|
||||
login_user(person)
|
||||
flash("Logged in successfully.", "success")
|
||||
return redirect(url_for('get_calendar', person_id=person.id))
|
||||
else:
|
||||
flash("Invalid email or password.", "danger")
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@auth.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
32
templates/auth/login.html
Normal file
32
templates/auth/login.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
|
||||
<h1 class="text-2xl font-bold text-center mb-4">Login</h1>
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="mb-4">
|
||||
{{ form.email.label(class="block text-sm font-medium text-gray-700") }}
|
||||
{{ form.email(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% for error in form.email.errors %}
|
||||
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="mb-4">
|
||||
{{ form.password.label(class="block text-sm font-medium text-gray-700") }}
|
||||
{{ form.password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% for error in form.password.errors %}
|
||||
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
{{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
templates/auth/signup.html
Normal file
51
templates/auth/signup.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
|
||||
<h1 class="text-2xl font-bold text-center mb-4">Sign Up</h1>
|
||||
<form method="POST" action="{{ url_for('auth.signup') }}" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<!-- Name Field -->
|
||||
<div class="mb-4">
|
||||
{{ form.name.label(class="block text-sm font-medium text-gray-700") }}
|
||||
{{ form.name(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% for error in form.name.errors %}
|
||||
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="mb-4">
|
||||
{{ form.email.label(class="block text-sm font-medium text-gray-700") }}
|
||||
{{ form.email(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% for error in form.email.errors %}
|
||||
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="mb-4">
|
||||
{{ form.password.label(class="block text-sm font-medium text-gray-700") }}
|
||||
{{ form.password(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
|
||||
{% for error in form.password.errors %}
|
||||
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Field -->
|
||||
<div class="mb-4">
|
||||
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700") }}
|
||||
{{ form.confirm_password(class="w-full p-2 border rounded focus:outline-none focus:ring-2
|
||||
focus:ring-blue-500") }}
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
{{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -16,7 +16,7 @@
|
||||
<script src="/static/js/tailwindcss@3.2.4.js"></script>
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
<script src="/static/js/htmx.min.js" defer></script>
|
||||
<script src="/static/js/hyperscript.min.js" defer></script>
|
||||
<script src="/static/js/hyperscript.min.js"></script>
|
||||
<script src="/static/js/sweetalert2@11.js" defer></script>
|
||||
<!-- Mermaid -->
|
||||
<script src="/static/js/mermaid.min.js"></script>
|
||||
@@ -61,7 +61,52 @@
|
||||
<span class="self-center whitespace-nowrap">Workout Tracker</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Show logged-in user's name and Logout link -->
|
||||
<span class="text-slate-700">
|
||||
{{ current_user.name }}
|
||||
</span>
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="text-slate-400 hover:text-slate-500 flex items-center gap-1">
|
||||
<!-- Heroicon: Arrow Left On Rectangle (Logout) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25a2.25 2.25 0 00-2.25-2.25H6
|
||||
a2.25 2.25 0 00-2.25 2.25v13.5
|
||||
A2.25 2.25 0 006 21h7.5
|
||||
a2.25 2.25 0 002.25-2.25V15m-3-3h10.5m0 0l-3-3m3 3l-3 3" />
|
||||
</svg>
|
||||
Logout
|
||||
</a>
|
||||
{% else %}
|
||||
<!-- Show Login and Sign Up links -->
|
||||
<a href="{{ url_for('auth.login') }}"
|
||||
class="text-slate-400 hover:text-slate-500 flex items-center gap-1">
|
||||
<!-- Heroicon: Arrow Right On Rectangle (Login) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25a2.25 2.25 0 00-2.25-2.25H6
|
||||
a2.25 2.25 0 00-2.25 2.25v13.5
|
||||
A2.25 2.25 0 006 21h7.5
|
||||
a2.25 2.25 0 002.25-2.25V15m-3-3h10.5
|
||||
m0 0l-3-3m3 3l-3 3" />
|
||||
</svg>
|
||||
Login
|
||||
</a>
|
||||
<a href="{{ url_for('auth.signup') }}"
|
||||
class="text-slate-400 hover:text-slate-500 flex items-center gap-1">
|
||||
<!-- Heroicon: User Plus (Sign Up) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25a4.5 4.5 0 11-9 0
|
||||
4.5 4.5 0 019 0zm7.5 12c0
|
||||
-3.315-2.685-6-6-6H6c
|
||||
-3.315 0-6 2.685-6 6m17.25-11.25h3m0 0v3m0-3l-3 0" />
|
||||
</svg>
|
||||
Sign Up
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="https://github.com/GabePope/WorkoutTracker"
|
||||
class="ml-6 block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"><span
|
||||
class="sr-only">Workout Tracker on GitHub</span><svg viewBox="0 0 16 16" class="w-6 h-6"
|
||||
|
||||
Reference in New Issue
Block a user