diff --git a/app/__init__.py b/app/__init__.py index dfb1af7..b689929 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1206,8 +1206,15 @@ def create_app(config=None): # Create default admin user if it doesn't exist admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0] if not User.query.filter_by(username=admin_username).first(): + from app.models import Role admin_user = User(username=admin_username, role="admin") admin_user.is_active = True + + # Assign admin role from the new Role system + admin_role = Role.query.filter_by(name="admin").first() + if admin_role: + admin_user.roles.append(admin_role) + db.session.add(admin_user) db.session.commit() print(f"Created default admin user: {admin_username}") @@ -1361,8 +1368,15 @@ def init_database(app): # Create default admin user if it doesn't exist admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0] if not User.query.filter_by(username=admin_username).first(): + from app.models import Role admin_user = User(username=admin_username, role="admin") admin_user.is_active = True + + # Assign admin role from the new Role system + admin_role = Role.query.filter_by(name="admin").first() + if admin_role: + admin_user.roles.append(admin_role) + db.session.add(admin_user) db.session.commit() print(f"Created default admin user: {admin_username}") diff --git a/app/models/user.py b/app/models/user.py index 1e15594..386b198 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,6 +25,7 @@ class User(UserMixin, db.Model): oidc_issuer = db.Column(db.String(255), nullable=True) avatar_filename = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=True) + password_change_required = db.Column(db.Boolean, default=False, nullable=False) # Force password change on first login # User preferences and settings email_notifications = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable email notifications diff --git a/app/routes/admin.py b/app/routes/admin.py index e025bb1..c7c2b94 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -15,7 +15,7 @@ from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module from app import db, limiter -from app.models import User, Project, TimeEntry, Settings, Invoice, Quote, QuoteItem +from app.models import User, Project, TimeEntry, Settings, Invoice, Quote, QuoteItem, Role from datetime import datetime from sqlalchemy import text import os @@ -176,28 +176,55 @@ def create_user(): """Create a new user""" if request.method == "POST": username = request.form.get("username", "").strip().lower() - role = request.form.get("role", "user") + role_name = request.form.get("role", "user") # This will be a role name from the Role system + default_password = request.form.get("default_password", "").strip() + force_password_change = request.form.get("force_password_change") == "on" if not username: flash(_("Username is required"), "error") - return render_template("admin/user_form.html", user=None) + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/user_form.html", user=None, all_roles=all_roles) # Check if user already exists if User.query.filter_by(username=username).first(): flash(_("User already exists"), "error") - return render_template("admin/user_form.html", user=None) + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/user_form.html", user=None, all_roles=all_roles) - # Create user - user = User(username=username, role=role) + # Get the Role object from the database + role_obj = Role.query.filter_by(name=role_name).first() + if not role_obj: + # Fallback: if role doesn't exist, try to use "user" role + role_obj = Role.query.filter_by(name="user").first() + if not role_obj: + flash(_("Default 'user' role not found. Please run 'flask seed_permissions_cmd' first."), "error") + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/user_form.html", user=None, all_roles=all_roles) + + # Create user with legacy role field for backward compatibility + user = User(username=username, role=role_name) + + # Assign the role from the new Role system + user.roles.append(role_obj) + + # Set default password if provided + if default_password: + user.set_password(default_password) + if force_password_change: + user.password_change_required = True + db.session.add(user) if not safe_commit("admin_create_user", {"username": username}): flash(_("Could not create user due to a database error. Please check server logs."), "error") - return render_template("admin/user_form.html", user=None) + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/user_form.html", user=None, all_roles=all_roles) flash(_('User "%(username)s" created successfully', username=username), "success") return redirect(url_for("admin.list_users")) - return render_template("admin/user_form.html", user=None) + # GET request - show form with available roles + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/user_form.html", user=None, all_roles=all_roles) @admin_bp.route("/admin/users//edit", methods=["GET", "POST"]) diff --git a/app/routes/auth.py b/app/routes/auth.py index ad0be45..30bf703 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -120,8 +120,15 @@ def login(): ) # Create new user, promote to admin if username is configured as admin - role = "admin" if username in admin_usernames else "user" - user = User(username=username, role=role) + role_name = "admin" if username in admin_usernames else "user" + user = User(username=username, role=role_name) + + # Assign role from the new Role system + from app.models import Role + role_obj = Role.query.filter_by(name=role_name).first() + if role_obj: + user.roles.append(role_obj) + # Set password if password auth is required if requires_password and password: user.set_password(password) @@ -141,7 +148,7 @@ def login(): # Track onboarding started for new user track_onboarding_started( - user.id, {"auth_method": auth_method, "self_registered": True, "is_admin": role == "admin"} + user.id, {"auth_method": auth_method, "self_registered": True, "is_admin": role_name == "admin"} ) flash(_("Welcome! Your account has been created."), "success") @@ -234,6 +241,11 @@ def login(): # Set super properties (included in all events) set_super_properties(user.id, user) + # Check if password change is required + if user.password_change_required: + flash(_("You must change your password before continuing."), "warning") + return redirect(url_for("auth.change_password")) + # Redirect to intended page or dashboard next_page = request.args.get("next") if not next_page or not next_page.startswith("/"): @@ -423,6 +435,55 @@ def edit_profile(): return render_template("auth/edit_profile.html", requires_password=requires_password) +@auth_bp.route("/change-password", methods=["GET", "POST"]) +@login_required +def change_password(): + """Change password page - required when password_change_required is True""" + if request.method == "POST": + current_password = request.form.get("current_password", "").strip() + new_password = request.form.get("new_password", "").strip() + confirm_password = request.form.get("confirm_password", "").strip() + + # Validate inputs + if not new_password: + flash(_("New password is required"), "error") + return render_template("auth/change_password.html") + + if len(new_password) < 8: + flash(_("Password must be at least 8 characters long."), "error") + return render_template("auth/change_password.html") + + if new_password != confirm_password: + flash(_("Passwords do not match."), "error") + return render_template("auth/change_password.html") + + # If user has a password, verify current password + if current_user.has_password: + if not current_password: + flash(_("Current password is required"), "error") + return render_template("auth/change_password.html") + + if not current_user.check_password(current_password): + flash(_("Current password is incorrect"), "error") + return render_template("auth/change_password.html") + + # Set new password + current_user.set_password(new_password) + current_user.password_change_required = False + + try: + db.session.commit() + current_app.logger.info("User '%s' changed password", current_user.username) + flash(_("Password changed successfully. You can now continue."), "success") + return redirect(url_for("main.dashboard")) + except Exception: + db.session.rollback() + flash(_("Could not update password due to a database error."), "error") + return render_template("auth/change_password.html") + + return render_template("auth/change_password.html") + + @auth_bp.route("/profile/avatar/remove", methods=["POST"]) @login_required def remove_avatar(): @@ -669,12 +730,19 @@ def oidc_callback(): if not Config.ALLOW_SELF_REGISTER: flash(_("User account does not exist and self-registration is disabled."), "error") return redirect(url_for("auth.login")) - role = "user" + role_name = "user" try: - user = User(username=username, role=role, email=email, full_name=full_name) + user = User(username=username, role=role_name, email=email, full_name=full_name) user.is_active = True user.oidc_issuer = issuer user.oidc_sub = sub + + # Assign role from the new Role system + from app.models import Role + role_obj = Role.query.filter_by(name=role_name).first() + if role_obj: + user.roles.append(role_obj) + db.session.add(user) if not safe_commit("oidc_create_user", {"username": username, "email": email}): raise RuntimeError("db commit failed on user create") @@ -685,7 +753,7 @@ def oidc_callback(): { "auth_method": "oidc", "self_registered": True, - "is_admin": role == "admin", + "is_admin": role_name == "admin", "has_email": bool(email), }, ) diff --git a/app/templates/admin/user_form.html b/app/templates/admin/user_form.html index 61cc2d7..bec7c6c 100644 --- a/app/templates/admin/user_form.html +++ b/app/templates/admin/user_form.html @@ -21,10 +21,35 @@
+

