mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 03:30:25 -06:00
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:
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
46
app/templates/auth/change_password.html
Normal file
46
app/templates/auth/change_password.html
Normal 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 %}
|
||||
|
||||
52
migrations/versions/074_add_password_change_required.py
Normal file
52
migrations/versions/074_add_password_change_required.py
Normal 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')
|
||||
|
||||
Reference in New Issue
Block a user