Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- Add created_by_id FK to users table (NULL = self-registration) - Set created_by_id in admin create, bulk create, and team add routes - Show "samorejestracja" or "dodał: [name]" in admin users panel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""
|
|
Team Management Routes
|
|
======================
|
|
|
|
Routes for company MANAGERs to manage their team members:
|
|
add/remove users, change roles, toggle permissions.
|
|
"""
|
|
|
|
import re
|
|
import secrets
|
|
import string
|
|
from datetime import datetime, timedelta
|
|
|
|
from flask import request, jsonify
|
|
from flask_login import login_required, current_user
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from blueprints.public import bp
|
|
from database import (
|
|
SessionLocal, User, Company, UserCompany,
|
|
UserCompanyPermissions, SystemRole, CompanyRole,
|
|
)
|
|
from email_service import send_welcome_activation_email
|
|
from extensions import limiter
|
|
from utils.permissions import can_invite_user_to_company
|
|
import logging
|
|
import os
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
|
|
|
PERMISSION_KEYS = [
|
|
'can_edit_description', 'can_edit_services', 'can_edit_contacts',
|
|
'can_edit_social', 'can_manage_classifieds', 'can_post_forum',
|
|
'can_view_analytics',
|
|
]
|
|
|
|
|
|
def _require_team_manager(company_id):
|
|
"""Check if current user can manage this company's team. Returns (db, company) or raises."""
|
|
if not can_invite_user_to_company(current_user, company_id):
|
|
return None, None
|
|
db = SessionLocal()
|
|
company = db.query(Company).filter(Company.id == company_id).first()
|
|
if not company:
|
|
db.close()
|
|
return None, None
|
|
return db, company
|
|
|
|
|
|
@bp.route('/firma/<int:company_id>/zespol')
|
|
@login_required
|
|
def team_list(company_id):
|
|
"""List team members with roles and permissions."""
|
|
db, company = _require_team_manager(company_id)
|
|
if not db:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
try:
|
|
members = db.query(UserCompany).filter(
|
|
UserCompany.company_id == company_id
|
|
).all()
|
|
|
|
result = []
|
|
for uc in members:
|
|
user = db.query(User).filter(User.id == uc.user_id).first()
|
|
if not user:
|
|
continue
|
|
|
|
perms = db.query(UserCompanyPermissions).filter_by(
|
|
user_id=user.id, company_id=company_id
|
|
).first()
|
|
|
|
member_data = {
|
|
'id': user.id,
|
|
'name': user.name or user.email,
|
|
'email': user.email,
|
|
'phone': user.phone,
|
|
'role': uc.role,
|
|
'is_primary': uc.is_primary,
|
|
'is_active': user.is_active,
|
|
'is_current_user': user.id == current_user.id,
|
|
'last_login': user.last_login.isoformat() if user.last_login else None,
|
|
'permissions': {},
|
|
}
|
|
|
|
if perms:
|
|
for key in PERMISSION_KEYS:
|
|
member_data['permissions'][key] = getattr(perms, key, False)
|
|
|
|
result.append(member_data)
|
|
|
|
# Sort: MANAGERs first, then by name
|
|
role_order = {'MANAGER': 0, 'EMPLOYEE': 1, 'VIEWER': 2, 'NONE': 3}
|
|
result.sort(key=lambda m: (role_order.get(m['role'], 9), (m['name'] or '').lower()))
|
|
|
|
return jsonify({'success': True, 'members': result, 'company_name': company.name})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing team for company {company_id}: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas pobierania zespołu'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/firma/<int:company_id>/zespol/dodaj', methods=['POST'])
|
|
@login_required
|
|
@limiter.limit("20 per hour")
|
|
def team_add_member(company_id):
|
|
"""Add a user to the company team."""
|
|
db, company = _require_team_manager(company_id)
|
|
if not db:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
try:
|
|
data = request.get_json() or {}
|
|
email = (data.get('email') or '').strip().lower()
|
|
name = (data.get('name') or '').strip()
|
|
role_str = (data.get('role') or 'EMPLOYEE').upper()
|
|
|
|
# Validate
|
|
if not email or not EMAIL_RE.match(email):
|
|
return jsonify({'success': False, 'error': 'Podaj prawidłowy adres email'}), 400
|
|
if not name:
|
|
return jsonify({'success': False, 'error': 'Podaj imię i nazwisko'}), 400
|
|
if role_str not in ('VIEWER', 'EMPLOYEE', 'MANAGER'):
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowa rola'}), 400
|
|
|
|
# Check if already in this team
|
|
existing_user = db.query(User).filter(User.email == email).first()
|
|
if existing_user:
|
|
existing_uc = db.query(UserCompany).filter_by(
|
|
user_id=existing_user.id, company_id=company_id
|
|
).first()
|
|
if existing_uc:
|
|
return jsonify({'success': False, 'error': 'Ta osoba już należy do Twojego zespołu'}), 400
|
|
|
|
is_new_account = False
|
|
|
|
if existing_user:
|
|
# Existing user — just link to company
|
|
user = existing_user
|
|
is_primary = user.company_id is None # primary if no company yet
|
|
uc = UserCompany(
|
|
user_id=user.id,
|
|
company_id=company_id,
|
|
role=role_str,
|
|
is_primary=is_primary,
|
|
)
|
|
db.add(uc)
|
|
|
|
if is_primary:
|
|
user.company_id = company_id
|
|
user.company_role = role_str
|
|
user.is_norda_member = company.status == 'active'
|
|
|
|
else:
|
|
# New user — create account
|
|
is_new_account = True
|
|
password_chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
|
generated_password = ''.join(secrets.choice(password_chars) for _ in range(16))
|
|
password_hash = generate_password_hash(generated_password, method='pbkdf2:sha256')
|
|
|
|
user = User(
|
|
email=email,
|
|
password_hash=password_hash,
|
|
name=name,
|
|
company_id=company_id,
|
|
is_verified=True,
|
|
is_active=True,
|
|
is_norda_member=company.status == 'active',
|
|
created_by_id=current_user.id,
|
|
)
|
|
user.set_role(SystemRole.EMPLOYEE)
|
|
user.set_company_role(CompanyRole[role_str])
|
|
db.add(user)
|
|
db.flush() # get user.id
|
|
|
|
uc = UserCompany(
|
|
user_id=user.id,
|
|
company_id=company_id,
|
|
role=role_str,
|
|
is_primary=True,
|
|
)
|
|
db.add(uc)
|
|
|
|
# Create permissions
|
|
db.flush()
|
|
UserCompanyPermissions.get_or_create(db, user.id, company_id)
|
|
|
|
db.commit()
|
|
|
|
# Send activation email for new accounts
|
|
if is_new_account:
|
|
try:
|
|
token = secrets.token_urlsafe(32)
|
|
user.reset_token = token
|
|
user.reset_token_expires = datetime.now() + timedelta(hours=72)
|
|
db.commit()
|
|
|
|
app_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
|
reset_url = f"{app_url}/reset-password/{token}"
|
|
send_welcome_activation_email(email, name, reset_url)
|
|
logger.info(f"Activation email sent to {email} for company {company_id}")
|
|
except Exception as mail_err:
|
|
logger.error(f"Failed to send activation email to {email}: {mail_err}")
|
|
|
|
logger.info(
|
|
f"Manager {current_user.email} added {email} (role={role_str}) "
|
|
f"to company {company_id} (new_account={is_new_account})"
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'user_id': user.id,
|
|
'is_new_account': is_new_account,
|
|
'message': f'{"Konto utworzone i email" if is_new_account else "Użytkownik"} '
|
|
f'został dodany do zespołu',
|
|
})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error adding team member to company {company_id}: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas dodawania osoby'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/rola', methods=['POST'])
|
|
@login_required
|
|
def team_change_role(company_id, user_id):
|
|
"""Change a team member's company role."""
|
|
if user_id == current_user.id:
|
|
return jsonify({'success': False, 'error': 'Nie możesz zmienić własnej roli'}), 400
|
|
|
|
db, company = _require_team_manager(company_id)
|
|
if not db:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
try:
|
|
data = request.get_json() or {}
|
|
role_str = (data.get('role') or '').upper()
|
|
if role_str not in ('VIEWER', 'EMPLOYEE', 'MANAGER'):
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowa rola'}), 400
|
|
|
|
uc = db.query(UserCompany).filter_by(
|
|
user_id=user_id, company_id=company_id
|
|
).first()
|
|
if not uc:
|
|
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
|
|
|
uc.role = role_str
|
|
|
|
# Sync user.company_role if this is their primary company
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if user and user.company_id == company_id:
|
|
user.set_company_role(CompanyRole[role_str])
|
|
|
|
# Update permissions when role changes
|
|
perms = UserCompanyPermissions.get_or_create(db, user_id, company_id)
|
|
if role_str == 'MANAGER':
|
|
perms.can_edit_contacts = True
|
|
perms.can_edit_social = True
|
|
perms.can_view_analytics = True
|
|
elif role_str in ('EMPLOYEE', 'VIEWER'):
|
|
perms.can_edit_contacts = False
|
|
perms.can_edit_social = False
|
|
perms.can_view_analytics = False
|
|
perms.granted_by_id = current_user.id
|
|
perms.updated_at = datetime.now()
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Manager {current_user.email} changed role of user {user_id} to {role_str} in company {company_id}")
|
|
return jsonify({'success': True, 'message': f'Rola zmieniona na {role_str}'})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error changing role for user {user_id} in company {company_id}: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas zmiany roli'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/uprawnienia', methods=['POST'])
|
|
@login_required
|
|
def team_toggle_permission(company_id, user_id):
|
|
"""Toggle a specific permission for a team member."""
|
|
db, company = _require_team_manager(company_id)
|
|
if not db:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
try:
|
|
data = request.get_json() or {}
|
|
perm_key = data.get('permission')
|
|
perm_value = bool(data.get('value'))
|
|
|
|
if perm_key not in PERMISSION_KEYS:
|
|
return jsonify({'success': False, 'error': 'Nieprawidłowe uprawnienie'}), 400
|
|
|
|
# Only toggle permissions for EMPLOYEE role
|
|
uc = db.query(UserCompany).filter_by(
|
|
user_id=user_id, company_id=company_id
|
|
).first()
|
|
if not uc:
|
|
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
|
if uc.role != 'EMPLOYEE':
|
|
return jsonify({'success': False, 'error': 'Uprawnienia można zmieniać tylko pracownikom'}), 400
|
|
|
|
perms = UserCompanyPermissions.get_or_create(db, user_id, company_id)
|
|
setattr(perms, perm_key, perm_value)
|
|
perms.granted_by_id = current_user.id
|
|
perms.updated_at = datetime.now()
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Manager {current_user.email} set {perm_key}={perm_value} for user {user_id} in company {company_id}")
|
|
return jsonify({'success': True, 'message': 'Uprawnienie zaktualizowane'})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error toggling permission for user {user_id}: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas zmiany uprawnienia'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/zaproszenie', methods=['POST'])
|
|
@login_required
|
|
@limiter.limit("10 per hour")
|
|
def team_resend_invite(company_id, user_id):
|
|
"""Resend activation email to a team member who hasn't logged in yet."""
|
|
db, company = _require_team_manager(company_id)
|
|
if not db:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
try:
|
|
uc = db.query(UserCompany).filter_by(
|
|
user_id=user_id, company_id=company_id
|
|
).first()
|
|
if not uc:
|
|
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
|
|
|
|
if user.last_login:
|
|
return jsonify({'success': False, 'error': 'Ta osoba już się logowała — nie potrzebuje zaproszenia'}), 400
|
|
|
|
# Generate new activation token
|
|
token = secrets.token_urlsafe(32)
|
|
user.reset_token = token
|
|
user.reset_token_expires = datetime.now() + timedelta(hours=72)
|
|
db.commit()
|
|
|
|
app_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
|
reset_url = f"{app_url}/reset-password/{token}"
|
|
|
|
try:
|
|
send_welcome_activation_email(user.email, user.name or user.email, reset_url)
|
|
logger.info(f"Resent activation email to {user.email} for company {company_id} by {current_user.email}")
|
|
return jsonify({'success': True, 'message': f'Zaproszenie wysłane na {user.email}'})
|
|
except Exception as mail_err:
|
|
logger.error(f"Failed to resend activation email to {user.email}: {mail_err}")
|
|
return jsonify({'success': False, 'error': 'Nie udało się wysłać emaila — spróbuj później'}), 500
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error resending invite for user {user_id}: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas wysyłania zaproszenia'}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/usun', methods=['POST'])
|
|
@login_required
|
|
def team_remove_member(company_id, user_id):
|
|
"""Remove a user from the company team (does not delete the account)."""
|
|
if user_id == current_user.id:
|
|
return jsonify({'success': False, 'error': 'Nie możesz usunąć siebie z zespołu'}), 400
|
|
|
|
db, company = _require_team_manager(company_id)
|
|
if not db:
|
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
|
|
|
try:
|
|
uc = db.query(UserCompany).filter_by(
|
|
user_id=user_id, company_id=company_id
|
|
).first()
|
|
if not uc:
|
|
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
user_name = (user.name or user.email) if user else '?'
|
|
|
|
# Delete permissions
|
|
db.query(UserCompanyPermissions).filter_by(
|
|
user_id=user_id, company_id=company_id
|
|
).delete()
|
|
|
|
# Delete company association
|
|
db.delete(uc)
|
|
|
|
# Clear primary company link if this was user's primary
|
|
if user and user.company_id == company_id:
|
|
user.company_id = None
|
|
user.set_company_role(CompanyRole.NONE)
|
|
# Check if user has other company associations
|
|
other_uc = db.query(UserCompany).filter(
|
|
UserCompany.user_id == user_id,
|
|
UserCompany.company_id != company_id
|
|
).first()
|
|
if not other_uc:
|
|
user.is_norda_member = False
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Manager {current_user.email} removed user {user_id} ({user_name}) from company {company_id}")
|
|
return jsonify({'success': True, 'message': f'{user_name} został usunięty z zespołu'})
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error removing user {user_id} from company {company_id}: {e}")
|
|
return jsonify({'success': False, 'error': 'Błąd podczas usuwania osoby'}), 500
|
|
finally:
|
|
db.close()
|