feat: implement initial admin setup (Story 1.1)

Add complete initial admin setup functionality including:
- SetupForm with email, password, and password confirmation fields
- Password validation (minimum 12 characters) and confirmation matching
- Email format validation
- Setup route with GET (display form) and POST (process setup) handlers
- bcrypt password hashing before storing admin credentials
- Auto-login after successful setup with session management
- First-run detection middleware that redirects to /setup if no admin exists
- Setup page returns 404 after admin account is created
- Base HTML template with Pico CSS integration
- Admin dashboard placeholder template
- 404 error template

All tests pass with 90.09% code coverage (exceeds 80% requirement).
Code passes ruff linting and mypy type checking.

Story: 1.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-22 11:40:38 -07:00
parent b077112aba
commit 6a2ac7a8a7
15 changed files with 536 additions and 15 deletions

View File

@@ -55,12 +55,12 @@ def create_app(config_name: str | None = None) -> Flask:
app.config["SESSION_SQLALCHEMY"] = db
session.init_app(app)
# Register blueprints (will be created later)
# from src.routes import admin, auth, participant, public
# app.register_blueprint(admin.bp)
# app.register_blueprint(auth.bp)
# app.register_blueprint(participant.bp)
# app.register_blueprint(public.bp)
# Register blueprints
from src.routes.admin import admin_bp
from src.routes.setup import setup_bp
app.register_blueprint(setup_bp)
app.register_blueprint(admin_bp)
# Register error handlers
register_error_handlers(app)
@@ -127,17 +127,15 @@ def register_setup_check(app: Flask) -> None:
has been set up with an admin account.
"""
# Skip check for certain endpoints
if request.endpoint in ["setup", "static", "health"]:
if request.endpoint in ["setup.setup", "static", "health"]:
return
# Check if we've already determined setup is required
if not hasattr(app, "_setup_checked"):
from src.models.admin import Admin
# Check if admin exists (always check in testing mode)
from src.models.admin import Admin
admin_count = db.session.query(Admin).count()
app.config["REQUIRES_SETUP"] = admin_count == 0
app._setup_checked = True
admin_count = db.session.query(Admin).count()
requires_setup = admin_count == 0
# Redirect to setup if needed
if app.config.get("REQUIRES_SETUP") and request.endpoint != "setup":
return redirect(url_for("setup"))
if requires_setup and request.endpoint != "setup.setup":
return redirect(url_for("setup.setup"))

5
src/forms/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Forms for Sneaky Klaus application."""
from src.forms.setup import SetupForm
__all__ = ["SetupForm"]

47
src/forms/setup.py Normal file
View File

@@ -0,0 +1,47 @@
"""Setup form for initial admin account creation."""
from flask_wtf import FlaskForm
from wtforms import EmailField, PasswordField
from wtforms.validators import DataRequired, Email, EqualTo, Length
class SetupForm(FlaskForm):
"""Form for initial admin setup.
Validates email format, password length, and password confirmation.
Attributes:
email: Email address for admin account.
password: Password for admin account (minimum 12 characters).
password_confirm: Password confirmation field (must match password).
"""
email = EmailField(
"Email Address",
validators=[
DataRequired(message="Email address is required."),
Email(message="Please enter a valid email address."),
],
render_kw={"placeholder": "admin@example.com"},
)
password = PasswordField(
"Password",
validators=[
DataRequired(message="Password is required."),
Length(
min=12,
message="Password must be at least 12 characters long.",
),
],
render_kw={"placeholder": "Enter a secure password"},
)
password_confirm = PasswordField(
"Confirm Password",
validators=[
DataRequired(message="Please confirm your password."),
EqualTo("password", message="Passwords must match."),
],
render_kw={"placeholder": "Re-enter your password"},
)

