fix: disable public signup and restore local login flow

This commit is contained in:
2026-04-13 10:06:56 +02:00
parent c4775c623f
commit 9a87ef9562
8 changed files with 70 additions and 17 deletions

View File

@@ -17,6 +17,7 @@ Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem C
- Echte Web-Push-Architektur mit gespeicherten `PushSubscription`s - Echte Web-Push-Architektur mit gespeicherten `PushSubscription`s
- CLI-Kommandos für Archivierung und serverseitig triggerbare Benachrichtigungen - CLI-Kommandos für Archivierung und serverseitig triggerbare Benachrichtigungen
- Cloudron-/Container-tauglicher Start mit `start.sh`, `Dockerfile` und `CloudronManifest.json` - Cloudron-/Container-tauglicher Start mit `start.sh`, `Dockerfile` und `CloudronManifest.json`
- Keine freie Registrierung nach dem ersten Nutzer; weitere Nutzer lassen sich kontrolliert per CLI anlegen
## Projektstruktur ## Projektstruktur
@@ -108,6 +109,17 @@ Demo-Logins:
- `anna@putzliga.local` / `putzliga123` - `anna@putzliga.local` / `putzliga123`
- `ben@putzliga.local` / `putzliga123` - `ben@putzliga.local` / `putzliga123`
## Nutzer anlegen
Freie Registrierung ist deaktiviert, sobald mindestens ein Nutzer existiert.
- Wenn die Datenbank noch leer ist, darf genau der erste Nutzer über `/register` angelegt werden.
- Weitere Nutzer legst du kontrolliert per CLI an:
```bash
flask --app app.py create-user
```
### 5. Entwicklungsserver starten ### 5. Entwicklungsserver starten
```bash ```bash

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import click import click
from .extensions import db from .extensions import db
from .models import BadgeDefinition from .models import BadgeDefinition, User
from .services.monthly import archive_months_missing_up_to_previous from .services.monthly import archive_months_missing_up_to_previous
from .services.notifications import send_due_notifications, send_monthly_winner_notifications from .services.notifications import send_due_notifications, send_monthly_winner_notifications
@@ -54,6 +54,22 @@ def register_cli(app) -> None:
seed_badges() seed_badges()
click.echo("Datenbank und Standard-Badges sind bereit.") click.echo("Datenbank und Standard-Badges sind bereit.")
@app.cli.command("create-user")
@click.option("--name", prompt=True, help="Anzeigename")
@click.option("--email", prompt=True, help="E-Mail")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, help="Passwort")
def create_user_command(name: str, email: str, password: str):
existing = User.query.filter_by(email=email.lower().strip()).first()
if existing:
click.echo("Es existiert bereits ein Nutzer mit dieser E-Mail.")
raise SystemExit(1)
user = User(name=name.strip(), email=email.lower().strip())
user.set_password(password)
db.session.add(user)
db.session.commit()
click.echo(f"Nutzer {user.email} wurde angelegt.")
@app.cli.command("archive-months") @app.cli.command("archive-months")
def archive_months_command(): def archive_months_command():
archive_months_missing_up_to_previous() archive_months_missing_up_to_previous()
@@ -68,4 +84,3 @@ def register_cli(app) -> None:
def notify_monthly_winner_command(): def notify_monthly_winner_command():
result = send_monthly_winner_notifications() result = send_monthly_winner_notifications()
click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}") click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}")

View File

