diff --git a/app.py b/app.py index 9c0a838..db286d2 100644 --- a/app.py +++ b/app.py @@ -12,9 +12,15 @@ SUPPORTED_LANGS = ["fr", "en", "de"] def _select_locale(): + # 1. Explicit session override (set via flag switcher) lang = flask_session.get("lang") if lang in SUPPORTED_LANGS: return lang + # 2. Authenticated user's stored preference + if current_user.is_authenticated and current_user.language in SUPPORTED_LANGS: + flask_session["lang"] = current_user.language + return current_user.language + # 3. Browser Accept-Language header return request.accept_languages.best_match(SUPPORTED_LANGS) or "en" @@ -74,11 +80,13 @@ def create_app(config_class=Config): def load_user(user_id): return db.session.get(User, int(user_id)) + from blueprints.admin import admin_bp from blueprints.auth import auth_bp from blueprints.dashboard import dashboard_bp from blueprints.equipment import equipment_bp from blueprints.sessions import sessions_bp from blueprints.analyses import analyses_bp + app.register_blueprint(admin_bp) app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(equipment_bp) @@ -116,6 +124,9 @@ def create_app(config_class=Config): def set_lang(lang: str): if lang in SUPPORTED_LANGS: flask_session["lang"] = lang + if current_user.is_authenticated: + current_user.language = lang + db.session.commit() return redirect(request.referrer or "/") @app.route("/") diff --git a/blueprints/admin.py b/blueprints/admin.py new file mode 100644 index 0000000..e8d6281 --- /dev/null +++ b/blueprints/admin.py @@ -0,0 +1,100 @@ +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from flask_babel import _ +from flask_login import current_user, login_required + +from extensions import db +from models import User + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin") + +ROLES = ["user", "admin"] + + +def _require_admin(): + if not current_user.is_authenticated or current_user.role != "admin": + abort(403) + + +# --------------------------------------------------------------------------- +# User list +# --------------------------------------------------------------------------- + +@admin_bp.route("/") +@login_required +def index(): + _require_admin() + users = db.session.scalars( + db.select(User).order_by(User.created_at.desc()) + ).all() + return render_template("admin/users.html", users=users, roles=ROLES) + + +# --------------------------------------------------------------------------- +# Change role +# --------------------------------------------------------------------------- + +@admin_bp.route("/users//role", methods=["POST"]) +@login_required +def change_role(user_id: int): + _require_admin() + user = db.session.get(User, user_id) + if user is None: + abort(404) + new_role = request.form.get("role", "user") + if new_role not in ROLES: + flash(_("Invalid role."), "error") + return redirect(url_for("admin.index")) + # Prevent removing the last admin + if user.role == "admin" and new_role != "admin": + admin_count = db.session.scalar( + db.select(db.func.count()).select_from(User).where(User.role == "admin") + ) + if admin_count <= 1: + flash(_("Cannot remove the last admin."), "error") + return redirect(url_for("admin.index")) + user.role = new_role + db.session.commit() + flash(_("Role updated for %(email)s.", email=user.email), "success") + return redirect(url_for("admin.index")) + + +# --------------------------------------------------------------------------- +# Reset password +# --------------------------------------------------------------------------- + +@admin_bp.route("/users//password", methods=["POST"]) +@login_required +def reset_password(user_id: int): + _require_admin() + user = db.session.get(User, user_id) + if user is None: + abort(404) + new_pw = request.form.get("new_password", "").strip() + if len(new_pw) < 8: + flash(_("Password must be at least 8 characters."), "error") + return redirect(url_for("admin.index")) + user.set_password(new_pw) + db.session.commit() + flash(_("Password reset for %(email)s.", email=user.email), "success") + return redirect(url_for("admin.index")) + + +# --------------------------------------------------------------------------- +# Delete user +# --------------------------------------------------------------------------- + +@admin_bp.route("/users//delete", methods=["POST"]) +@login_required +def delete_user(user_id: int): + _require_admin() + if user_id == current_user.id: + flash(_("You cannot delete your own account."), "error") + return redirect(url_for("admin.index")) + user = db.session.get(User, user_id) + if user is None: + abort(404) + email = user.email + db.session.delete(user) + db.session.commit() + flash(_("User %(email)s deleted.", email=email), "success") + return redirect(url_for("admin.index")) diff --git a/blueprints/auth.py b/blueprints/auth.py index 2b48652..e135ecd 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -12,6 +12,7 @@ from flask import ( render_template, request, send_from_directory, + session as flask_session, url_for, ) from flask_babel import _ @@ -28,6 +29,13 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth") # Helpers # --------------------------------------------------------------------------- +def _login(user: "User") -> None: + """Log in user and restore their language preference into the session.""" + login_user(user) + if user.language: + flask_session["lang"] = user.language + + def _safe_next() -> str: target = request.args.get("next") or "" if target and urlparse(target).netloc == "": @@ -107,7 +115,7 @@ def login(): user.last_login_at = datetime.now(timezone.utc) db.session.commit() - login_user(user) + _login(user) return redirect(_safe_next()) return render_template("auth/login.html") @@ -167,7 +175,7 @@ def register(): _dispatch_confirmation(user) return render_template("auth/confirm_pending.html", email=email) - login_user(user) + _login(user) flash(_("Account created! Welcome."), "success") return redirect(url_for("dashboard.index")) @@ -186,7 +194,7 @@ def confirm_email(token: str): user.email_confirmed = True user.email_confirm_token = None db.session.commit() - login_user(user) + _login(user) flash(_("Email confirmed! Welcome."), "success") return redirect(url_for("dashboard.index")) @@ -243,7 +251,7 @@ def callback_google(): flash(_("This email is already registered with a different login method."), "error") return redirect(url_for("auth.login")) - login_user(user) + _login(user) return redirect(_safe_next()) @@ -293,7 +301,7 @@ def callback_github(): flash(_("This email is already registered with a different login method."), "error") return redirect(url_for("auth.login")) - login_user(user) + _login(user) return redirect(_safe_next()) diff --git a/docker-compose.yaml b/docker-compose.yaml index 190a513..bdf68a3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,11 +13,11 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - ${NETWORK} web: build: . - ports: - - "5000:5000" restart: unless-stopped depends_on: db: @@ -30,7 +30,14 @@ services: volumes: - app_storage:/app/storage - .:/app # bind-mount source so code changes are live without a rebuild + networks: + - ${NETWORK} volumes: postgres_data: app_storage: + + +networks: + ${NETWORK}: + external: true diff --git a/migrations/versions/6818f37f4124_user_role_and_language.py b/migrations/versions/6818f37f4124_user_role_and_language.py new file mode 100644 index 0000000..7dd4f30 --- /dev/null +++ b/migrations/versions/6818f37f4124_user_role_and_language.py @@ -0,0 +1,44 @@ +"""user_role_and_language + +Revision ID: 6818f37f4124 +Revises: bf96ceb7f076 +Create Date: 2026-03-19 15:51:15.091825 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6818f37f4124' +down_revision = 'bf96ceb7f076' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('role', sa.String(length=20), nullable=True)) + batch_op.add_column(sa.Column('language', sa.String(length=10), nullable=True)) + + op.execute("UPDATE users SET role = 'user' WHERE role IS NULL") + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('role', nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('language') + batch_op.drop_column('role') + + op.execute("UPDATE users SET role = 'user' WHERE role IS NULL") + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('role', nullable=False) + + # ### end Alembic commands ### diff --git a/models.py b/models.py index 93f21a8..d608395 100644 --- a/models.py +++ b/models.py @@ -38,6 +38,8 @@ class User(UserMixin, db.Model): email_confirm_token: Mapped[str | None] = mapped_column(String(128)) show_equipment_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) bio: Mapped[str | None] = mapped_column(Text) + role: Mapped[str] = mapped_column(String(20), nullable=False, default="user") + language: Mapped[str | None] = mapped_column(String(10)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..b28f631 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin — Users') }} — The Shooter's Network{% endblock %} +{% block content %} + +
+

