Администратор открывает любой ящик: MAILBOXES → OPEN, полоса «вернуться в admin@»
continuous-integration/drone/pr Build is passing
continuous-integration/drone/pr Build is passing
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<LoginResponse> open(Mono<Authentication> 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<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ export class ApiService {
|
||||
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>(`/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<Mailbox> {
|
||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export class AuthService {
|
||||
private router = inject(Router);
|
||||
|
||||
readonly email = signal<string | null>(read('email'));
|
||||
/** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */
|
||||
readonly openedBy = signal<string | null>(read('adminEmail'));
|
||||
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
|
||||
|
||||
login(email: string, password: string): Observable<TokenResponse> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
|
||||
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
|
||||
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
||||
@if (m.email !== me()) {
|
||||
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
|
||||
<mat-icon>mark_email_read</mat-icon> OPEN</button>
|
||||
}
|
||||
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
|
||||
@if (m.email !== me()) {
|
||||
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
|
||||
@@ -62,6 +67,7 @@ export class MailboxesSection {
|
||||
private api = inject(ApiService);
|
||||
private state = inject(MailStateService);
|
||||
private toasts = inject(ToastService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
readonly mailboxes = signal<Mailbox[]>([]);
|
||||
readonly secret = signal<{ email: string; password: string } | null>(null);
|
||||
@@ -111,6 +117,14 @@ export class MailboxesSection {
|
||||
});
|
||||
}
|
||||
|
||||
/** Administrator reads this mailbox: its session; the banner on top leads back. Full reload — fresh folders. */
|
||||
open(m: Mailbox): void {
|
||||
this.api.openMailbox(m.id).subscribe({
|
||||
next: session => { this.auth.openAs(session); location.assign('/'); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
toggle(m: Mailbox): void {
|
||||
this.api.setMailboxActive(m.id, !m.active).subscribe({
|
||||
next: () => this.load(),
|
||||
|
||||
@@ -69,6 +69,15 @@ import { ToastService } from '../../ui/toast.service';
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (auth.openedBy(); as admin) {
|
||||
<div class="bg-pink-600/90 text-white text-sm font-rajdhani px-4 py-2 flex flex-wrap items-center justify-center gap-3" role="status">
|
||||
<mat-icon class="!text-base">visibility</mat-icon>
|
||||
<span>You are in mailbox <b class="font-mono">{{ auth.email() }}</b> as administrator</span>
|
||||
<button type="button" class="ghost-btn !py-1 !border-white !text-white" (click)="backToAdmin()">
|
||||
<mat-icon>arrow_back</mat-icon> Back to {{ admin }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
|
||||
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
|
||||
aria-label="Mail folders">
|
||||
@@ -247,6 +256,11 @@ export class ShellComponent {
|
||||
}
|
||||
}
|
||||
|
||||
backToAdmin(): void {
|
||||
this.auth.backToAdmin();
|
||||
location.assign('/settings');
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.state.reset();
|
||||
this.auth.logout();
|
||||
|
||||
Reference in New Issue
Block a user