@@ -5,21 +5,23 @@ from flask_wtf.file import FileAllowed, FileField
from wtforms import ( from wtforms import (
BooleanField, BooleanField,
DateField, DateField,
EmailField,
IntegerField, IntegerField,
PasswordField, PasswordField,
SelectField,
StringField, StringField,
SelectField,
SubmitField, SubmitField,
TextAreaField, TextAreaField,
) )
from wtforms.validators import DataRequired, Email, EqualTo, Length, NumberRange, Optional, ValidationError from wtforms.validators import DataRequired, EqualTo, Length, NumberRange, Optional, Regexp, ValidationError
from .models import User from .models import User
EMAIL_LIKE = Regexp(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", message="Bitte gib eine gültige E-Mail-Adresse ein.")
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)]) email = StringField("E-Mail", validators=[DataRequired(), EMAIL_LIKE, Length(max=255)])
password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)]) password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)])
remember_me = BooleanField("Angemeldet bleiben") remember_me = BooleanField("Angemeldet bleiben")
submit = SubmitField("Einloggen") submit = SubmitField("Einloggen")
@@ -27,7 +29,7 @@ class LoginForm(FlaskForm):
class RegisterForm(FlaskForm): class RegisterForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)]) name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)])
email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)]) email = StringField("E-Mail", validators=[DataRequired(), EMAIL_LIKE, Length(max=255)])
password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)]) password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)])
password_confirm = PasswordField( password_confirm = PasswordField(
"Passwort wiederholen", "Passwort wiederholen",
@@ -76,7 +78,7 @@ class TaskForm(FlaskForm):
class SettingsProfileForm(FlaskForm): class SettingsProfileForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)]) name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)])
email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)]) email = StringField("E-Mail", validators=[DataRequired(), EMAIL_LIKE, Length(max=255)])
password = PasswordField("Neues Passwort", validators=[Optional(), Length(min=6, max=128)]) password = PasswordField("Neues Passwort", validators=[Optional(), Length(min=6, max=128)])
avatar = FileField( avatar = FileField(
"Avatar", "Avatar",
@@ -96,4 +98,3 @@ class SettingsProfileForm(FlaskForm):
return return
if User.query.filter_by(email=value).first(): if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.") raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")

View File

@@ -11,6 +11,10 @@ from ..models import User
bp = Blueprint("auth", __name__) bp = Blueprint("auth", __name__)
def registration_open() -> bool:
return User.query.count() == 0
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
@@ -24,13 +28,16 @@ def login():
flash(f"Willkommen zurück, {user.name}.", "success") flash(f"Willkommen zurück, {user.name}.", "success")
return redirect(url_for("tasks.my_tasks")) return redirect(url_for("tasks.my_tasks"))
flash("Die Kombination aus E-Mail und Passwort passt leider nicht.", "error") flash("Die Kombination aus E-Mail und Passwort passt leider nicht.", "error")
return render_template("auth/login.html", form=form) return render_template("auth/login.html", form=form, registration_open=registration_open())
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("tasks.my_tasks")) return redirect(url_for("tasks.my_tasks"))
if not registration_open():
flash("Freie Registrierung ist deaktiviert.", "info")
return redirect(url_for("auth.login"))
form = RegisterForm() form = RegisterForm()
if form.validate_on_submit(): if form.validate_on_submit():
@@ -50,4 +57,3 @@ def logout():
logout_user() logout_user()
flash("Du bist jetzt abgemeldet.", "info") flash("Du bist jetzt abgemeldet.", "info")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 B

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -1,6 +1,5 @@
const CACHE_NAME = "putzliga-shell-v1"; const CACHE_NAME = "putzliga-shell-v2";
const ASSETS = [ const ASSETS = [
"/my-tasks",
"/static/css/style.css", "/static/css/style.css",
"/static/js/app.js", "/static/js/app.js",
"/static/images/logo.svg", "/static/images/logo.svg",
@@ -24,6 +23,24 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return; if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
const isStaticAsset =
url.origin === self.location.origin &&
(
url.pathname.startsWith("/static/") ||
url.pathname === "/manifest.json" ||
url.pathname === "/service-worker.js"
);
if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request));
return;
}
if (!isStaticAsset) {
return;
}
event.respondWith( event.respondWith(
caches.match(event.request).then((cached) => { caches.match(event.request).then((cached) => {
if (cached) return cached; if (cached) return cached;
@@ -32,8 +49,7 @@ self.addEventListener("fetch", (event) => {
const clone = response.clone(); const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response; return response;
}) });
.catch(() => caches.match("/my-tasks"));
}) })
); );
}); });

View File

@@ -41,8 +41,11 @@
{{ form.submit(class_="button button--wide") }} {{ form.submit(class_="button button--wide") }}
</form> </form>
<p class="inline-note">Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.</p> <p class="inline-note">Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.</p>
<p class="inline-note">Noch kein Konto? <a href="{{ url_for('auth.register') }}">Neu registrieren</a></p> {% if registration_open %}
<p class="inline-note">Es gibt noch keinen Nutzer. <a href="{{ url_for('auth.register') }}">Ersten Account anlegen</a></p>
{% else %}
<p class="inline-note">Freie Registrierung ist deaktiviert.</p>
{% endif %}
</section> </section>
</section> </section>
{% endblock %} {% endblock %}