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/auth/register.page.ts b/frontend/src/app/pages/auth/register.page.ts index d121f9b..beebd82 100644 --- a/frontend/src/app/pages/auth/register.page.ts +++ b/frontend/src/app/pages/auth/register.page.ts @@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
- +
- +
@if (error()) {
-
+
+ + @ + @if (domains().length > 1) { + + } @else { + {{ domain() }} + } +
-
+
+
@if (error()) { } @@ -46,6 +62,10 @@ import { ToastService } from '../../ui/toast.service'; {{ m.displayName }} @if (m.admin) { ADMIN } {{ size(m.usedBytes) }} + @if (m.email !== me()) { + + } @if (m.email !== me()) {
-
+
+
-
+
+
@if (passwordError()) { } @@ -117,6 +129,7 @@ export class SettingsPage { next = ''; again = ''; readonly passwordError = signal(''); + readonly showPw = signal(false); readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes)); readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes)); diff --git a/frontend/src/app/pages/shell/shell.component.ts b/frontend/src/app/pages/shell/shell.component.ts index e27b664..4a68014 100644 --- a/frontend/src/app/pages/shell/shell.component.ts +++ b/frontend/src/app/pages/shell/shell.component.ts @@ -69,6 +69,15 @@ import { ToastService } from '../../ui/toast.service'; + @if (auth.openedBy(); as admin) { +
+ visibility + You are in mailbox {{ auth.email() }} as administrator + +
+ } +