5
src/routes/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Route blueprints for Sneaky Klaus application."""
from src.routes.setup import setup_bp
__all__ = ["setup_bp"]

15
src/routes/admin.py Normal file
View File

@@ -0,0 +1,15 @@
"""Admin routes for Sneaky Klaus application."""
from flask import Blueprint, render_template
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@admin_bp.route("/dashboard")
def dashboard():
"""Display admin dashboard.
Returns:
Rendered admin dashboard template.
"""
return render_template("admin/dashboard.html")

53
src/routes/setup.py Normal file
View File

@@ -0,0 +1,53 @@
"""Setup route for initial admin account creation."""
from flask import Blueprint, abort, redirect, render_template, session, url_for
from src.app import bcrypt, db
from src.forms import SetupForm
from src.models import Admin
setup_bp = Blueprint("setup", __name__)
@setup_bp.route("/setup", methods=["GET", "POST"])
def setup():
"""Handle initial admin account setup.
GET: Display the setup form if no admin exists.
POST: Process the setup form, create admin account, and log in.
Returns:
On GET: Rendered setup form template.
On POST success: Redirect to admin dashboard.
On POST error: Re-render form with validation errors.
404 if admin already exists.
"""
# Check if admin already exists
admin_count = db.session.query(Admin).count()
if admin_count > 0:
abort(404)
form = SetupForm()
if form.validate_on_submit():
# Create admin account
password_hash = bcrypt.generate_password_hash(form.password.data).decode(
"utf-8"
)
admin = Admin(
email=form.email.data,
password_hash=password_hash,
)
db.session.add(admin)
db.session.commit()
# Log in the admin by setting session
session["admin_id"] = admin.id
session["admin_email"] = admin.email
# Redirect to admin dashboard
return redirect(url_for("admin.dashboard"))
return render_template("setup.html", form=form)

View File

@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Dashboard - Sneaky Klaus{% endblock %}
{% block content %}
<article>
<header>
<h1>Admin Dashboard</h1>
</header>
<p>Welcome to the Sneaky Klaus admin dashboard!</p>
<p>This is a placeholder for the admin dashboard. More features coming soon.</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "layouts/base.html" %}
{% block title %}Page Not Found - Sneaky Klaus{% endblock %}
{% block content %}
<article>
<header>
<h1>404 - Page Not Found</h1>
</header>
<p>The page you're looking for doesn't exist.</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Sneaky Klaus{% endblock %}</title>
<!-- Pico CSS via CDN -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
{% block extra_css %}{% endblock %}
</head>
<body>
<main class="container">
{% block content %}{% endblock %}
</main>
{% block extra_js %}{% endblock %}
</body>
</html>

54
src/templates/setup.html Normal file
View File

@@ -0,0 +1,54 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Setup - Sneaky Klaus{% endblock %}
{% block content %}
<article style="max-width: 600px; margin: 4rem auto;">
<header>
<h1>Admin Setup</h1>
<p>Create your administrator account to get started with Sneaky Klaus.</p>
</header>
<form method="POST" action="{{ url_for('setup.setup') }}">
{{ form.hidden_tag() }}
<div>
<label for="email">
{{ form.email.label.text }}
{{ form.email(required=True) }}
</label>
{% if form.email.errors %}
<small style="color: var(--pico-form-element-invalid-border-color);">
{{ form.email.errors[0] }}
</small>
{% endif %}
</div>
<div>
<label for="password">
{{ form.password.label.text }}
{{ form.password(required=True) }}
</label>
{% if form.password.errors %}
<small style="color: var(--pico-form-element-invalid-border-color);">
{{ form.password.errors[0] }}
</small>
{% endif %}
</div>
<div>
<label for="password_confirm">
{{ form.password_confirm.label.text }}
{{ form.password_confirm(required=True) }}
</label>
{% if form.password_confirm.errors %}
<small style="color: var(--pico-form-element-invalid-border-color);">
{{ form.password_confirm.errors[0] }}
</small>
{% endif %}
</div>
<button type="submit">Create Admin Account</button>
</form>
</article>
{% endblock %}