initial login logic
This commit is contained in:
20
.clinerules
Normal file
20
.clinerules
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Project Rules
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Python Flask webapp
|
||||||
|
- Uses Jinja Template for server side rendering
|
||||||
|
- No CSS framework, vanilla CSS with simple and plain design
|
||||||
|
- Sqlite3 for database
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
- Follow Python PEP 8 style guidelines
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Write unit tests where it makes sense
|
||||||
|
- Use Python's unittest library
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Prefer server side rendering over client side rendering
|
||||||
|
- Use client side rendering only to dynamically update data when its necessary
|
||||||
|
- Use dependency injection for testability
|
||||||
|
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
DATABASE_URL=sqlite:///app.db
|
||||||
|
ALTCHA_HMAC_KEY=your-altcha-hmac-key-here
|
||||||
83
altcha_utils.py
Normal file
83
altcha_utils.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class AltchaChallenge:
|
||||||
|
"""Self-hosted Altcha challenge generator and verifier."""
|
||||||
|
|
||||||
|
def __init__(self, hmac_key=None):
|
||||||
|
self.hmac_key = hmac_key or secrets.token_hex(32)
|
||||||
|
|
||||||
|
def create_challenge(self, max_number=100000):
|
||||||
|
"""Generate a new Altcha challenge."""
|
||||||
|
salt = secrets.token_hex(12)
|
||||||
|
secret_number = secrets.randbelow(max_number) + 1
|
||||||
|
|
||||||
|
# Create the challenge string that needs to be solved
|
||||||
|
challenge_data = f"{salt}{secret_number}"
|
||||||
|
challenge = hashlib.sha256(challenge_data.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Create signature for verification
|
||||||
|
signature = hmac.new(
|
||||||
|
self.hmac_key.encode(),
|
||||||
|
challenge.encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"algorithm": "SHA-256",
|
||||||
|
"challenge": challenge,
|
||||||
|
"maxnumber": max_number,
|
||||||
|
"salt": salt,
|
||||||
|
"signature": signature
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_solution(self, payload):
|
||||||
|
"""Verify an Altcha solution payload."""
|
||||||
|
try:
|
||||||
|
# Decode base64 payload
|
||||||
|
decoded = base64.b64decode(payload).decode('utf-8')
|
||||||
|
data = json.loads(decoded)
|
||||||
|
|
||||||
|
algorithm = data.get('algorithm', '')
|
||||||
|
challenge = data.get('challenge', '')
|
||||||
|
number = data.get('number', 0)
|
||||||
|
salt = data.get('salt', '')
|
||||||
|
signature = data.get('signature', '')
|
||||||
|
|
||||||
|
# Verify algorithm
|
||||||
|
if algorithm.upper() != 'SHA-256':
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
self.hmac_key.encode(),
|
||||||
|
challenge.encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(signature, expected_signature):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verify the solution
|
||||||
|
challenge_data = f"{salt}{number}"
|
||||||
|
computed_challenge = hashlib.sha256(challenge_data.encode()).hexdigest()
|
||||||
|
|
||||||
|
return hmac.compare_digest(computed_challenge, challenge)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_altcha_instances = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_altcha(hmac_key):
|
||||||
|
"""Get or create an Altcha instance for the given HMAC key."""
|
||||||
|
if hmac_key not in _altcha_instances:
|
||||||
|
_altcha_instances[hmac_key] = AltchaChallenge(hmac_key)
|
||||||
|
return _altcha_instances[hmac_key]
|
||||||
37
app.py
Normal file
37
app.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from flask_login import LoginManager
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from models import db, User
|
||||||
|
from routes import main
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'main.signup'
|
||||||
|
login_manager.login_message = 'Please sign up or log in to access this page.'
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(main)
|
||||||
|
|
||||||
|
# Create database tables
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = create_app()
|
||||||
|
app.run(debug=True)
|
||||||
12
config.py
Normal file
12
config.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
WTF_CSRF_ENABLED = True
|
||||||
|
ALTCHA_HMAC_KEY = os.environ.get('ALTCHA_HMAC_KEY') or 'dev-altcha-key-change-in-production'
|
||||||
48
forms.py
Normal file
48
forms.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import re
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, PasswordField, HiddenField
|
||||||
|
from wtforms.validators import (
|
||||||
|
DataRequired, Email, EqualTo, Length, ValidationError
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strong_password(form, field):
|
||||||
|
"""Validate password meets strong requirements."""
|
||||||
|
password = field.data
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
errors.append("at least 8 characters")
|
||||||
|
if not re.search(r'[A-Z]', password):
|
||||||
|
errors.append("an uppercase letter")
|
||||||
|
if not re.search(r'[a-z]', password):
|
||||||
|
errors.append("a lowercase letter")
|
||||||
|
if not re.search(r'\d', password):
|
||||||
|
errors.append("a number")
|
||||||
|
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||||
|
errors.append("a special character (!@#$%^&*(),.?\":{}|<>)")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(f"Password must contain {', '.join(errors)}.")
|
||||||
|
|
||||||
|
|
||||||
|
class SignupForm(FlaskForm):
|
||||||
|
email = StringField('Email', validators=[
|
||||||
|
DataRequired(message="Email is required."),
|
||||||
|
Email(message="Please enter a valid email address."),
|
||||||
|
Length(max=255, message="Email must be less than 255 characters.")
|
||||||
|
])
|
||||||
|
|
||||||
|
password = PasswordField('Password', validators=[
|
||||||
|
DataRequired(message="Password is required."),
|
||||||
|
strong_password
|
||||||
|
])
|
||||||
|
|
||||||
|
confirm_password = PasswordField('Confirm Password', validators=[
|
||||||
|
DataRequired(message="Please confirm your password."),
|
||||||
|
EqualTo('password', message="Passwords must match.")
|
||||||
|
])
|
||||||
|
|
||||||
|
altcha = HiddenField('altcha', validators=[
|
||||||
|
DataRequired(message="Please complete the CAPTCHA challenge.")
|
||||||
|
])
|
||||||
32
models.py
Normal file
32
models.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
import bcrypt
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
"""Hash password with bcrypt (includes salt automatically)."""
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
self.password_hash = bcrypt.hashpw(
|
||||||
|
password.encode('utf-8'), salt
|
||||||
|
).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
"""Verify password against stored hash."""
|
||||||
|
return bcrypt.checkpw(
|
||||||
|
password.encode('utf-8'),
|
||||||
|
self.password_hash.encode('utf-8')
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.email}>'
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-WTF==1.2.1
|
||||||
|
bcrypt==4.1.2
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
email-validator==2.1.0
|
||||||
|
altcha==0.1.2
|
||||||
64
routes.py
Normal file
64
routes.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, jsonify, current_app
|
||||||
|
from flask_login import login_user, login_required, current_user
|
||||||
|
|
||||||
|
from models import db, User
|
||||||
|
from forms import SignupForm
|
||||||
|
from altcha_utils import get_altcha
|
||||||
|
|
||||||
|
main = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/altcha/challenge')
|
||||||
|
def altcha_challenge():
|
||||||
|
"""Generate a new Altcha challenge for the signup form."""
|
||||||
|
altcha = get_altcha(current_app.config['ALTCHA_HMAC_KEY'])
|
||||||
|
challenge = altcha.create_challenge()
|
||||||
|
return jsonify(challenge)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/')
|
||||||
|
def index():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
return redirect(url_for('main.signup'))
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/signup', methods=['GET', 'POST'])
|
||||||
|
def signup():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
form = SignupForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
# Verify Altcha solution
|
||||||
|
altcha = get_altcha(current_app.config['ALTCHA_HMAC_KEY'])
|
||||||
|
if not altcha.verify_solution(form.altcha.data):
|
||||||
|
flash('CAPTCHA verification failed. Please try again.', 'error')
|
||||||
|
return render_template('signup.html', form=form)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
existing_user = User.query.filter_by(email=form.email.data.lower()).first()
|
||||||
|
if existing_user:
|
||||||
|
flash('An account with this email already exists.', 'error')
|
||||||
|
return render_template('signup.html', form=form)
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(email=form.email.data.lower())
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Auto-login after signup
|
||||||
|
login_user(user)
|
||||||
|
flash('Account created successfully!', 'success')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
return render_template('signup.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/dashboard')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
return render_template('dashboard.html')
|
||||||
132
static/css/styles.css
Normal file
132
static/css/styles.css
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash messages */
|
||||||
|
.flash {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth container */
|
||||||
|
.auth-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 60px auto;
|
||||||
|
padding: 32px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container h1 {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 60px auto;
|
||||||
|
padding: 32px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-container h1 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-container p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
25
templates/base.html
Normal file
25
templates/base.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Kebuu{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
templates/dashboard.html
Normal file
11
templates/dashboard.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Kebuu{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<h1>Welcome to your Dashboard</h1>
|
||||||
|
<p>Hello, {{ current_user.email }}!</p>
|
||||||
|
<p>You have successfully signed up and logged in.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
62
templates/signup.html
Normal file
62
templates/signup.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sign Up - Kebuu{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script async defer src="https://cdn.altcha.org/js/latest/altcha.min.js" type="module"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-container">
|
||||||
|
<h1>Create Account</h1>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('main.signup') }}" class="auth-form">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
{{ form.email(class="form-input", placeholder="you@example.com", id="email") }}
|
||||||
|
{% if form.email.errors %}
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
{{ form.password(class="form-input", id="password") }}
|
||||||
|
{% if form.password.errors %}
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<small class="hint">Must contain: 8+ characters, uppercase, lowercase, number, special character</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Confirm Password</label>
|
||||||
|
{{ form.confirm_password(class="form-input", id="confirm_password") }}
|
||||||
|
{% if form.confirm_password.errors %}
|
||||||
|
{% for error in form.confirm_password.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<altcha-widget
|
||||||
|
challengeurl="{{ url_for('main.altcha_challenge') }}"
|
||||||
|
style="--altcha-max-width: 100%;"
|
||||||
|
></altcha-widget>
|
||||||
|
{% if form.altcha.errors %}
|
||||||
|
{% for error in form.altcha.errors %}
|
||||||
|
<span class="error">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Sign Up</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user