From 91bb8b9b2dbd5b6c2030bf93c58ada3ac431b715 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 03:09:29 +0100 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BB=D1=8E=D0=B1=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=8F=D1=89=D0=B8=D0=BA:=20MAILBOXES=20=E2=86=92=20OPEN,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D0=B0=20=C2=AB=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=D1=81=D1=8F=20=D0=B2=20admin@=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/admin/mailboxes/{id}/open — сессия этого ящика без его пароля, только для MAIL_ADMINS; каждое открытие — в лог (кто какой ящик открыл). В веб-почте: кнопка OPEN у ящика, сверху розовая полоса «Вы в ящике X как администратор — Back to admin@». Тесты: +1 (всего 75), ng build OK. Co-Authored-By: Claude Opus 5.5 --- .../controller/MailboxAdminController.java | 21 ++++++++++++- .../MailboxAdminControllerTest.java | 16 +++++++++- frontend/src/app/core/api.service.ts | 3 ++ frontend/src/app/core/auth.service.ts | 31 +++++++++++++++++++ .../app/pages/settings/mailboxes.section.ts | 14 +++++++++ .../src/app/pages/shell/shell.component.ts | 14 +++++++++ 6 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java index 2171a94..7929741 100644 --- a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -3,8 +3,10 @@ package com.mailserver.controller; import com.mailserver.config.MailAdmins; import com.mailserver.config.MailDomains; import com.mailserver.domain.User; +import com.mailserver.dto.LoginResponse; import com.mailserver.service.MailService; import com.mailserver.service.UserService; +import com.mailserver.util.JwtUtils; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.slf4j.Logger; @@ -66,8 +68,10 @@ public class MailboxAdminController { private final MailService mail; private final MailDomains domains; private final MailAdmins admins; + private final JwtUtils jwt; - public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins) { + public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) { + this.jwt = jwt; this.users = users; this.mail = mail; this.domains = domains; @@ -123,6 +127,21 @@ public class MailboxAdminController { }); } + /** + * Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox. + * Every opening is logged — who opened which mailbox. + */ + @PostMapping("/{id}/open") + public Mono open(Mono auth, @PathVariable Long id) { + return admin(auth).flatMap(me -> mailbox(id).map(user -> { + log.warn("Administrator {} opened mailbox {}", me, user.getEmail()); + LoginResponse response = new LoginResponse(); + response.setToken(jwt.generateToken(user.getEmail())); + response.setEmail(user.getEmail()); + return response; + })); + } + /** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */ @PutMapping("/{id}/active") public Mono active(Mono auth, @PathVariable Long id, @RequestBody Mono body) { diff --git a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java index ccb4252..c747880 100644 --- a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java @@ -33,7 +33,8 @@ class MailboxAdminControllerTest { private final MailService mail = mock(MailService.class); private final MailDomains domains = new MailDomains("jambocheck.com", ""); private final MailAdmins admins = new MailAdmins("", "", domains); - private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins); + private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class); + private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt); private final User admin = user(1L, "admin@jambocheck.com"); private final User alice = user(2L, "alice@jambocheck.com"); @@ -43,6 +44,7 @@ class MailboxAdminControllerTest { when(users.all()).thenReturn(Flux.just(alice, admin)); when(users.byId(1L)).thenReturn(Mono.just(admin)); when(users.byId(2L)).thenReturn(Mono.just(alice)); + when(users.byId(99L)).thenReturn(Mono.empty()); when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0)))); when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0))); when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> { @@ -105,6 +107,18 @@ class MailboxAdminControllerTest { .isEqualTo(HttpStatus.BAD_REQUEST); } + @Test + void administratorOpensAnyMailboxOthersCanNot() { + when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token"); + + var session = controller.open(as("admin@jambocheck.com"), 2L).block(); + assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com"); + assertThat(session.getToken()).isEqualTo("alice-token"); + + assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND); + } + private MailboxAdminController.Created create(String me, String email, String password) { return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block(); } diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index d74b53f..9c2ddca 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -25,6 +25,9 @@ export class ApiService { newMailboxPassword(id: number): Observable { return this.http.post(`/api/admin/mailboxes/${id}/password`, {}); } + openMailbox(id: number): Observable<{ token: string; email: string }> { + return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {}); + } setMailboxActive(id: number, active: boolean): Observable { return this.http.put(`/api/admin/mailboxes/${id}/active`, { active }); } diff --git a/frontend/src/app/core/auth.service.ts b/frontend/src/app/core/auth.service.ts index 9945d35..4ea269c 100644 --- a/frontend/src/app/core/auth.service.ts +++ b/frontend/src/app/core/auth.service.ts @@ -12,6 +12,8 @@ export class AuthService { private router = inject(Router); readonly email = signal(read('email')); + /** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */ + readonly openedBy = signal(read('adminEmail')); readonly loggedIn = computed(() => this.email() !== null && read('token') !== null); login(email: string, password: string): Observable { @@ -24,6 +26,32 @@ export class AuthService { token(): string | null { return read('token'); } + /** Administrator opens another mailbox: its session, the admin session kept to come back. */ + openAs(response: TokenResponse): void { + const adminToken = read('token'); + const adminEmail = read('email'); + try { + if (adminToken && adminEmail && !read('adminToken')) { + localStorage.setItem('adminToken', adminToken); + localStorage.setItem('adminEmail', adminEmail); + } + } catch { /* storage blocked */ } + this.store(response); + this.openedBy.set(read('adminEmail')); + } + + /** Back to the administrator's own mailbox. */ + backToAdmin(): void { + const token = read('adminToken'); + const email = read('adminEmail'); + try { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminEmail'); + } catch { /* storage blocked */ } + this.openedBy.set(null); + if (token && email) { this.store({ token, email }); } else { this.clear(); } + } + logout(): void { this.clear(); this.router.navigate(['/login']); @@ -33,8 +61,11 @@ export class AuthService { try { localStorage.removeItem('token'); localStorage.removeItem('email'); + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminEmail'); } catch { /* storage blocked */ } this.email.set(null); + this.openedBy.set(null); } private store(response: TokenResponse): void { diff --git a/frontend/src/app/pages/settings/mailboxes.section.ts b/frontend/src/app/pages/settings/mailboxes.section.ts index ff388d7..848fbf3 100644 --- a/frontend/src/app/pages/settings/mailboxes.section.ts +++ b/frontend/src/app/pages/settings/mailboxes.section.ts @@ -5,6 +5,7 @@ import { ApiService } from '../../core/api.service'; import { MailStateService } from '../../core/mail-state.service'; import { Mailbox, errorText, formatBytes } from '../../core/models'; import { ToastService } from '../../ui/toast.service'; +import { AuthService } from '../../core/auth.service'; /** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */ @Component({ @@ -46,6 +47,10 @@ import { ToastService } from '../../ui/toast.service'; {{ m.displayName }} @if (m.admin) { ADMIN } {{ size(m.usedBytes) }} + @if (m.email !== me()) { + + } @if (m.email !== me()) { + + } +