Fix user creation to use new Role system and add default password with forced change

- Updated user creation to assign roles from Role system instead of legacy role field
- Added password_change_required field to User model with migration
- Added default password input and force password change option in user creation form
- Updated login route to check password_change_required and redirect to change password page
- Created change_password route and template for forced password changes
- Updated all user creation points (admin, self-registration, OIDC, default admin) to use new Role system
- Updated user form template to show roles from Role system instead of hardcoded options

Fixes issue where newly created users were still using legacy roles instead of the new role-based permission system.
This commit is contained in:
Dries Peeters
2025-11-28 22:47:01 +01:00
parent 8585b097e0
commit 6ad96f114a
7 changed files with 249 additions and 16 deletions

View File

@@ -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}")

View File

@@ -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

View File

@@ -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/<int:user_id>/edit", methods=["GET", "POST"])

View File

@@ -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),
},
)

View File

@@ -21,10 +21,35 @@
<div>
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Role</label>
<select name="role" id="role" class="form-input">
<option value="user" {% if user and user.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Admin</option>
{% if all_roles %}
{% for role in all_roles %}
<option value="{{ role.name }}" {% if user and user.roles and role in user.roles %}selected{% elif not user and role.name == 'user' %}selected{% elif user and not user.roles and role.name == user.role %}selected{% endif %}>
{{ role.name|capitalize }}{% if role.is_system_role %} (System){% endif %}
</option>
{% endfor %}
{% else %}
{# Fallback to legacy roles if Role system not initialized #}
<option value="user" {% if user and user.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Admin</option>
{% endif %}
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
Select a role from the new role-based permission system. Users can have multiple roles assigned via "Manage Roles & Permissions" after creation.
</p>
</div>
{% if not user %}
<div>
<label for="default_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default Password</label>
<input type="password" name="default_password" id="default_password" class="form-input" placeholder="Leave empty for no password">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
Set an initial password for this user. If set, you can require them to change it on first login.
</p>
</div>
<div class="flex items-center">
<input type="checkbox" name="force_password_change" id="force_password_change" class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="force_password_change" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Require password change on first login</label>
</div>
{% endif %}
{% if user %}
<div class="flex items-center">
<input type="checkbox" name="is_active" id="is_active" {% if user.is_active %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Change Password') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">
{% if current_user.password_change_required %}
{{ _('You must change your password before continuing.') }}
{% else %}
{{ _('Update your password.') }}
{% endif %}
</p>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-md">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-6">
{% if current_user.has_password %}
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Current Password') }}</label>
<input type="password" name="current_password" id="current_password" required class="form-input">
</div>
{% endif %}
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('New Password') }}</label>
<input type="password" name="new_password" id="new_password" required minlength="8" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Password must be at least 8 characters long.') }}</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Confirm New Password') }}</label>
<input type="password" name="confirm_password" id="confirm_password" required minlength="8" class="form-input">
</div>
</div>
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6 flex justify-end">
{% if not current_user.password_change_required %}
<a href="{{ url_for('auth.profile') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mr-4">{{ _('Cancel') }}</a>
{% endif %}
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Change Password') }}</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -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')