This commit is contained in:
2025-07-09 01:05:45 -05:00
parent 1bbe6e2743
commit d7a610a83b
113 changed files with 1512 additions and 2348 deletions

View File

@ -1,15 +1,62 @@
# plugins/auth/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
from wtforms import StringField, PasswordField, BooleanField, SubmitField, IntegerField
from wtforms.validators import (
DataRequired, Email, Length, EqualTo, Regexp, NumberRange
)
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=25)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=6)])
confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
username = StringField(
'Username',
validators=[DataRequired(), Length(min=3, max=25)]
)
email = StringField(
'Email',
validators=[DataRequired(), Email(), Length(max=120)]
)
invitation_code = StringField(
'Invitation Code',
validators=[DataRequired(),
Length(min=36, max=36,
message="Invitation code must be 36 characters.")]
)
password = PasswordField(
'Password',
validators=[DataRequired(), Length(min=6)]
)
confirm = PasswordField(
'Confirm Password',
validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')]
)
submit = SubmitField('Register')
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
email = StringField(
'Email',
validators=[DataRequired(), Email(), Length(max=120)]
)
password = PasswordField(
'Password',
validators=[DataRequired()]
)
remember_me = BooleanField('Remember me')
submit = SubmitField('Log In')
class InviteForm(FlaskForm):
email = StringField(
'Recipient Email',
validators=[DataRequired(), Email(), Length(max=120)]
)
submit = SubmitField('Send Invite')
class AdjustInvitesForm(FlaskForm):
delta = IntegerField(
'Adjustment',
validators=[DataRequired(), NumberRange()]
)
submit = SubmitField('Adjust Invites')

View File

