broke
This commit is contained in:
@ -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')
|
||||
|
@ -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
|
||||
|
||||
|
||||
# ─── Auto‐revoke 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 JSON‐driven plugin loader to wire Flask‐Login.
|
||||
"""
|
||||
login_manager.user_loader(_load_user)
|
||||
lm.user_loader(load_user)
|
||||
|
@ -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
|
||||
)
|
||||
|
19
plugins/auth/templates/auth/invite.html
Normal file
19
plugins/auth/templates/auth/invite.html
Normal 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 %}
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
Reference in New Issue
Block a user