diff --git a/app/main.py b/app/main.py index a460cce..8123f10 100644 --- a/app/main.py +++ b/app/main.py @@ -165,6 +165,133 @@ def _send_email(to: str, subject: str, html_body: str) -> None: srv.login(SMTP_USERNAME, SMTP_PASSWORD) srv.sendmail(SMTP_FROM_EMAIL, to, msg.as_string()) + +def _build_light_email(title: str, subhead: str, body_html: str, cta_url: str | None = None, cta_label: str | None = None) -> str: + """Shared light-theme card shell (logo, heading, body, optional button, + footer) for one-off transactional emails that don't need the full + credentials-table layout the access-approval templates have inline. + Those keep their own copies (changing one must not risk the others); + this is for newer, simpler notifications - welcome, password reset, + pilot-granted - so they don't each re-type the whole shell.""" + cta_html = "" + if cta_url: + cta_html = ( + f'
' + f'{cta_label or "Войти в полигон"}' + f'
' + ) + return f""" + + +
+ + + + +
+ MONT +

{title}

+

{subhead}

+
+
+ {body_html} + {cta_html} +
+ Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на RGalyaviev@mont.ru +
+""" + + +def _credentials_table(rows: list[tuple[str, str, bool]]) -> str: + """rows: (label, value, monospace) - renders the same light-theme + label/value table used across access emails.""" + trs = [] + for i, (label, value, mono) in enumerate(rows): + border = "border-top:1px solid #e7ebf0;" if i > 0 else "" + font = "font-family:monospace;" if mono else "" + trs.append( + f'{label}' + f'{value}' + ) + return ( + '' + + "".join(trs) + "
" + ) + + +def _send_welcome_email(user, password: str) -> str: + """Sent once, when an admin manually creates a user on the Users tab - + the only way that person learns their login/password, since the admin + never sees the plaintext (create_user() generates it).""" + body = ( + f'

' + f'Здравствуйте{", " + escape(user.first_name) + "" if user.first_name else ""}!
' + f'Для вас создан аккаунт на Инфраструктурном полигоне MONT.

' + + _credentials_table([ + ("Адрес портала", f'{PORTAL_URL}', False), + ("Логин", escape(user.username), True), + ("Пароль", escape(password), True), + ]) + ) + html = _build_light_email("Доступ к Инфраструктурному полигону MONT", "Для вас создан аккаунт", body, PORTAL_URL) + try: + _send_email(user.username, "Доступ к Инфраструктурному полигону MONT", html) + return "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex), channel="welcome") + return f"Ошибка email: {ex}" + + +def _send_password_reset_email(user, password: str) -> str: + """Sent when an admin resets a user's password from the Users tab - + same reasoning as the welcome email: the plaintext only ever exists + in this message, never in the admin UI.""" + body = ( + f'

' + f'Здравствуйте{", " + escape(user.first_name) + "" if user.first_name else ""}!
' + f'Пароль от вашего аккаунта на полигоне MONT был сброшен администратором.

' + + _credentials_table([ + ("Адрес портала", f'{PORTAL_URL}', False), + ("Логин", escape(user.username), True), + ("Новый пароль", escape(password), True), + ]) + ) + html = _build_light_email("Пароль обновлён", "Инфраструктурный полигон MONT", body, PORTAL_URL) + try: + _send_email(user.username, "Пароль обновлён — полигон MONT", html) + return "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex), channel="password_reset") + return f"Ошибка email: {ex}" + + +def _send_pilot_granted_email(user, service, expires_at) -> str: + """Sent once, the moment a pilot's RDP slot is first assigned to this + user (not on every later tweak of that same assignment - see the + "new grant" check in assign_rdp_slot()). No credentials here: this + user already has an account and password, this just tells them a new + product showed up.""" + expiry_row = ( + f'

Доступ действует до {expires_at.strftime("%d.%m.%Y")}

' + if expires_at else "" + ) + body = ( + f'

' + f'Здравствуйте{", " + escape(user.first_name) + "" if user.first_name else ""}!
' + f'Вам открыт доступ к пилотному проекту {escape(service.name)} на Инфраструктурном полигоне MONT.

' + + expiry_row + ) + html = _build_light_email(f"Доступ к пилоту: {escape(service.name)}", "Новый продукт на полигоне", body, PORTAL_URL, "Открыть полигон") + try: + _send_email(user.username, f"Доступ к пилоту «{service.name}» — полигон MONT", html) + return "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex), channel="pilot_granted") + return f"Ошибка email: {ex}" + + def _tg_api(method: str, payload: dict) -> dict: import urllib.request as _ur import json as _j @@ -2920,6 +3047,7 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep UserServiceAccess.service_id == service.id, ) ) + is_new_grant = access is None if access: access.expires_at = expires_at else: @@ -2928,7 +3056,13 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep slot.assigned_user_id = target_user.id db.commit() audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None) - return {"ok": True, "assigned_user_id": target_user.id} + email_status = None + if is_new_grant: + # Only on a genuinely new grant - not every time the admin tweaks + # the expiry date for someone who already has this pilot, which + # also calls this endpoint. + email_status = _send_pilot_granted_email(target_user, service, expires_at) + return {"ok": True, "assigned_user_id": target_user.id, "email_status": email_status} @app.post("/api/admin/categories") @@ -2972,11 +3106,17 @@ def update_web_pool_size(payload: dict, request: Request, _: User = Depends(requ @app.post("/api/admin/users") def create_user(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): + """The password is never typed by the admin - it's generated here and + mailed to the user (username is their email, same convention as the + self-service approval flow). If the email fails to send, the plaintext + comes back in the response as a one-time fallback so the admin isn't + locked out of handing it over; the frontend only surfaces it then.""" validate_csrf(request) expires_at = dt.datetime.fromisoformat(payload["expires_at"]) + password = _generate_password() user = User( username=payload["username"], - password_hash=hash_password(payload["password"]), + password_hash=hash_password(password), expires_at=expires_at, active=payload.get("active", True), is_admin=payload.get("is_admin", False), @@ -2985,7 +3125,11 @@ def create_user(payload: dict, request: Request, _: User = Depends(require_admin ) db.add(user) db.commit() - return {"id": user.id} + email_status = _send_welcome_email(user, password) + result = {"id": user.id, "email_status": email_status} + if not email_status.startswith("Email отправлен"): + result["password"] = password + return result @app.put("/api/admin/users/{user_id}") @@ -2997,14 +3141,31 @@ def edit_user(user_id: int, payload: dict, request: Request, _: User = Depends(r for key in ["username", "active", "is_admin", "first_name", "last_name"]: if key in payload: setattr(user, key, payload[key]) - if "password" in payload and payload["password"]: - user.password_hash = hash_password(payload["password"]) if "expires_at" in payload: user.expires_at = dt.datetime.fromisoformat(payload["expires_at"]) db.commit() return {"ok": True} +@app.post("/api/admin/users/{user_id}/reset-password") +def reset_user_password(user_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): + """Generates a new password and emails it - the only way a user's + password changes now, mirroring create_user(). Same fallback: if the + email bounces, the plaintext comes back so the admin can pass it on.""" + validate_csrf(request) + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + password = _generate_password() + user.password_hash = hash_password(password) + db.commit() + email_status = _send_password_reset_email(user, password) + result = {"ok": True, "email_status": email_status} + if not email_status.startswith("Email отправлен"): + result["password"] = password + return result + + @app.delete("/api/admin/users/{user_id}") def delete_user(user_id: int, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)): validate_csrf(request) diff --git a/app/templates/admin.html b/app/templates/admin.html index 949f81e..3955f09 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -258,12 +258,12 @@ -
+
@@ -285,11 +285,11 @@
Добавить пользователя
+
Пароль не задаётся вручную — он сгенерируется и уйдёт пользователю на почту (username) сразу после создания.
- + - @@ -960,9 +960,9 @@ document.getElementById('u_first_name').value = firstName || ''; document.getElementById('u_last_name').value = lastName || ''; document.getElementById('u_exp').value = dateFromIso(expiresIso); - document.getElementById('u_pwd').value = ''; document.getElementById('u_active').value = String(active); document.getElementById('u_admin').value = String(isAdmin); + document.getElementById('user_reset_pwd_btn').style.display = ''; markSelected('.user-item', 'data-user-id', id); syncAclForSelectedUser(); } @@ -970,15 +970,17 @@ async function createUser() { const expDate = document.getElementById('new_u_exp').value; if (!expDate) return alert('Выберите дату деактивации'); - await api('/api/admin/users', 'POST', { + const result = await api('/api/admin/users', 'POST', { username: document.getElementById('new_u_name').value, first_name: document.getElementById('new_u_first_name').value, last_name: document.getElementById('new_u_last_name').value, - password: document.getElementById('new_u_pwd').value, expires_at: expiryToApi(expDate), active: document.getElementById('new_u_active').value === 'true', is_admin: document.getElementById('new_u_admin').value === 'true', }); + if (result.password) { + alert(`Письмо не отправлено (${result.email_status}). Пароль: ${result.password}\nПередайте его пользователю вручную.`); + } location.reload(); } @@ -995,12 +997,22 @@ active: document.getElementById('u_active').value === 'true', is_admin: document.getElementById('u_admin').value === 'true', }; - const pwd = document.getElementById('u_pwd').value.trim(); - if (pwd) payload.password = pwd; await api(`/api/admin/users/${id}`, 'PUT', payload); location.reload(); } + async function resetUserPassword() { + const id = document.getElementById('u_id').value; + if (!id) return alert('Выберите пользователя'); + if (!confirm('Сгенерировать новый пароль и отправить его пользователю на почту?')) return; + const result = await api(`/api/admin/users/${id}/reset-password`, 'POST', {}); + if (result.password) { + alert(`Письмо не отправлено (${result.email_status}). Новый пароль: ${result.password}\nПередайте его пользователю вручную.`); + } else { + alert('Новый пароль отправлен пользователю на почту.'); + } + } + async function deleteUser() { const id = document.getElementById('u_id').value; if (!id) return alert('Выберите пользователя'); @@ -1010,9 +1022,10 @@ } function clearUserForm() { - ['u_id','u_name','u_exp','u_pwd'].forEach(id => document.getElementById(id).value = ''); + ['u_id','u_name','u_exp'].forEach(id => document.getElementById(id).value = ''); document.getElementById('u_active').value = 'true'; document.getElementById('u_admin').value = 'false'; + document.getElementById('user_reset_pwd_btn').style.display = 'none'; document.querySelectorAll('.acl_service').forEach(x => x.checked = false); document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item')); } @@ -1283,7 +1296,10 @@ const userId = document.getElementById(`pilot_slot_user_${slotId}`).value; const expInput = document.getElementById(`pilot_slot_exp_${slotId}`); const expires_at = (userId && expInput.value) ? `${expInput.value}T23:59:59+00:00` : null; - await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null, expires_at}); + const result = await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null, expires_at}); + if (result.email_status && !result.email_status.startsWith('Email отправлен')) { + alert(`Слот назначен, но письмо пользователю не ушло: ${result.email_status}`); + } location.reload(); }