{{ _('User Management') }}

+ {{ users|length }} {{ _('users') }} +
+ +
+ + + + + + + + + + + + + + {% for u in users %} + + {# User info #} + + + {# Provider #} + + + {# Role badge + change form #} + + + {# Language #} + + + {# Dates #} + + + + {# Actions #} + + + {% endfor %} + +
{{ _('User') }}{{ _('Provider') }}{{ _('Role') }}{{ _('Language') }}{{ _('Joined') }}{{ _('Last login') }}{{ _('Actions') }}
+
+ {% if u.effective_avatar_url %} + + {% else %} +
+ {{ (u.display_name or u.email)[0].upper() }} +
+ {% endif %} +
+
{{ u.display_name or '—' }}
+
{{ u.email }}
+
+
+
+ {% if u.provider == 'google' %}🔵 Google + {% elif u.provider == 'github' %}⚫ GitHub + {% else %}🔑 {{ _('Local') }} + {% endif %} + +
+ + +
+
+ {{ u.language or '—' }} + {{ u.created_at.strftime('%d %b %Y') }} + {{ u.last_login_at.strftime('%d %b %Y') if u.last_login_at else '—' }} + +
+ + {# Reset password (local accounts only) #} + {% if u.provider == 'local' %} +
+ + 🔑 {{ _('Reset pwd') }} + +
+ + +
+
+ {% endif %} + + {# Delete — cannot delete yourself #} + {% if u.id != current_user.id %} +
+ +
+ {% else %} + {{ _('(you)') }} + {% endif %} + +
+
+
+ +{% endblock %} diff --git a/templates/base.html b/templates/base.html index bae3aa7..ae244eb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -250,6 +250,7 @@ {{ _('Equipment') }} {{ _('Sessions') }} {{ _('Dashboard') }} + {% if current_user.role == 'admin' %}{{ _('Admin') }}{% endif %} {% endif %} @@ -304,6 +305,7 @@ {{ _('Equipment') }} {{ _('Sessions') }} {{ _('Dashboard') }} + {% if current_user.role == 'admin' %}{{ _('Admin') }}{% endif %} {{ _('Profile') }}
diff --git a/translations/de/LC_MESSAGES/messages.mo b/translations/de/LC_MESSAGES/messages.mo index 4944ed7..f6c525f 100644 Binary files a/translations/de/LC_MESSAGES/messages.mo and b/translations/de/LC_MESSAGES/messages.mo differ diff --git a/translations/de/LC_MESSAGES/messages.po b/translations/de/LC_MESSAGES/messages.po index 5458b48..99cc948 100644 --- a/translations/de/LC_MESSAGES/messages.po +++ b/translations/de/LC_MESSAGES/messages.po @@ -651,3 +651,69 @@ msgstr "Notiz gespeichert." msgid "Please log in to access this page." msgstr "Bitte melden Sie sich an, um auf diese Seite zuzugreifen." + +msgid "Admin — Users" +msgstr "Admin — Benutzer" + +msgid "User Management" +msgstr "Benutzerverwaltung" + +msgid "users" +msgstr "Benutzer" + +msgid "Provider" +msgstr "Anbieter" + +msgid "Role" +msgstr "Rolle" + +msgid "Language" +msgstr "Sprache" + +msgid "Joined" +msgstr "Beigetreten" + +msgid "Last login" +msgstr "Letzter Login" + +msgid "Actions" +msgstr "Aktionen" + +msgid "Local" +msgstr "Lokal" + +msgid "Set" +msgstr "Setzen" + +msgid "Reset pwd" +msgstr "Passwort reset" + +msgid "New password (min 8)" +msgstr "Neues Passwort (min 8)" + +msgid "Delete user %(email)s? All their data will be permanently removed." +msgstr "Benutzer %(email)s löschen? Alle Daten werden dauerhaft entfernt." + +msgid "(you)" +msgstr "(Sie)" + +msgid "Invalid role." +msgstr "Ungültige Rolle." + +msgid "Cannot remove the last admin." +msgstr "Der letzte Administrator kann nicht entfernt werden." + +msgid "Role updated for %(email)s." +msgstr "Rolle für %(email)s aktualisiert." + +msgid "Password reset for %(email)s." +msgstr "Passwort für %(email)s zurückgesetzt." + +msgid "You cannot delete your own account." +msgstr "Sie können Ihr eigenes Konto nicht löschen." + +msgid "User %(email)s deleted." +msgstr "Benutzer %(email)s gelöscht." + +msgid "Admin" +msgstr "Admin" diff --git a/translations/en/LC_MESSAGES/messages.mo b/translations/en/LC_MESSAGES/messages.mo index fa7f8fd..edfdbfd 100644 Binary files a/translations/en/LC_MESSAGES/messages.mo and b/translations/en/LC_MESSAGES/messages.mo differ diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index 578abfc..c197a06 100644 Binary files a/translations/fr/LC_MESSAGES/messages.mo and b/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po index 63c64c6..535de2b 100644 --- a/translations/fr/LC_MESSAGES/messages.po +++ b/translations/fr/LC_MESSAGES/messages.po @@ -651,3 +651,69 @@ msgstr "Note sauvegardée." msgid "Please log in to access this page." msgstr "Veuillez vous connecter pour accéder à cette page." + +msgid "Admin — Users" +msgstr "Admin — Utilisateurs" + +msgid "User Management" +msgstr "Gestion des utilisateurs" + +msgid "users" +msgstr "utilisateurs" + +msgid "Provider" +msgstr "Fournisseur" + +msgid "Role" +msgstr "Rôle" + +msgid "Language" +msgstr "Langue" + +msgid "Joined" +msgstr "Inscrit" + +msgid "Last login" +msgstr "Dernière connexion" + +msgid "Actions" +msgstr "Actions" + +msgid "Local" +msgstr "Local" + +msgid "Set" +msgstr "Définir" + +msgid "Reset pwd" +msgstr "Réinit. mdp" + +msgid "New password (min 8)" +msgstr "Nouveau mot de passe (min 8)" + +msgid "Delete user %(email)s? All their data will be permanently removed." +msgstr "Supprimer l'utilisateur %(email)s ? Toutes ses données seront définitivement supprimées." + +msgid "(you)" +msgstr "(vous)" + +msgid "Invalid role." +msgstr "Rôle invalide." + +msgid "Cannot remove the last admin." +msgstr "Impossible de supprimer le dernier administrateur." + +msgid "Role updated for %(email)s." +msgstr "Rôle mis à jour pour %(email)s." + +msgid "Password reset for %(email)s." +msgstr "Mot de passe réinitialisé pour %(email)s." + +msgid "You cannot delete your own account." +msgstr "Vous ne pouvez pas supprimer votre propre compte." + +msgid "User %(email)s deleted." +msgstr "Utilisateur %(email)s supprimé." + +msgid "Admin" +msgstr "Admin"