@ -1,33 +1,42 @@
# File: plugins/auth/models.py
from uuid import uuid4
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from datetime import datetime
from sqlalchemy import event
from sqlalchemy.orm import object_session
from app import db, login_manager
from app.config import Config
class User(db.Model, UserMixin):
__tablename__ = 'users'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.Text, nullable=False)
role = db.Column(db.String(50), default='user')
is_verified = db.Column(db.Boolean, default=False)
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.Text, nullable=False)
role = db.Column(db.String(50), default='user')
is_verified = db.Column(db.Boolean, default=False)
excluded_from_analytics = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
is_banned = db.Column(db.Boolean, nullable=False, default=False)
suspended_until = db.Column(db.DateTime, nullable=True)
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
is_banned = db.Column(db.Boolean, nullable=False, default=False)
suspended_until = db.Column(db.DateTime, nullable=True)
# ← New: how many invites the user may still send
invites_remaining = db.Column(
db.Integer,
nullable=False,
default=Config.INVITES_PER_USER
)
# relationships (existing)
submitted_submissions = db.relationship(
"Submission",
foreign_keys="Submission.user_id",
back_populates="submitter",
lazy=True
)
reviewed_submissions = db.relationship(
"Submission",
foreign_keys="Submission.reviewed_by",
@ -42,18 +51,77 @@ class User(db.Model, UserMixin):
return check_password_hash(self.password_hash, password)
# ─── Flask-Login integration ─────────────────────────────────────────────────
class Invitation(db.Model):
__tablename__ = 'invitation'
def _load_user(user_id):
"""Return a User by ID, or None."""
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid4()))
recipient_email = db.Column(db.String(120), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
sender_ip = db.Column(db.String(45), nullable=False)
receiver_ip = db.Column(db.String(45), nullable=True)
is_used = db.Column(db.Boolean, default=False, nullable=False)
used_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
used_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
creator = db.relationship(
'User',
foreign_keys=[created_by],
backref='invitations_sent'
)
user = db.relationship(
'User',
foreign_keys=[used_by],
backref='invitation_received'
)
def mark_used(self, user, ip_addr):
self.is_used = True
self.user = user
self.used_at = datetime.utcnow()
self.receiver_ip = ip_addr
# ─── Autorevoke invites when a user is banned/suspended/deleted ────────────────
@event.listens_for(User, 'after_update')
def revoke_user_invites(mapper, connection, target):
sess = object_session(target)
if not sess:
return
state = db.inspect(target)
banned_changed = state.attrs.is_banned.history.has_changes()
deleted_changed = state.attrs.is_deleted.history.has_changes()
suspended_changed = state.attrs.suspended_until.history.has_changes()
if (banned_changed and target.is_banned) \
or (deleted_changed and target.is_deleted) \
or (suspended_changed and target.suspended_until and target.suspended_until > datetime.utcnow()):
invs = sess.query(Invitation) \
.filter_by(created_by=target.id, is_active=True, is_used=False) \
.all()
refund = len(invs)
for inv in invs:
inv.is_active = False
target.invites_remaining = target.invites_remaining + refund
sess.add(target)
sess.flush()
# ─── Flask-Login user loader ────────────────────────────────────────────────────
@login_manager.user_loader
def load_user(user_id):
if not str(user_id).isdigit():
return None
return User.query.get(int(user_id))
def register_user_loader(app):
def register_user_loader(lm):
"""
Hook into Flask-Login to register the user_loader.
Called by our JSON-driven loader if declared in plugin.json.
Called by the JSONdriven plugin loader to wire FlaskLogin.
"""
login_manager.user_loader(_load_user)
lm.user_loader(load_user)

View File

@ -1,30 +1,40 @@
# File: plugins/auth/routes.py
# plugins/auth/routes.py
from flask import Blueprint, render_template, redirect, flash, url_for, request
from flask_login import login_user, logout_user, login_required
from .models import User
from .forms import LoginForm, RegistrationForm
from datetime import datetime
from flask import (
Blueprint, render_template, redirect, flash, url_for, request
)
from flask_login import (
login_user, logout_user, login_required, current_user
)
from app import db
from .models import User, Invitation
from .forms import (
LoginForm, RegistrationForm, InviteForm, AdjustInvitesForm
)
bp = Blueprint(
'auth',
__name__,
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/
template_folder='templates',
url_prefix='/auth'
)
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('home'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
user = User.query.filter_by(email=form.email.data.lower()).first()
if user and user.check_password(form.password.data):
login_user(user)
login_user(user, remember=form.remember_me.data)
flash('Logged in successfully.', 'success')
return redirect(url_for('home'))
next_page = request.args.get('next') or url_for('home')
return redirect(next_page)
flash('Invalid email or password.', 'danger')
return render_template('login.html', form=form) # resolves to templates/auth/login.html
return render_template('auth/login.html', form=form)
@bp.route('/logout')
@ -37,12 +47,92 @@ def logout():
@bp.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if current_user.is_authenticated:
return redirect(url_for('home'))
invite_code = request.args.get('invite', '').strip()
form = RegistrationForm(invitation_code=invite_code)
if form.validate_on_submit():
user = User(email=form.email.data)
# Validate invitation
invitation = Invitation.query.filter_by(
code=form.invitation_code.data,
is_active=True,
is_used=False
).first()
if not invitation:
flash('Registration is by invitation only. Provide a valid code.', 'warning')
return render_template('auth/register.html', form=form)
if invitation.recipient_email and \
invitation.recipient_email.lower() != form.email.data.lower():
flash('This invitation is not valid for that email address.', 'warning')
return render_template('auth/register.html', form=form)
# Create the user
user = User(email=form.email.data.lower())
user.set_password(form.password.data)
db.session.add(user)
db.session.flush()
# Mark invitation used
invitation.mark_used(user, request.remote_addr)
db.session.commit()
flash('Account created! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('register.html', form=form) # resolves to templates/auth/register.html
return render_template('auth/register.html', form=form)
@bp.route('/invite', methods=['GET', 'POST'])
@login_required
def send_invite():
form = InviteForm()
# Block banned/suspended users from sending
if current_user.is_banned or current_user.is_deleted or (
current_user.suspended_until and current_user.suspended_until > datetime.utcnow()
):
flash('You are not permitted to send invitations.', 'warning')
return redirect(url_for('home'))
if form.validate_on_submit():
if current_user.invites_remaining < 1:
flash('No invites remaining. Ask an admin to increase your quota.', 'danger')
else:
recipient = form.email.data.strip().lower()
inv = Invitation(
recipient_email=recipient,
created_by=current_user.id,
sender_ip=request.remote_addr
)
db.session.add(inv)
current_user.invites_remaining -= 1
db.session.commit()
flash(f'Invitation sent to {recipient}.', 'success')
return redirect(url_for('auth.send_invite'))
return render_template(
'auth/invite.html',
form=form,
invites=current_user.invites_remaining
)
@bp.route('/admin/adjust_invites/<int:user_id>', methods=['GET', 'POST'])
@login_required
def adjust_invites(user_id):
# assume current_user has admin rights
user = User.query.get_or_404(user_id)
form = AdjustInvitesForm()
if form.validate_on_submit():
user.invites_remaining = max(0, user.invites_remaining + form.delta.data)
db.session.commit()
flash(f"{user.email}'s invite quota adjusted by {form.delta.data}.", 'info')
return redirect(url_for('admin.user_list'))
return render_template(
'auth/adjust_invites.html',
form=form,
user=user
)

View File

@ -0,0 +1,19 @@
{% extends "core/base.html" %}
{% block title %}Send Invitation{% endblock %}
{% block content %}
<div class="container mt-4" style="max-width: 500px;">
<h2>Send Invitation</h2>
<p>You have <strong>{{ invites }}</strong> invites remaining.</p>
<form method="POST" action="{{ url_for('auth.send_invite') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control", placeholder="recipient@example.com") }}
{% for error in form.email.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">{{ form.submit.label.text }}</button>
</form>
</div>
{% endblock %}

View File

@ -1,16 +1,34 @@
{% extends 'core/base.html' %}
{% extends "core/base.html" %}
{% block title %}Log In{% endblock %}
{% block content %}
<h2>Login</h2>
<form method="POST" action="{{ url_for('auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<div class="container mt-4" style="max-width: 400px;">
<h2 class="mb-3">Log In</h2>
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control", placeholder="you@example.com") }}
{% for e in form.email.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Password") }}
{% for e in form.password.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
</form>
</div>
{% endblock %}

View File

@ -1,17 +1,53 @@
{% extends 'core/base.html' %}
{% extends "core/base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<h2>Register</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="container mt-4" style="max-width: 400px;">
<h2 class="mb-3">Register</h2>
<form method="POST" action="{{ url_for('auth.register') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
<label>Email</label>
<input name="email" class="form-control" type="email" required>
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control", placeholder="Username") }}
{% for e in form.username.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label>Password</label>
<input name="password" class="form-control" type="password" required>
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control", placeholder="you@example.com") }}
{% for e in form.email.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<button class="btn btn-primary" type="submit">Register</button>
</form>
<div class="mb-3">
{{ form.invitation_code.label(class="form-label") }}
{{ form.invitation_code(class="form-control", placeholder="Invitation Code") }}
{% for e in form.invitation_code.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Password") }}
{% for e in form.password.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm.label(class="form-label") }}
{{ form.confirm(class="form-control", placeholder="Confirm Password") }}
{% for e in form.confirm.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
</form>
</div>
{% endblock %}