Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cc1a22232 | |||
| f2de165c55 |
Binary file not shown.
+27
-4
@@ -1,30 +1,53 @@
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
import os
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
|
||||
|
||||
def apply_startup_migrations():
|
||||
inspector = inspect(db.engine)
|
||||
|
||||
if "users" in inspector.get_table_names():
|
||||
existing_columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
user_column_migrations = {
|
||||
"two_factor_enabled": "ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT 0",
|
||||
"two_factor_secret": "ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(64)",
|
||||
}
|
||||
|
||||
for column_name, statement in user_column_migrations.items():
|
||||
if column_name not in existing_columns:
|
||||
db.session.execute(text(statement))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db")
|
||||
app.config["SECRET_KEY"] = "dev-secret-key"
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key")
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "login.login"
|
||||
login_manager.login_message_category = "info"
|
||||
|
||||
from .models.user import User
|
||||
from .models.gamestate import GameState
|
||||
from .models.login_attempt import LoginAttempt
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
apply_startup_migrations()
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
from .. import db
|
||||
|
||||
|
||||
class LoginAttempt(db.Model):
|
||||
__tablename__ = "login_attempts"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ip_address = db.Column(db.String(45), nullable=False)
|
||||
username = db.Column(db.String(80), nullable=False)
|
||||
failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||
locked_until = db.Column(db.DateTime)
|
||||
last_failed_at = db.Column(db.DateTime)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("ip_address", "username", name="uq_login_attempt_ip_username"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LoginAttempt {self.ip_address}:{self.username}>"
|
||||
+4
-2
@@ -1,15 +1,17 @@
|
||||
from .. import db
|
||||
from flask_login import UserMixin
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(20), unique=True, nullable=False)
|
||||
password = db.Column(db.String(255), nullable=False) # <- größer!
|
||||
email = db.Column(db.String(120), unique=True, nullable=True)
|
||||
password = db.Column(db.String(255), nullable=False)
|
||||
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
||||
last_login = db.Column(db.DateTime)
|
||||
two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
||||
two_factor_secret = db.Column(db.String(64))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.username}>"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+462
-23
@@ -1,30 +1,291 @@
|
||||
from flask import Blueprint, render_template, request, url_for, redirect, flash
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import login_user, login_required, logout_user
|
||||
from ..models.user import User
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from .. import db
|
||||
from ..models.login_attempt import LoginAttempt
|
||||
from ..models.user import User
|
||||
|
||||
login_bp = Blueprint("login", __name__)
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
MAX_LOGIN_FAILURES = 5
|
||||
LOGIN_LOCK_MINUTES = 15
|
||||
TWO_FACTOR_CODE_DIGITS = 6
|
||||
TWO_FACTOR_TIME_STEP_SECONDS = 30
|
||||
TWO_FACTOR_VALID_WINDOW = 1
|
||||
TWO_FACTOR_ISSUER = "8 Queens Solver"
|
||||
MAX_TWO_FACTOR_ATTEMPTS = 5
|
||||
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,20}$")
|
||||
DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection")
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def get_utc_timestamp(moment=None):
|
||||
if moment is None:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
|
||||
return int(moment.timestamp())
|
||||
|
||||
|
||||
def normalize_username(username):
|
||||
return (username or "").strip()
|
||||
|
||||
|
||||
def validate_username(username):
|
||||
if not USERNAME_PATTERN.fullmatch(username):
|
||||
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
|
||||
return None
|
||||
|
||||
|
||||
def validate_password(password):
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
|
||||
|
||||
if len(password) > MAX_PASSWORD_LENGTH:
|
||||
return f"Password must be at most {MAX_PASSWORD_LENGTH} characters long."
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_request_ip():
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()[:45] or "unknown"
|
||||
|
||||
return (request.remote_addr or "unknown")[:45]
|
||||
|
||||
|
||||
def login_attempt_buckets(username):
|
||||
normalized_username = normalize_username(username).lower() or "__empty__"
|
||||
ip_address = get_request_ip()
|
||||
return [
|
||||
(ip_address, normalized_username),
|
||||
(ip_address, "__ip__"),
|
||||
]
|
||||
|
||||
|
||||
def get_or_create_attempt(ip_address, bucket_key):
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
|
||||
if attempt is None:
|
||||
attempt = LoginAttempt(ip_address=ip_address, username=bucket_key)
|
||||
db.session.add(attempt)
|
||||
|
||||
return attempt
|
||||
|
||||
|
||||
def clear_expired_lock(attempt, now):
|
||||
if attempt.locked_until and attempt.locked_until <= now:
|
||||
attempt.failed_attempts = 0
|
||||
attempt.locked_until = None
|
||||
|
||||
|
||||
def get_active_lock(username):
|
||||
now = utcnow()
|
||||
active_until = None
|
||||
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
|
||||
if not attempt:
|
||||
continue
|
||||
|
||||
clear_expired_lock(attempt, now)
|
||||
|
||||
if attempt.locked_until and (active_until is None or attempt.locked_until > active_until):
|
||||
active_until = attempt.locked_until
|
||||
|
||||
db.session.commit()
|
||||
return active_until
|
||||
|
||||
|
||||
def record_failed_login(username):
|
||||
now = utcnow()
|
||||
locked_until = None
|
||||
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = get_or_create_attempt(ip_address, bucket_key)
|
||||
clear_expired_lock(attempt, now)
|
||||
attempt.failed_attempts = (attempt.failed_attempts or 0) + 1
|
||||
attempt.last_failed_at = now
|
||||
|
||||
if attempt.failed_attempts >= MAX_LOGIN_FAILURES:
|
||||
attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES)
|
||||
locked_until = attempt.locked_until
|
||||
|
||||
db.session.commit()
|
||||
return locked_until
|
||||
|
||||
|
||||
def clear_login_attempts(username):
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
|
||||
if attempt:
|
||||
db.session.delete(attempt)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def lookup_user_by_username(username):
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return User.query.filter(db.func.lower(User.username) == username.lower()).first()
|
||||
|
||||
|
||||
def minutes_until(target_time):
|
||||
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
||||
return max(1, (seconds + 59) // 60)
|
||||
|
||||
|
||||
def normalize_two_factor_secret(secret):
|
||||
return (secret or "").strip().replace(" ", "").upper()
|
||||
|
||||
|
||||
def generate_two_factor_secret():
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def format_two_factor_secret(secret):
|
||||
normalized = normalize_two_factor_secret(secret)
|
||||
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
|
||||
|
||||
|
||||
def clear_pending_two_factor(user=None):
|
||||
session.pop("pending_2fa_user_id", None)
|
||||
session.pop("pending_2fa_attempts", None)
|
||||
|
||||
|
||||
def get_pending_two_factor_setup_secret(user):
|
||||
if session.get("pending_2fa_setup_user_id") != user.id:
|
||||
return None
|
||||
|
||||
secret = normalize_two_factor_secret(session.get("pending_2fa_setup_secret"))
|
||||
return secret or None
|
||||
|
||||
|
||||
def clear_pending_two_factor_setup():
|
||||
session.pop("pending_2fa_setup_user_id", None)
|
||||
session.pop("pending_2fa_setup_secret", None)
|
||||
|
||||
|
||||
def build_totp_uri(user, secret):
|
||||
normalized_secret = normalize_two_factor_secret(secret)
|
||||
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
|
||||
issuer = quote(TWO_FACTOR_ISSUER)
|
||||
return (
|
||||
f"otpauth://totp/{label}"
|
||||
f"?secret={normalized_secret}&issuer={issuer}"
|
||||
f"&algorithm=SHA1&digits={TWO_FACTOR_CODE_DIGITS}&period={TWO_FACTOR_TIME_STEP_SECONDS}"
|
||||
)
|
||||
|
||||
|
||||
def decode_two_factor_secret(secret):
|
||||
normalized_secret = normalize_two_factor_secret(secret)
|
||||
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
|
||||
return base64.b32decode(padded_secret, casefold=True)
|
||||
|
||||
|
||||
def generate_totp_code(secret, for_time=None):
|
||||
timestamp = get_utc_timestamp(for_time)
|
||||
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
|
||||
key = decode_two_factor_secret(secret)
|
||||
message = struct.pack(">Q", counter)
|
||||
digest = hmac.new(key, message, hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
truncated_hash = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
||||
code = truncated_hash % (10 ** TWO_FACTOR_CODE_DIGITS)
|
||||
return f"{code:0{TWO_FACTOR_CODE_DIGITS}d}"
|
||||
|
||||
|
||||
def verify_totp_code(secret, code, at_time=None):
|
||||
normalized_code = (code or "").strip()
|
||||
|
||||
if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS:
|
||||
return False
|
||||
|
||||
current_time = at_time or utcnow()
|
||||
|
||||
try:
|
||||
for window in range(-TWO_FACTOR_VALID_WINDOW, TWO_FACTOR_VALID_WINDOW + 1):
|
||||
window_time = current_time + timedelta(seconds=window * TWO_FACTOR_TIME_STEP_SECONDS)
|
||||
|
||||
if secrets.compare_digest(generate_totp_code(secret, window_time), normalized_code):
|
||||
return True
|
||||
except (TypeError, ValueError, binascii.Error):
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_pending_two_factor_user():
|
||||
user_id = session.get("pending_2fa_user_id")
|
||||
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
return db.session.get(User, user_id)
|
||||
|
||||
|
||||
def render_login_settings(error=None):
|
||||
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
||||
formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None
|
||||
provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None
|
||||
|
||||
return render_template(
|
||||
"login_settings.html",
|
||||
error=error,
|
||||
pending_two_factor_secret=formatted_secret,
|
||||
pending_two_factor_uri=provisioning_uri,
|
||||
)
|
||||
|
||||
|
||||
@login_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
username = normalize_username(request.form.get("username"))
|
||||
password = request.form.get("password") or ""
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
return render_template("sign_up.html", error="Username already taken!")
|
||||
username_error = validate_username(username)
|
||||
password_error = validate_password(password)
|
||||
|
||||
hashed_password = generate_password_hash(password)
|
||||
if username_error:
|
||||
return render_template("sign_up.html", error=username_error, username=username)
|
||||
|
||||
new_user = User(
|
||||
username=username,
|
||||
password=hashed_password
|
||||
)
|
||||
if password_error:
|
||||
return render_template("sign_up.html", error=password_error, username=username)
|
||||
|
||||
if lookup_user_by_username(username):
|
||||
return render_template("sign_up.html", error="Username already taken!", username=username)
|
||||
|
||||
new_user = User(username=username, password=generate_password_hash(password))
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
flash("Account created successfully. You can log in now.", "success")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
return render_template("sign_up.html")
|
||||
@@ -32,23 +293,201 @@ def register():
|
||||
|
||||
@login_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
username = normalize_username(request.form.get("username"))
|
||||
password = request.form.get("password") or ""
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not username or not password:
|
||||
return render_template("login.html", error="Username and password are required.", username=username)
|
||||
|
||||
if user and check_password_hash(user.password, password):
|
||||
login_user(user)
|
||||
return redirect(url_for("main.dashboard"))
|
||||
locked_until = get_active_lock(username)
|
||||
|
||||
return render_template("login.html", error="Invalid username or password")
|
||||
if locked_until and locked_until > utcnow():
|
||||
return render_template(
|
||||
"login.html",
|
||||
error=f"Too many failed login attempts. Please try again in about {minutes_until(locked_until)} minute(s).",
|
||||
username=username,
|
||||
)
|
||||
|
||||
user = lookup_user_by_username(username)
|
||||
password_hash = user.password if user else DUMMY_PASSWORD_HASH
|
||||
password_matches = check_password_hash(password_hash, password)
|
||||
|
||||
if not user or not password_matches:
|
||||
locked_until = record_failed_login(username)
|
||||
error_message = "Invalid username or password."
|
||||
|
||||
if locked_until:
|
||||
error_message = (
|
||||
f"Too many failed login attempts. Please try again in about {minutes_until(locked_until)} minute(s)."
|
||||
)
|
||||
|
||||
return render_template("login.html", error=error_message, username=username)
|
||||
|
||||
clear_login_attempts(username)
|
||||
|
||||
if user.two_factor_enabled:
|
||||
if not user.two_factor_secret:
|
||||
return render_template(
|
||||
"login.html",
|
||||
error="This account requires two-factor authentication, but no authenticator app is configured.",
|
||||
username=username,
|
||||
)
|
||||
|
||||
session["pending_2fa_user_id"] = user.id
|
||||
session["pending_2fa_attempts"] = 0
|
||||
flash("Enter the current 6-digit code from your authenticator app.", "info")
|
||||
return redirect(url_for("login.verify_two_factor"))
|
||||
|
||||
user.last_login = utcnow()
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
clear_pending_two_factor()
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
@login_bp.route('/logout', methods=['POST'])
|
||||
|
||||
@login_bp.route("/login/verify", methods=["GET", "POST"])
|
||||
def verify_two_factor():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
user = get_pending_two_factor_user()
|
||||
|
||||
if user is None:
|
||||
flash("Your verification session expired. Please log in again.", "error")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
code = (request.form.get("code") or "").strip()
|
||||
|
||||
if not code.isdigit() or len(code) != TWO_FACTOR_CODE_DIGITS:
|
||||
return render_template(
|
||||
"two_factor_verify.html",
|
||||
error="Please enter the 6-digit verification code.",
|
||||
)
|
||||
|
||||
attempts = int(session.get("pending_2fa_attempts", 0))
|
||||
|
||||
if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
|
||||
clear_pending_two_factor(user)
|
||||
db.session.commit()
|
||||
flash("Too many invalid 2FA attempts. Please log in again.", "error")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
if not user.two_factor_secret:
|
||||
clear_pending_two_factor()
|
||||
flash("Authenticator app 2FA is not configured for this account.", "error")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
if not verify_totp_code(user.two_factor_secret, code):
|
||||
attempts += 1
|
||||
session["pending_2fa_attempts"] = attempts
|
||||
|
||||
if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
|
||||
clear_pending_two_factor(user)
|
||||
db.session.commit()
|
||||
flash("Too many invalid 2FA attempts. Please log in again.", "error")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
remaining_attempts = MAX_TWO_FACTOR_ATTEMPTS - attempts
|
||||
return render_template(
|
||||
"two_factor_verify.html",
|
||||
error=f"Invalid authenticator code. You have {remaining_attempts} attempt(s) left.",
|
||||
)
|
||||
|
||||
clear_pending_two_factor(user)
|
||||
user.last_login = utcnow()
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
flash("Two-factor authentication successful.", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template("two_factor_verify.html")
|
||||
|
||||
|
||||
@login_bp.route("/login/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def login_settings():
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
|
||||
if action == "start_two_factor_setup":
|
||||
if current_user.two_factor_enabled:
|
||||
return render_login_settings(error="Authenticator app 2FA is already enabled.")
|
||||
|
||||
secret = generate_two_factor_secret()
|
||||
session["pending_2fa_setup_user_id"] = current_user.id
|
||||
session["pending_2fa_setup_secret"] = secret
|
||||
flash("Add the secret to your authenticator app, then confirm it with the current 6-digit code.", "info")
|
||||
return redirect(url_for("login.login_settings"))
|
||||
|
||||
if action == "confirm_two_factor_setup":
|
||||
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
||||
|
||||
if not pending_secret:
|
||||
return render_login_settings(error="Start the authenticator setup first.")
|
||||
|
||||
code = (request.form.get("code") or "").strip()
|
||||
|
||||
if not verify_totp_code(pending_secret, code):
|
||||
return render_login_settings(error="Invalid authenticator code. Please try the current code again.")
|
||||
|
||||
current_user.two_factor_secret = pending_secret
|
||||
current_user.two_factor_enabled = True
|
||||
clear_pending_two_factor_setup()
|
||||
db.session.commit()
|
||||
flash("Authenticator app 2FA enabled.", "success")
|
||||
return redirect(url_for("login.login_settings"))
|
||||
|
||||
if action == "cancel_two_factor_setup":
|
||||
clear_pending_two_factor_setup()
|
||||
flash("Authenticator app setup cancelled.", "info")
|
||||
return redirect(url_for("login.login_settings"))
|
||||
|
||||
if action == "disable_two_factor":
|
||||
current_user.two_factor_enabled = False
|
||||
current_user.two_factor_secret = None
|
||||
clear_pending_two_factor_setup()
|
||||
db.session.commit()
|
||||
flash("Authenticator app 2FA disabled.", "success")
|
||||
return redirect(url_for("login.login_settings"))
|
||||
|
||||
if action == "password":
|
||||
current_password = request.form.get("current_password") or ""
|
||||
new_password = request.form.get("new_password") or ""
|
||||
confirm_password = request.form.get("confirm_password") or ""
|
||||
|
||||
if not check_password_hash(current_user.password, current_password):
|
||||
return render_login_settings(error="Your current password is incorrect.")
|
||||
|
||||
password_error = validate_password(new_password)
|
||||
|
||||
if password_error:
|
||||
return render_login_settings(error=password_error)
|
||||
|
||||
if new_password != confirm_password:
|
||||
return render_login_settings(error="The new passwords do not match.")
|
||||
|
||||
current_user.password = generate_password_hash(new_password)
|
||||
db.session.commit()
|
||||
flash("Password updated successfully.", "success")
|
||||
return redirect(url_for("login.login_settings"))
|
||||
|
||||
return render_login_settings(error="Unknown settings action.")
|
||||
|
||||
return render_login_settings()
|
||||
|
||||
|
||||
@login_bp.route("/logout", methods=["POST"])
|
||||
@login_required
|
||||
def logout():
|
||||
clear_pending_two_factor()
|
||||
clear_pending_two_factor_setup()
|
||||
logout_user()
|
||||
flash('You have been logged out', 'info')
|
||||
return redirect(url_for('login.login'))
|
||||
flash("You have been logged out.", "info")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
+1
-120
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
@@ -28,10 +27,6 @@ def parse_favorite_flag(raw_value):
|
||||
return str(raw_value).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def get_position_label(row, col, board_size):
|
||||
return f"{chr(65 + col)}{board_size - row}"
|
||||
|
||||
|
||||
def solve_queens(board_size):
|
||||
queen_positions = [-1] * board_size
|
||||
queen_solutions = []
|
||||
@@ -69,105 +64,6 @@ def solve_rooks(board_size):
|
||||
return rook_positions
|
||||
|
||||
|
||||
def build_queens_trace(board_size):
|
||||
queen_positions = [-1] * board_size
|
||||
steps = []
|
||||
|
||||
def is_queen_position_safe(row, col):
|
||||
for previous_row in range(row):
|
||||
previous_col = queen_positions[previous_row]
|
||||
|
||||
if previous_col == col:
|
||||
return False
|
||||
|
||||
if abs(previous_col - col) == abs(previous_row - row):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def place_queen(row):
|
||||
if row == board_size:
|
||||
steps.append({
|
||||
"type": "solution",
|
||||
"row": None,
|
||||
"col": None,
|
||||
"board": queen_positions[:],
|
||||
"message": f"Loesung gefunden. Alle {board_size} Damen stehen ohne Konflikte."
|
||||
})
|
||||
return True
|
||||
|
||||
for col in range(board_size):
|
||||
if not is_queen_position_safe(row, col):
|
||||
continue
|
||||
|
||||
queen_positions[row] = col
|
||||
position = get_position_label(row, col, board_size)
|
||||
steps.append({
|
||||
"type": "place",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"board": queen_positions[:],
|
||||
"message": f"Dame auf {position} gesetzt."
|
||||
})
|
||||
|
||||
if place_queen(row + 1):
|
||||
return True
|
||||
|
||||
queen_positions[row] = -1
|
||||
steps.append({
|
||||
"type": "remove",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"board": queen_positions[:],
|
||||
"message": f"Backtracking: Dame von {position} entfernt."
|
||||
})
|
||||
|
||||
return False
|
||||
|
||||
solved = place_queen(0)
|
||||
|
||||
return {
|
||||
"mode": "queens",
|
||||
"board_size": board_size,
|
||||
"initial_board": [-1] * board_size,
|
||||
"steps": steps,
|
||||
"solved": solved
|
||||
}
|
||||
|
||||
|
||||
def build_rooks_trace(board_size):
|
||||
rook_positions = solve_rooks(board_size)
|
||||
board = [-1] * board_size
|
||||
steps = []
|
||||
|
||||
for row, col in enumerate(rook_positions):
|
||||
board[row] = col
|
||||
position = get_position_label(row, col, board_size)
|
||||
steps.append({
|
||||
"type": "place",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"board": board[:],
|
||||
"message": f"Turm auf {position} gesetzt."
|
||||
})
|
||||
|
||||
steps.append({
|
||||
"type": "solution",
|
||||
"row": None,
|
||||
"col": None,
|
||||
"board": board[:],
|
||||
"message": f"Loesung gefunden. Alle {board_size} Tuerme stehen ohne Konflikte."
|
||||
})
|
||||
|
||||
return {
|
||||
"mode": "rooks",
|
||||
"board_size": board_size,
|
||||
"initial_board": [-1] * board_size,
|
||||
"steps": steps,
|
||||
"solved": True
|
||||
}
|
||||
|
||||
|
||||
def serialize_game_state(game_state):
|
||||
board = json.loads(game_state.board)
|
||||
|
||||
@@ -253,24 +149,12 @@ def solve_queens_route():
|
||||
return jsonify(solve_queens(board_size))
|
||||
|
||||
|
||||
@main_bp.route("/solve_trace")
|
||||
def solve_queens_trace_route():
|
||||
board_size = parse_board_size(request.args.get("size"))
|
||||
return jsonify(build_queens_trace(board_size))
|
||||
|
||||
|
||||
@main_bp.route("/solve_rooks")
|
||||
def solve_rooks_route():
|
||||
board_size = parse_board_size(request.args.get("size"))
|
||||
return jsonify(solve_rooks(board_size))
|
||||
|
||||
|
||||
@main_bp.route("/solve_rooks_trace")
|
||||
def solve_rooks_trace_route():
|
||||
board_size = parse_board_size(request.args.get("size"))
|
||||
return jsonify(build_rooks_trace(board_size))
|
||||
|
||||
|
||||
@main_bp.route("/save", methods=["POST"])
|
||||
@login_required
|
||||
def save_game():
|
||||
@@ -282,7 +166,6 @@ def save_game():
|
||||
save_name = (data.get("saveName") or "").strip() or f"{piece_mode.title()} Save"
|
||||
save_note = (data.get("saveNote") or "").strip()
|
||||
is_favorite = bool(data.get("isFavorite"))
|
||||
timestamp = datetime.utcnow()
|
||||
|
||||
game_state = GameState(
|
||||
user_id=current_user.id,
|
||||
@@ -291,9 +174,7 @@ def save_game():
|
||||
board_size=board_size,
|
||||
save_name=save_name,
|
||||
save_note=save_note,
|
||||
is_favorite=is_favorite,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
is_favorite=is_favorite
|
||||
)
|
||||
|
||||
db.session.add(game_state)
|
||||
|
||||
+67
-131
@@ -24,6 +24,29 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-logout-form {
|
||||
margin: 0 0 0 15px;
|
||||
}
|
||||
|
||||
.nav-link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 📦 Layout */
|
||||
.container {
|
||||
max-width: 900px;
|
||||
@@ -40,14 +63,35 @@ body {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.game-card {
|
||||
text-align: center;
|
||||
.flash-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
#statusMessage {
|
||||
min-height: 24px;
|
||||
margin: 0 0 8px;
|
||||
font-weight: bold;
|
||||
.flash-message {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.flash-message.info {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board {
|
||||
@@ -97,21 +141,6 @@ body {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cell.solver-place {
|
||||
box-shadow: inset 0 0 0 4px #22c55e;
|
||||
animation: solverPulse 0.35s ease;
|
||||
}
|
||||
|
||||
.cell.solver-remove {
|
||||
box-shadow: inset 0 0 0 4px #f59e0b;
|
||||
animation: solverPulse 0.35s ease;
|
||||
}
|
||||
|
||||
.cell.solver-solution {
|
||||
box-shadow: inset 0 0 0 4px #2563eb;
|
||||
animation: solverPulse 0.35s ease;
|
||||
}
|
||||
|
||||
.white {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
@@ -128,10 +157,6 @@ body {
|
||||
/* 🎛️ Buttons */
|
||||
.controls {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -148,12 +173,6 @@ body {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn:disabled:hover {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: #64748b;
|
||||
}
|
||||
@@ -194,79 +213,6 @@ body {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.solver-panel {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dbe4ee;
|
||||
background: #f8fafc;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.solver-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.solver-panel h3 {
|
||||
margin: 0 0 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.solver-message {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.solver-step-info {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.solver-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.solver-controls .btn,
|
||||
.controls .btn {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.solver-speed {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.solver-speed input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.solver-speed-label {
|
||||
font-weight: bold;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.solver-speed-value {
|
||||
min-width: 62px;
|
||||
text-align: right;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.board-size-info {
|
||||
margin: 0 0 10px;
|
||||
color: #475569;
|
||||
@@ -279,14 +225,10 @@ body {
|
||||
width: min(320px, 85vw);
|
||||
height: 100vh;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
box-shadow: -10px 0 24px rgba(15, 23, 42, 0.18);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
@@ -413,6 +355,21 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inline-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-actions .btn {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
@@ -437,20 +394,6 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@keyframes solverPulse {
|
||||
0% {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
@@ -460,14 +403,7 @@ body {
|
||||
padding: 15px 18px;
|
||||
}
|
||||
|
||||
.solver-panel-header,
|
||||
.solver-speed {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.solver-step-info,
|
||||
.solver-speed-value {
|
||||
justify-self: start;
|
||||
.nav-logout-form {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+210
-64
@@ -1,7 +1,12 @@
|
||||
:root {
|
||||
--primary-bg-color: #f4f6f8;
|
||||
--secondary-bg-color: white;
|
||||
--nav-bg-color: #333;
|
||||
--nav-bg-color: #172033;
|
||||
--panel-bg-color: rgba(255, 255, 255, 0.18);
|
||||
--panel-border-color: rgba(255, 255, 255, 0.24);
|
||||
--text-color: #172033;
|
||||
--muted-text-color: #475569;
|
||||
--success-color: #15803d;
|
||||
--info-color: #1d4ed8;
|
||||
--error-color: #b91c1c;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -18,24 +23,30 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-color);
|
||||
font-family: "Geist Sans", system-ui, sans-serif;
|
||||
|
||||
background: linear-gradient(135deg, #c6dff4, #7c8384);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.45), transparent 32%),
|
||||
linear-gradient(135deg, #d2ecff 0%, #b8c6db 48%, #7f8ea3 100%);
|
||||
}
|
||||
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
background: var(--nav-bg-color);
|
||||
min-height: 68px;
|
||||
padding: 15px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
background: var(--nav-bg-color);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -46,28 +57,68 @@ nav a {
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: #4facfe;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* Center login section */
|
||||
main {
|
||||
min-height: calc(100vh - 60px);
|
||||
/* full screen minus navbar */
|
||||
.nav-form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: calc(100vh - 68px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
main h1 {
|
||||
margin-bottom: 20px;
|
||||
.settings-shell {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
main {
|
||||
.auth-panel {
|
||||
width: min(420px, 100%);
|
||||
padding: 34px;
|
||||
border-radius: 20px;
|
||||
background: var(--panel-bg-color);
|
||||
border: 1px solid var(--panel-border-color);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
|
||||
animation: fadeIn 0.8s ease;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
width: min(900px, 100%);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -80,72 +131,167 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Form box */
|
||||
form {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 35px;
|
||||
border-radius: 12px;
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
h1,
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted-text-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
label {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
form {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 35px;
|
||||
border-radius: 12px;
|
||||
width: 320px;
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.25);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.35);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #e9eff5, #daebf0);
|
||||
color: rgb(0, 0, 0);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||
color: #172033;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
/* Error message */
|
||||
.error {
|
||||
color: red;
|
||||
margin-bottom: 15px;
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* button anim */
|
||||
.button-row button {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: linear-gradient(135deg, #e2e8f0, #cbd5e1);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
color: var(--error-color);
|
||||
background: rgba(254, 226, 226, 0.92);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
color: var(--success-color);
|
||||
background: rgba(220, 252, 231, 0.92);
|
||||
}
|
||||
|
||||
.message.info {
|
||||
color: var(--info-color);
|
||||
background: rgba(219, 234, 254, 0.92);
|
||||
}
|
||||
|
||||
.qr-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0 6px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 220px;
|
||||
min-height: 220px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
color: var(--muted-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-code img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
nav {
|
||||
padding: 16px 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
padding: 20px 12px 28px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-408
@@ -2,34 +2,17 @@ const DEFAULT_BOARD_SIZE = 8;
|
||||
const MIN_BOARD_SIZE = 4;
|
||||
const MAX_BOARD_SIZE = 13;
|
||||
const EMPTY_CELL = -1;
|
||||
const DEFAULT_SOLVER_MESSAGE = "Bereite einen Ablauf vor und spiele ihn Schritt fuer Schritt ab.";
|
||||
|
||||
let mode = "queens";
|
||||
let boardSize = DEFAULT_BOARD_SIZE;
|
||||
let pendingBoardSize = DEFAULT_BOARD_SIZE;
|
||||
let board = createEmptyBoard(boardSize);
|
||||
|
||||
let solverSteps = [];
|
||||
let solverStepIndex = -1;
|
||||
let solverTimerId = null;
|
||||
let solverSpeedMs = 320;
|
||||
let solverLoading = false;
|
||||
let solverRequestId = 0;
|
||||
let currentSolverHighlight = null;
|
||||
|
||||
const boardDiv = document.getElementById("board");
|
||||
const statusMessage = document.getElementById("statusMessage");
|
||||
const queenList = document.getElementById("queenList");
|
||||
const btnQueens = document.getElementById("btnQueens");
|
||||
const btnRooks = document.getElementById("btnRooks");
|
||||
const btnPrepareSolve = document.getElementById("btnPrepareSolve");
|
||||
const btnInstantSolve = document.getElementById("btnInstantSolve");
|
||||
const btnNextStep = document.getElementById("btnNextStep");
|
||||
const btnPlayPause = document.getElementById("btnPlayPause");
|
||||
const solverMessage = document.getElementById("solverMessage");
|
||||
const solverStepInfo = document.getElementById("solverStepInfo");
|
||||
const solverSpeedInput = document.getElementById("solverSpeedInput");
|
||||
const solverSpeedValue = document.getElementById("solverSpeedValue");
|
||||
const settingsSidebar = document.getElementById("settingsSidebar");
|
||||
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
|
||||
const saveSidebar = document.getElementById("saveSidebar");
|
||||
@@ -80,21 +63,6 @@ function setStatus(message = "", color = "") {
|
||||
statusMessage.style.color = color;
|
||||
}
|
||||
|
||||
function setSolverMessage(message = DEFAULT_SOLVER_MESSAGE) {
|
||||
solverMessage.textContent = message;
|
||||
}
|
||||
|
||||
function updateSolverSpeed(rawValue) {
|
||||
solverSpeedMs = Number(rawValue);
|
||||
solverSpeedInput.value = String(solverSpeedMs);
|
||||
solverSpeedValue.textContent = `${solverSpeedMs} ms`;
|
||||
|
||||
if (isSolverPlaying()) {
|
||||
stopSolverPlayback(false);
|
||||
scheduleNextSolverPlaybackStep();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBoardLayout() {
|
||||
const cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize)));
|
||||
|
||||
@@ -102,85 +70,10 @@ function updateBoardLayout() {
|
||||
boardDiv.style.setProperty("--cell-size", `${cellSize}px`);
|
||||
}
|
||||
|
||||
function isSolverPlaying() {
|
||||
return solverTimerId !== null;
|
||||
}
|
||||
|
||||
function hasPreparedSolverTrace() {
|
||||
return solverSteps.length > 0;
|
||||
}
|
||||
|
||||
function hasCompletedSolverTrace() {
|
||||
return hasPreparedSolverTrace() && solverStepIndex >= solverSteps.length - 1;
|
||||
}
|
||||
|
||||
function stopSolverPlayback(refreshControls = true) {
|
||||
if (solverTimerId !== null) {
|
||||
window.clearTimeout(solverTimerId);
|
||||
solverTimerId = null;
|
||||
}
|
||||
|
||||
if (refreshControls) {
|
||||
updateSolverControls();
|
||||
}
|
||||
}
|
||||
|
||||
function updateSolverControls() {
|
||||
const currentStep = Math.max(0, solverStepIndex + 1);
|
||||
|
||||
btnPrepareSolve.disabled = solverLoading;
|
||||
btnInstantSolve.disabled = solverLoading;
|
||||
btnNextStep.disabled = solverLoading || hasCompletedSolverTrace();
|
||||
btnPlayPause.disabled = solverLoading;
|
||||
|
||||
btnPrepareSolve.textContent = solverLoading ? "Preparing..." : "Solve Step-by-Step";
|
||||
|
||||
if (isSolverPlaying()) {
|
||||
btnPlayPause.textContent = "Pause";
|
||||
} else if (hasCompletedSolverTrace()) {
|
||||
btnPlayPause.textContent = "Replay Animation";
|
||||
} else {
|
||||
btnPlayPause.textContent = "Play Animation";
|
||||
}
|
||||
|
||||
solverStepInfo.textContent = `Schritt ${currentStep} / ${solverSteps.length}`;
|
||||
}
|
||||
|
||||
function discardSolverTrace(message = DEFAULT_SOLVER_MESSAGE) {
|
||||
solverRequestId += 1;
|
||||
solverLoading = false;
|
||||
stopSolverPlayback(false);
|
||||
|
||||
solverSteps = [];
|
||||
solverStepIndex = -1;
|
||||
currentSolverHighlight = null;
|
||||
|
||||
setSolverMessage(message);
|
||||
updateSolverControls();
|
||||
}
|
||||
|
||||
function rewindSolverTrace() {
|
||||
stopSolverPlayback(false);
|
||||
solverStepIndex = -1;
|
||||
currentSolverHighlight = null;
|
||||
board = createEmptyBoard(boardSize);
|
||||
setSolverMessage("Ablauf zurueckgesetzt. Starte die Animation erneut oder gehe weiter mit Next Step.");
|
||||
drawBoard();
|
||||
updateSolverControls();
|
||||
}
|
||||
|
||||
function setMode(newMode) {
|
||||
if (mode === newMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
mode = newMode;
|
||||
resetBoard();
|
||||
updateModeButtons();
|
||||
resetBoard({
|
||||
statusMessageText: `${getPieceLabel(mode)}-Modus aktiviert.`,
|
||||
statusColor: "#2563eb",
|
||||
solverMessageText: `${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`
|
||||
});
|
||||
}
|
||||
|
||||
function closeAllPanels() {
|
||||
@@ -206,6 +99,8 @@ function getConflicts() {
|
||||
const colB = board[rowB];
|
||||
const sameColumn = colA === colB;
|
||||
const sameDiagonal = Math.abs(colA - colB) === Math.abs(rowA - rowB);
|
||||
|
||||
// Rooks only attack vertically, queens also attack diagonally.
|
||||
const hasConflict = mode === "rooks" ? sameColumn : sameColumn || sameDiagonal;
|
||||
|
||||
if (hasConflict) {
|
||||
@@ -221,16 +116,16 @@ function checkSolution() {
|
||||
const allPlaced = board.every((col) => col !== EMPTY_CELL);
|
||||
|
||||
if (!allPlaced) {
|
||||
setStatus("Noch nicht alle Figuren platziert.", "#d97706");
|
||||
setStatus("❗ Noch nicht alle Figuren platziert.", "orange");
|
||||
return;
|
||||
}
|
||||
|
||||
if (getConflicts().length > 0) {
|
||||
setStatus("Es gibt Konflikte auf dem Brett.", "red");
|
||||
setStatus("❌ Es gibt Konflikte!", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Perfekt geloest.", "green");
|
||||
setStatus("🎉 Perfekt geloest!", "green");
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
@@ -239,7 +134,6 @@ function drawBoard() {
|
||||
updateBoardLayout();
|
||||
|
||||
const conflicts = getConflicts();
|
||||
const highlight = currentSolverHighlight;
|
||||
|
||||
for (let row = -1; row < boardSize; row++) {
|
||||
for (let col = -1; col < boardSize; col++) {
|
||||
@@ -256,19 +150,10 @@ function drawBoard() {
|
||||
} else {
|
||||
cell.classList.add("cell", (row + col) % 2 === 0 ? "white" : "black");
|
||||
|
||||
if (highlight && highlight.row === row && highlight.col === col) {
|
||||
cell.classList.add(
|
||||
highlight.type === "remove"
|
||||
? "solver-remove"
|
||||
: highlight.type === "solution"
|
||||
? "solver-solution"
|
||||
: "solver-place"
|
||||
);
|
||||
}
|
||||
|
||||
if (board[row] === col) {
|
||||
cell.textContent = mode === "queens" ? "♛" : "♜";
|
||||
|
||||
// Highlight pieces that are part of at least one conflict pair.
|
||||
if (conflicts.some(([conflictRow, conflictCol]) => conflictRow === row && conflictCol === col)) {
|
||||
cell.classList.add("invalid");
|
||||
}
|
||||
@@ -285,18 +170,6 @@ function drawBoard() {
|
||||
}
|
||||
|
||||
function placeQueen(row, col) {
|
||||
if (solverLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPreparedSolverTrace() || isSolverPlaying()) {
|
||||
discardSolverTrace("Solver-Ablauf verworfen, weil das Brett manuell geaendert wurde.");
|
||||
setStatus("Solver-Ablauf verworfen. Bereite ihn bei Bedarf neu vor.", "#d97706");
|
||||
} else {
|
||||
setStatus();
|
||||
}
|
||||
|
||||
currentSolverHighlight = null;
|
||||
board[row] = board[row] === col ? EMPTY_CELL : col;
|
||||
drawBoard();
|
||||
}
|
||||
@@ -309,9 +182,7 @@ function previewBoardSize(size) {
|
||||
|
||||
function applyBoardSize() {
|
||||
boardSize = pendingBoardSize;
|
||||
discardSolverTrace(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`);
|
||||
board = createEmptyBoard(boardSize);
|
||||
currentSolverHighlight = null;
|
||||
updateBoardSizeDisplays(boardSize);
|
||||
setStatus(`Brettgroesse auf ${boardSize} x ${boardSize} gesetzt.`, "#2563eb");
|
||||
drawBoard();
|
||||
@@ -487,284 +358,30 @@ async function openSavePointsPanel() {
|
||||
}
|
||||
|
||||
function applyLoadedGame(data) {
|
||||
discardSolverTrace(`${getPieceLabel(data.mode || "queens")}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`);
|
||||
|
||||
board = Array.isArray(data.board) ? data.board.slice() : createEmptyBoard(DEFAULT_BOARD_SIZE);
|
||||
board = data.board;
|
||||
boardSize = Array.isArray(data.board) ? clampBoardSize(data.board.length) : DEFAULT_BOARD_SIZE;
|
||||
pendingBoardSize = boardSize;
|
||||
mode = data.mode === "rooks" ? "rooks" : "queens";
|
||||
currentSolverHighlight = null;
|
||||
mode = data.mode;
|
||||
|
||||
updateModeButtons();
|
||||
updateBoardSizeDisplays(boardSize);
|
||||
drawBoard();
|
||||
}
|
||||
|
||||
function applySolverStep(step) {
|
||||
board = Array.isArray(step.board) ? step.board.slice() : createEmptyBoard(boardSize);
|
||||
|
||||
if (Number.isInteger(step.row) && Number.isInteger(step.col)) {
|
||||
currentSolverHighlight = {
|
||||
row: step.row,
|
||||
col: step.col,
|
||||
type: step.type
|
||||
};
|
||||
} else if (step.type === "solution" && solverStepIndex > 0) {
|
||||
const previousStep = solverSteps[solverStepIndex - 1];
|
||||
currentSolverHighlight = Number.isInteger(previousStep.row) && Number.isInteger(previousStep.col)
|
||||
? {
|
||||
row: previousStep.row,
|
||||
col: previousStep.col,
|
||||
type: "solution"
|
||||
}
|
||||
: null;
|
||||
} else {
|
||||
currentSolverHighlight = null;
|
||||
}
|
||||
|
||||
setSolverMessage(step.message || DEFAULT_SOLVER_MESSAGE);
|
||||
drawBoard();
|
||||
updateSolverControls();
|
||||
}
|
||||
|
||||
function advanceSolverStep() {
|
||||
if (!hasPreparedSolverTrace() || hasCompletedSolverTrace()) {
|
||||
stopSolverPlayback(false);
|
||||
updateSolverControls();
|
||||
return false;
|
||||
}
|
||||
|
||||
solverStepIndex += 1;
|
||||
applySolverStep(solverSteps[solverStepIndex]);
|
||||
|
||||
if (hasCompletedSolverTrace()) {
|
||||
stopSolverPlayback(false);
|
||||
setStatus(`Loesung fuer ${boardSize} x ${boardSize} Schritt fuer Schritt aufgebaut.`, "green");
|
||||
updateSolverControls();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleNextSolverPlaybackStep() {
|
||||
stopSolverPlayback(false);
|
||||
|
||||
solverTimerId = window.setTimeout(() => {
|
||||
solverTimerId = null;
|
||||
|
||||
const advanced = advanceSolverStep();
|
||||
|
||||
if (advanced && !hasCompletedSolverTrace()) {
|
||||
scheduleNextSolverPlaybackStep();
|
||||
return;
|
||||
}
|
||||
|
||||
updateSolverControls();
|
||||
}, solverSpeedMs);
|
||||
|
||||
updateSolverControls();
|
||||
}
|
||||
|
||||
function startSolverPlayback(stepImmediately = true) {
|
||||
if (!hasPreparedSolverTrace() || hasCompletedSolverTrace()) {
|
||||
updateSolverControls();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepImmediately) {
|
||||
const advanced = advanceSolverStep();
|
||||
|
||||
if (!advanced || hasCompletedSolverTrace()) {
|
||||
updateSolverControls();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextSolverPlaybackStep();
|
||||
}
|
||||
|
||||
async function prepareSolutionTrace() {
|
||||
const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace";
|
||||
const requestId = ++solverRequestId;
|
||||
|
||||
solverLoading = true;
|
||||
stopSolverPlayback(false);
|
||||
solverSteps = [];
|
||||
solverStepIndex = -1;
|
||||
currentSolverHighlight = null;
|
||||
setSolverMessage(`Berechne ${getPieceLabel(mode)}-Ablauf fuer ${boardSize} x ${boardSize}...`);
|
||||
setStatus(`Solver berechnet die Schritte fuer ${boardSize} x ${boardSize}.`, "#2563eb");
|
||||
updateSolverControls();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${endpoint}?size=${boardSize}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("trace request failed");
|
||||
}
|
||||
|
||||
const trace = await response.json();
|
||||
|
||||
if (requestId !== solverRequestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
solverSteps = Array.isArray(trace.steps) ? trace.steps : [];
|
||||
solverStepIndex = -1;
|
||||
board = Array.isArray(trace.initial_board) ? trace.initial_board.slice() : createEmptyBoard(boardSize);
|
||||
currentSolverHighlight = null;
|
||||
|
||||
if (!trace.solved || solverSteps.length === 0) {
|
||||
setSolverMessage("Keine Loesung gefunden.");
|
||||
setStatus("Keine Loesung fuer dieses Brett gefunden.", "red");
|
||||
drawBoard();
|
||||
return false;
|
||||
}
|
||||
|
||||
setSolverMessage(
|
||||
mode === "queens"
|
||||
? `Ablauf bereit mit ${solverSteps.length} Schritten. Nutze Next Step oder Play Animation, um das Backtracking zu verfolgen.`
|
||||
: `Ablauf bereit mit ${solverSteps.length} Schritten. Nutze Next Step oder starte die Animation.`
|
||||
);
|
||||
setStatus("Solver-Ablauf bereit. Nutze Next Step oder Play Animation.", "#2563eb");
|
||||
drawBoard();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestId !== solverRequestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setSolverMessage("Der Solver-Ablauf konnte nicht geladen werden.");
|
||||
setStatus("Der Solver-Ablauf konnte nicht geladen werden.", "red");
|
||||
return false;
|
||||
} finally {
|
||||
if (requestId === solverRequestId) {
|
||||
solverLoading = false;
|
||||
updateSolverControls();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function nextSolverStep() {
|
||||
if (solverLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasPreparedSolverTrace()) {
|
||||
const prepared = await prepareSolutionTrace();
|
||||
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSolverPlaying()) {
|
||||
stopSolverPlayback(false);
|
||||
}
|
||||
|
||||
if (!advanceSolverStep()) {
|
||||
setStatus("Der Solver-Ablauf ist bereits abgeschlossen.", "#2563eb");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSolverPlayback() {
|
||||
if (solverLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSolverPlaying()) {
|
||||
stopSolverPlayback();
|
||||
setStatus("Solver-Animation pausiert.", "#2563eb");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasPreparedSolverTrace()) {
|
||||
const prepared = await prepareSolutionTrace();
|
||||
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCompletedSolverTrace()) {
|
||||
rewindSolverTrace();
|
||||
}
|
||||
|
||||
setStatus("Solver-Animation laeuft.", "#2563eb");
|
||||
startSolverPlayback(true);
|
||||
}
|
||||
|
||||
async function solve() {
|
||||
await prepareSolutionTrace();
|
||||
}
|
||||
|
||||
async function instantSolve() {
|
||||
if (solverLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve";
|
||||
const requestId = ++solverRequestId;
|
||||
const response = await fetch(`${endpoint}?size=${boardSize}`);
|
||||
const solution = await response.json();
|
||||
|
||||
solverLoading = true;
|
||||
stopSolverPlayback(false);
|
||||
solverSteps = [];
|
||||
solverStepIndex = -1;
|
||||
currentSolverHighlight = null;
|
||||
setSolverMessage(`Lade sofortige ${getPieceLabel(mode)}-Loesung fuer ${boardSize} x ${boardSize}...`);
|
||||
setStatus(`Sofortige Loesung wird geladen.`, "#2563eb");
|
||||
updateSolverControls();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${endpoint}?size=${boardSize}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("instant solve request failed");
|
||||
}
|
||||
|
||||
const solution = await response.json();
|
||||
|
||||
if (requestId !== solverRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
board = mode === "queens" && Array.isArray(solution[0]) ? solution[0].slice() : solution.slice();
|
||||
currentSolverHighlight = null;
|
||||
setSolverMessage("Sofortige Loesung geladen. Starte Solve Step-by-Step, wenn du den Ablauf sehen moechtest.");
|
||||
setStatus(`Loesung fuer ${boardSize} x ${boardSize} sofort geladen.`, "green");
|
||||
drawBoard();
|
||||
} catch (error) {
|
||||
if (requestId !== solverRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSolverMessage("Die sofortige Loesung konnte nicht geladen werden.");
|
||||
setStatus("Die sofortige Loesung konnte nicht geladen werden.", "red");
|
||||
} finally {
|
||||
if (requestId === solverRequestId) {
|
||||
solverLoading = false;
|
||||
updateSolverControls();
|
||||
}
|
||||
}
|
||||
// The queens endpoint may return multiple solutions; we display the first one.
|
||||
board = mode === "queens" && Array.isArray(solution[0]) ? solution[0] : solution;
|
||||
setStatus(`Loesung fuer ${boardSize} x ${boardSize} geladen.`, "green");
|
||||
drawBoard();
|
||||
}
|
||||
|
||||
function resetBoard(options = {}) {
|
||||
const {
|
||||
statusMessageText = "",
|
||||
statusColor = "",
|
||||
solverMessageText = `${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`
|
||||
} = options;
|
||||
|
||||
discardSolverTrace(solverMessageText);
|
||||
function resetBoard() {
|
||||
board = createEmptyBoard(boardSize);
|
||||
currentSolverHighlight = null;
|
||||
|
||||
if (statusMessageText) {
|
||||
setStatus(statusMessageText, statusColor);
|
||||
} else {
|
||||
setStatus();
|
||||
}
|
||||
|
||||
setStatus();
|
||||
drawBoard();
|
||||
}
|
||||
|
||||
@@ -784,7 +401,7 @@ function updateQueenList() {
|
||||
|
||||
li.textContent = `${mode === "queens" ? "Dame" : "Turm"} ${count}: ${position}`;
|
||||
queenList.appendChild(li);
|
||||
count += 1;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,7 +422,7 @@ async function saveGame() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus("Der Speicherstand konnte nicht gespeichert werden.", "red");
|
||||
setStatus("❌ Der Speicherstand konnte nicht gespeichert werden.", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -826,7 +443,7 @@ async function loadSavePoint(savePointId) {
|
||||
const response = await fetch(`/load/${savePointId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus("Dieser Speicherstand konnte nicht geladen werden.", "red");
|
||||
setStatus("❌ Dieser Speicherstand konnte nicht geladen werden.", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -843,7 +460,7 @@ async function toggleSavePointFavorite(savePointId) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus("Favorit konnte nicht aktualisiert werden.", "red");
|
||||
setStatus("❌ Favorit konnte nicht aktualisiert werden.", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -863,7 +480,7 @@ async function deleteSavePoint(savePointId) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus("Speicherpunkt konnte nicht geloescht werden.", "red");
|
||||
setStatus("❌ Speicherpunkt konnte nicht geloescht werden.", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -874,7 +491,4 @@ async function deleteSavePoint(savePointId) {
|
||||
pendingBoardSize = boardSize;
|
||||
updateModeButtons();
|
||||
updateBoardSizeDisplays(boardSize);
|
||||
updateSolverSpeed(solverSpeedMs);
|
||||
setSolverMessage(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`);
|
||||
updateSolverControls();
|
||||
drawBoard();
|
||||
|
||||
+61
-24
@@ -1,32 +1,69 @@
|
||||
const button = document.querySelector(".magnetic");
|
||||
const magneticButtons = document.querySelectorAll(".magnetic");
|
||||
|
||||
button.addEventListener("mousemove", (e) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - rect.width / 2;
|
||||
const y = e.clientY - rect.top - rect.height / 2;
|
||||
magneticButtons.forEach((button) => {
|
||||
button.addEventListener("mousemove", (event) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left - rect.width / 2;
|
||||
const y = event.clientY - rect.top - rect.height / 2;
|
||||
|
||||
button.style.transform = `translate(${x * 0.2}px, ${y * 0.2}px)`;
|
||||
});
|
||||
button.style.transform = `translate(${x * 0.15}px, ${y * 0.15}px)`;
|
||||
});
|
||||
|
||||
button.addEventListener("mouseleave", () => {
|
||||
button.style.transform = "translate(0,0)";
|
||||
});
|
||||
|
||||
const inputs = document.querySelectorAll("input");
|
||||
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener("input", () => {
|
||||
input.style.boxShadow = "0 0 8px rgba(79,172,254,0.6)";
|
||||
|
||||
setTimeout(() => {
|
||||
input.style.boxShadow = "";
|
||||
}, 300);
|
||||
button.addEventListener("mouseleave", () => {
|
||||
button.style.transform = "translate(0, 0)";
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.querySelector("form");
|
||||
document.querySelectorAll("input").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
input.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.4)";
|
||||
|
||||
form.addEventListener("submit", () => {
|
||||
button.innerText = "Logging in...";
|
||||
button.style.opacity = "0.7";
|
||||
window.clearTimeout(input._loginGlowTimeout);
|
||||
input._loginGlowTimeout = window.setTimeout(() => {
|
||||
input.style.boxShadow = "";
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("form").forEach((form) => {
|
||||
form.addEventListener("submit", (event) => {
|
||||
const submitter = event.submitter;
|
||||
|
||||
if (!submitter || submitter.dataset.loadingLabelApplied === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingLabel = submitter.dataset.loadingLabel;
|
||||
|
||||
if (loadingLabel) {
|
||||
submitter.dataset.loadingLabelApplied = "true";
|
||||
submitter.dataset.originalLabel = submitter.innerText;
|
||||
submitter.innerText = loadingLabel;
|
||||
submitter.style.opacity = "0.7";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".qr-code[data-qr-value]").forEach((container) => {
|
||||
const qrValue = container.dataset.qrValue;
|
||||
|
||||
if (!qrValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.QRCode !== "function") {
|
||||
container.textContent = "QR preview could not be loaded.";
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
new window.QRCode(container, {
|
||||
text: qrValue,
|
||||
width: 220,
|
||||
height: 220,
|
||||
colorDark: "#172033",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: window.QRCode.CorrectLevel.M
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,24 +6,33 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard</title>
|
||||
|
||||
<link rel="stylesheet" href="../static/css/dashboard.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- 🔝 Navbar -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-left">♛ 8-Queens App</div>
|
||||
<div class="nav-right">
|
||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('login.logout') }}">Logout</a>
|
||||
<a href="{{ url_for('login.login_settings') }}">Login settings</a>
|
||||
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
|
||||
<button class="nav-link-button" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 📦 Container -->
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
{% for category, message in messages %}
|
||||
<p class="flash-message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- 👤 User Card -->
|
||||
<div class="card">
|
||||
<h2>👋 Willkommen, {{ current_user.username }}</h2>
|
||||
|
||||
@@ -31,18 +40,16 @@
|
||||
<p><strong>ID:</strong> {{ current_user.id }}</p>
|
||||
<p><strong>Username:</strong> {{ current_user.username }}</p>
|
||||
|
||||
{% if current_user.email %}
|
||||
<p><strong>Email:</strong> {{ current_user.email }}</p>
|
||||
{% else %}
|
||||
<p><strong>Email:</strong> Not set</p>
|
||||
{% endif %}
|
||||
|
||||
<p><strong>2FA:</strong> {% if current_user.two_factor_enabled %}Authenticator app enabled{% else %}Disabled{% endif %}</p>
|
||||
<p><strong>Erstellt:</strong> {{ current_user.created_at }}</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('login.logout') }}" method="post">
|
||||
<button class="btn danger" type="submit">Logout</button>
|
||||
</form>
|
||||
<div class="inline-actions">
|
||||
<a class="btn secondary" href="{{ url_for('login.login_settings') }}">Open login settings</a>
|
||||
<form action="{{ url_for('login.logout') }}" method="post">
|
||||
<button class="btn danger" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-switch">
|
||||
@@ -51,8 +58,6 @@
|
||||
<button class="btn secondary" onclick="toggleSidebar()" id="btnSidebar">Board Settings</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ♟️ Game Card -->
|
||||
<div class="card game-card">
|
||||
<h2>♟️ Damen- und Tuerme-Spiel</h2>
|
||||
<p class="board-size-info">Aktuelle Brettgroesse: <span id="boardSizeValue">8</span> x <span
|
||||
@@ -61,31 +66,8 @@
|
||||
<p id="statusMessage"></p>
|
||||
<div class="board" id="board"></div>
|
||||
|
||||
<div class="solver-panel">
|
||||
<div class="solver-panel-header">
|
||||
<div>
|
||||
<h3>Solver-Ablauf</h3>
|
||||
<p class="solver-message" id="solverMessage">Bereite einen Ablauf vor und spiele ihn Schritt fuer Schritt ab.</p>
|
||||
</div>
|
||||
<p class="solver-step-info" id="solverStepInfo">Schritt 0 / 0</p>
|
||||
</div>
|
||||
|
||||
<div class="solver-controls">
|
||||
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve" type="button">Solve Step-by-Step</button>
|
||||
<button class="btn secondary" onclick="instantSolve()" id="btnInstantSolve" type="button">Instant Solve</button>
|
||||
<button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep" type="button">Next Step</button>
|
||||
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause" type="button">Play Animation</button>
|
||||
</div>
|
||||
|
||||
<div class="solver-speed">
|
||||
<label class="solver-speed-label" for="solverSpeedInput">Speed</label>
|
||||
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
|
||||
oninput="updateSolverSpeed(this.value)">
|
||||
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick="solve()">Solve</button>
|
||||
<button class="btn" onclick="checkSolution()">Check</button>
|
||||
<button class="btn secondary" onclick="resetBoard()">Reset</button>
|
||||
|
||||
@@ -188,7 +170,7 @@
|
||||
<div class="sidebar-backdrop" id="saveBackdrop" onclick="toggleSavePanel(false)"></div>
|
||||
<div class="sidebar-backdrop" id="savePointsBackdrop" onclick="toggleSavePointsPanel(false)"></div>
|
||||
|
||||
<script src="../static/js/dashboard.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||
|
||||
</body>
|
||||
|
||||
|
||||
+30
-18
@@ -5,35 +5,47 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login</title>
|
||||
<link rel="stylesheet" href="../static/css/login.css">
|
||||
<link rel="stylesheet" href="../static/css/login.css">
|
||||
</link>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('login.login') }}"><span>Login</span></a></li>
|
||||
<li><a href="{{ url_for('login.register') }}"><span>Create account</span></a></li>
|
||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<h1>Login to your account</h1>
|
||||
<main class="auth-shell">
|
||||
<section class="auth-panel">
|
||||
<h1>Login to your account</h1>
|
||||
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your authenticator app after the password check.</p>
|
||||
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form action="{{ url_for('login.login') }}" method="post">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" name="username" required />
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" name="password" required />
|
||||
<button class="magnetic" type="submit">Login</button>
|
||||
</form>
|
||||
{% if error %}
|
||||
<p class="message error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login.login') }}" method="post">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" minlength="8" maxlength="128" required>
|
||||
|
||||
<button class="magnetic" type="submit" data-loading-label="Checking login...">Login</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="../static/js/login.js" defer></script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login Settings</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
<li><a href="{{ url_for('login.login_settings') }}">Login settings</a></li>
|
||||
</ul>
|
||||
<form action="{{ url_for('login.logout') }}" method="post" class="nav-form">
|
||||
<button type="submit" class="nav-button">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<main class="auth-shell settings-shell">
|
||||
<section class="auth-panel settings-panel">
|
||||
<h1>Login settings</h1>
|
||||
<p class="muted">Manage authenticator app 2FA and your password without changing the registration flow.</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if error %}
|
||||
<p class="message error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="settings-grid">
|
||||
{% if current_user.two_factor_enabled %}
|
||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="disable_two_factor">
|
||||
|
||||
<h2>Authenticator app 2FA</h2>
|
||||
<p class="field-hint">2FA is currently enabled for this account.</p>
|
||||
<p class="field-hint">Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.</p>
|
||||
|
||||
<button class="secondary-button" type="submit" data-loading-label="Disabling 2FA...">Disable 2FA</button>
|
||||
</form>
|
||||
{% elif pending_two_factor_secret %}
|
||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="confirm_two_factor_setup">
|
||||
|
||||
<h2>Finish 2FA setup</h2>
|
||||
<p class="field-hint">Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.</p>
|
||||
|
||||
<div class="qr-panel">
|
||||
<div class="qr-code" data-qr-value="{{ pending_two_factor_uri }}"></div>
|
||||
</div>
|
||||
<p class="field-hint">If scanning does not work, you can still use the manual setup key below.</p>
|
||||
|
||||
<label for="two_factor_secret">Manual setup key</label>
|
||||
<input id="two_factor_secret" type="text" value="{{ pending_two_factor_secret }}" readonly>
|
||||
|
||||
<label for="two_factor_uri">Setup URI</label>
|
||||
<input id="two_factor_uri" type="text" value="{{ pending_two_factor_uri }}" readonly>
|
||||
<p class="field-hint">This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.</p>
|
||||
|
||||
<label for="code">Authenticator code</label>
|
||||
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="magnetic" type="submit" data-loading-label="Enabling 2FA...">Confirm and enable</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="cancel_two_factor_setup">
|
||||
|
||||
<h2>Cancel setup</h2>
|
||||
<p class="field-hint">Cancel the current authenticator setup if you want to restart with a new secret.</p>
|
||||
|
||||
<button class="secondary-button" type="submit">Cancel setup</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="start_two_factor_setup">
|
||||
|
||||
<h2>Authenticator app 2FA</h2>
|
||||
<p class="field-hint">Protect your account with 6-digit codes from an authenticator app.</p>
|
||||
<p class="field-hint">After starting setup, you will get a QR code to scan and then confirm once with a live code.</p>
|
||||
|
||||
<button class="magnetic" type="submit" data-loading-label="Starting setup...">Start setup</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="password">
|
||||
|
||||
<h2>Change password</h2>
|
||||
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" type="password" name="current_password" minlength="8" maxlength="128"
|
||||
required>
|
||||
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" type="password" name="new_password" minlength="8" maxlength="128" required>
|
||||
|
||||
<label for="confirm_password">Confirm new password</label>
|
||||
<input id="confirm_password" type="password" name="confirm_password" minlength="8" maxlength="128"
|
||||
required>
|
||||
|
||||
<button class="magnetic" type="submit" data-loading-label="Updating password...">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+30
-13
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign Up</title>
|
||||
<link rel="stylesheet" href="../static/css/login.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -15,21 +15,38 @@
|
||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main>
|
||||
<h1>Create an account</h1>
|
||||
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<main class="auth-shell">
|
||||
<section class="auth-panel">
|
||||
<h1>Create an account</h1>
|
||||
<p class="muted">Create your account here and enable authenticator app 2FA later in your login settings.</p>
|
||||
|
||||
<form action="{{ url_for('login.register') }}" method="post">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" name="username" required />
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" name="password" required />
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if error %}
|
||||
<p class="message error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login.register') }}" method="post">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" minlength="8" maxlength="128" required>
|
||||
<p class="field-hint">Use at least 8 characters.</p>
|
||||
|
||||
<button class="magnetic" type="submit" data-loading-label="Creating account...">Sign Up</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify Login</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="auth-shell">
|
||||
<section class="auth-panel">
|
||||
<h1>Verify your login</h1>
|
||||
<p class="muted">Open your authenticator app and enter the current 6-digit code.</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="message {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if error %}
|
||||
<p class="message error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login.verify_two_factor') }}" method="post">
|
||||
<label for="code">Verification code</label>
|
||||
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
|
||||
<p class="field-hint">Codes refresh automatically every 30 seconds in your authenticator app.</p>
|
||||
|
||||
<button class="magnetic" type="submit" data-loading-label="Verifying...">Verify</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+18
-6
@@ -1,16 +1,28 @@
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(20) NOT NULL UNIQUE,
|
||||
password VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(120) NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
password VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
two_factor_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
two_factor_secret VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
username VARCHAR(80) NOT NULL,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until DATETIME,
|
||||
last_failed_at DATETIME,
|
||||
CONSTRAINT uq_login_attempt_ip_username UNIQUE (ip_address, username)
|
||||
);
|
||||
|
||||
CREATE TABLE game_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
board TEXT NOT NULL, -- JSON String (z.B. "[0,4,7,5,2,6,1,3]")
|
||||
mode TEXT NOT NULL, -- "queens" oder "rooks"
|
||||
board TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
board_size INTEGER NOT NULL DEFAULT 8,
|
||||
save_name VARCHAR(80) NOT NULL DEFAULT 'Unnamed Save',
|
||||
save_note TEXT NOT NULL DEFAULT '',
|
||||
|
||||
Reference in New Issue
Block a user