feat: add password reset button in User Insights for batch-imported accounts
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

Adds a "Send reset" action button in the Problems tab and user profile page,
allowing admins to send password reset emails directly from User Insights
dashboard. Each reset requires manual confirmation via dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-22 13:16:45 +01:00
parent 774ce6fca6
commit 80b9bf1fea
3 changed files with 157 additions and 3 deletions

View File

@ -10,10 +10,12 @@ import csv
import io
import logging
import math
import os
import secrets
from datetime import date, timedelta, datetime
from flask import render_template, request, redirect, url_for, flash, Response
from flask_login import login_required
from flask import render_template, request, redirect, url_for, flash, Response, jsonify
from flask_login import login_required, current_user
from sqlalchemy import func, desc, text, or_
from sqlalchemy.orm import joinedload
@ -1584,3 +1586,70 @@ def user_insights_export():
return redirect(url_for('admin.user_insights'))
finally:
db.close()
@bp.route('/user-insights/send-reset/<int:user_id>', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def user_insights_send_reset(user_id):
"""Send password reset email to user from User Insights."""
db = SessionLocal()
try:
user = db.query(User).get(user_id)
if not user or not user.is_active:
return jsonify({'error': 'Użytkownik nie znaleziony lub nieaktywny'}), 404
# Generate reset token (24h validity)
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires = datetime.now() + timedelta(hours=24)
db.commit()
# Build reset URL
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
reset_url = f"{base_url}/reset-password/{token}"
# Send email
import email_service
if email_service.is_configured():
success = email_service.send_password_reset_email(user.email, reset_url)
else:
return jsonify({'error': 'Email service nie skonfigurowany'}), 500
if not success:
return jsonify({'error': 'Błąd wysyłki emaila'}), 500
# Log to email_logs
email_log = EmailLog(
recipient_email=user.email,
recipient_name=user.name,
email_type='password_reset',
subject='Reset hasła - Norda Biznes Partner',
status='sent',
user_id=user.id,
sender_email=current_user.email,
)
db.add(email_log)
# Audit log
audit = AuditLog(
user_id=current_user.id,
user_email=current_user.email,
action='user.password_reset_sent',
entity_type='user',
entity_id=user.id,
entity_name=user.email,
ip_address=request.remote_addr,
request_path=request.path,
)
db.add(audit)
db.commit()
logger.info(f"Admin {current_user.email} sent password reset to {user.email} (user {user.id})")
return jsonify({'success': True, 'message': f'Reset wysłany do {user.email}'})
except Exception as e:
logger.error(f"Send reset error for user {user_id}: {e}")
db.rollback()
return jsonify({'error': 'Błąd wysyłki'}), 500
finally:
db.close()

View File

@ -139,6 +139,12 @@
details[open] summary span:first-child { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
/* Reset password button */
.btn-reset-pw { padding: 4px 10px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; white-space: nowrap; transition: var(--transition); }
.btn-reset-pw:hover { background: var(--background); border-color: var(--primary); }
.btn-reset-pw.sent { background: #dcfce7; color: #166534; border-color: #86efac; cursor: default; }
.btn-reset-pw.error { background: #fef2f2; color: #991b1b; border-color: #fca5a5; }
/* Responsive */
@media (max-width: 768px) {
.insights-header { flex-direction: column; align-items: flex-start; }
@ -332,6 +338,7 @@
<th>Wolne strony</th>
<th>🔒</th>
<th>Ostatni login</th>
<th>Akcja</th>
</tr>
</thead>
<tbody>
@ -355,6 +362,9 @@
<td>{{ p.slow_pages }}</td>
<td>{% if p.is_locked %}<span class="badge badge-critical">Tak</span>{% endif %}</td>
<td>{{ p.last_login.strftime('%d.%m %H:%M') if p.last_login else 'Nigdy' }}</td>
<td>
<button class="btn-reset-pw" data-uid="{{ p.user.id }}" data-name="{{ p.user.name or p.user.email }}" data-email="{{ p.user.email }}">📧 Reset</button>
</td>
</tr>
{% endfor %}
</tbody>
@ -872,4 +882,37 @@
}
});
{% endif %}
// Password reset buttons
document.querySelectorAll('.btn-reset-pw').forEach(function(btn) {
btn.addEventListener('click', async function() {
const uid = this.dataset.uid;
const name = this.dataset.name;
const email = this.dataset.email;
if (!confirm('Wysłać reset hasła do ' + name + ' (' + email + ')?')) return;
this.disabled = true;
this.textContent = '⏳...';
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/admin/user-insights/send-reset/' + uid, {
method: 'POST',
headers: {'X-CSRFToken': csrfToken}
});
const data = await resp.json();
if (data.success) {
this.textContent = '✓ Wysłano';
this.classList.add('sent');
} else {
this.textContent = '✗ ' + (data.error || 'Błąd');
this.classList.add('error');
this.disabled = false;
}
} catch (e) {
this.textContent = '✗ Błąd';
this.classList.add('error');
this.disabled = false;
}
});
});
{% endblock %}

View File

@ -110,6 +110,12 @@
/* Chart */
.chart-container { position: relative; height: 250px; }
/* Reset password button */
.btn-reset-pw { padding: 4px 10px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; white-space: nowrap; transition: var(--transition); vertical-align: middle; }
.btn-reset-pw:hover { background: var(--background); border-color: var(--primary); }
.btn-reset-pw.sent { background: #dcfce7; color: #166534; border-color: #86efac; cursor: default; }
.btn-reset-pw.error { background: #fef2f2; color: #991b1b; border-color: #fca5a5; }
@media (max-width: 768px) {
.profile-header { flex-direction: column; }
.gauges-row { flex-direction: row; gap: var(--spacing-md); }
@ -129,7 +135,10 @@
<div class="profile-header">
<div class="profile-info">
<div class="profile-name">{{ user.name or 'Bez nazwy' }}</div>
<div class="profile-email">{{ user.email }}</div>
<div class="profile-email">
{{ user.email }}
<button class="btn-reset-pw" data-uid="{{ user.id }}" data-name="{{ user.name or user.email }}" data-email="{{ user.email }}" style="margin-left: 12px;">📧 Wyślij reset hasła</button>
</div>
<div class="profile-meta">
{% if user.company %}
<div class="profile-meta-item">
@ -435,4 +444,37 @@
}
}
});
// Password reset button
document.querySelectorAll('.btn-reset-pw').forEach(function(btn) {
btn.addEventListener('click', async function() {
const uid = this.dataset.uid;
const name = this.dataset.name;
const email = this.dataset.email;
if (!confirm('Wysłać reset hasła do ' + name + ' (' + email + ')?')) return;
this.disabled = true;
this.textContent = '⏳...';
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/admin/user-insights/send-reset/' + uid, {
method: 'POST',
headers: {'X-CSRFToken': csrfToken}
});
const data = await resp.json();
if (data.success) {
this.textContent = '✓ Wysłano';
this.classList.add('sent');
} else {
this.textContent = '✗ ' + (data.error || 'Błąd');
this.classList.add('error');
this.disabled = false;
}
} catch (e) {
this.textContent = '✗ Błąd';
this.classList.add('error');
this.disabled = false;
}
});
});
{% endblock %}