+ Select a role from the new role-based permission system. Users can have multiple roles assigned via "Manage Roles & Permissions" after creation. +

+ {% if not user %} +
+ + +

+ Set an initial password for this user. If set, you can require them to change it on first login. +

+
+
+ + +
+ {% endif %} {% if user %}
diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 0000000..2946dd9 --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{ _('Change Password') }}

+

+ {% if current_user.password_change_required %} + {{ _('You must change your password before continuing.') }} + {% else %} + {{ _('Update your password.') }} + {% endif %} +

+
+
+ +
+
+ +
+ {% if current_user.has_password %} +
+ + +
+ {% endif %} +
+ + +

{{ _('Password must be at least 8 characters long.') }}

+
+
+ + +
+
+
+ {% if not current_user.password_change_required %} + {{ _('Cancel') }} + {% endif %} + +
+
+
+{% endblock %} + diff --git a/migrations/versions/074_add_password_change_required.py b/migrations/versions/074_add_password_change_required.py new file mode 100644 index 0000000..3fec49f --- /dev/null +++ b/migrations/versions/074_add_password_change_required.py @@ -0,0 +1,52 @@ +"""Add password_change_required to users table + +Revision ID: 074_password_change_required +Revises: 073_ai_features_gps_tracking +Create Date: 2025-01-27 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '074_password_change_required' +down_revision = '073_ai_features_gps_tracking' +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + """Check if a column exists in a table""" + try: + return column_name in [col['name'] for col in inspector.get_columns(table_name)] + except Exception: + return False + + +def upgrade(): + """Add password_change_required column to users table""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + # Ensure users table exists + if 'users' not in inspector.get_table_names(): + return + + # Add password_change_required column if missing + if not _has_column(inspector, 'users', 'password_change_required'): + op.add_column('users', sa.Column('password_change_required', sa.Boolean(), nullable=False, server_default='false')) + + +def downgrade(): + """Remove password_change_required column from users table""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + if 'users' not in inspector.get_table_names(): + return + + # Drop password_change_required column if exists + if _has_column(inspector, 'users', 'password_change_required'): + op.drop_column('users', 'password_change_required') +