Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b280744ff7 | ||
|
|
aedda06f2e | ||
|
|
91bb8b9b2d | ||
|
|
84ae0b6c7d |
@@ -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 {
|
||||
|
||||
@@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password">Password (8+ characters)</label>
|
||||
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()"
|
||||
<div class="relative"><input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
|
||||
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password2">Password again</label>
|
||||
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()"
|
||||
<div class="relative"><input id="password2" name="password2" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password2()"
|
||||
(ngModelChange)="password2.set($event)" required autocomplete="new-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
|
||||
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button></div>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
||||
@@ -79,6 +87,7 @@ export class RegisterPage {
|
||||
|
||||
readonly loaded = signal(false);
|
||||
readonly enabled = signal(false);
|
||||
readonly show = signal(false);
|
||||
readonly domains = signal<string[]>([]);
|
||||
readonly domain = signal('');
|
||||
readonly local = signal('');
|
||||
|
||||
@@ -5,25 +5,43 @@ 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({
|
||||
selector: 'app-mailboxes-section',
|
||||
imports: [FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
// ячейка сетки настроек — сам элемент компонента: на две колонки
|
||||
host: { class: 'block xl:col-span-2' },
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title">
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="mailboxes-title">
|
||||
<h2 id="mailboxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
|
||||
</h2>
|
||||
|
||||
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
|
||||
<div><label class="synth-label" for="mb-email">Address</label>
|
||||
<input id="mb-email" name="email" class="synth-input" [(ngModel)]="email" [placeholder]="'no-reply@' + domain()" autocomplete="off" required></div>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input id="mb-email" name="email" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
|
||||
placeholder="no-reply" autocomplete="off" required>
|
||||
<span class="flex items-center text-cyan-300 font-mono">@</span>
|
||||
@if (domains().length > 1) {
|
||||
<select name="domain" class="synth-input !w-auto" [ngModel]="domain()" (ngModelChange)="domain.set($event)" aria-label="Domain">
|
||||
@for (d of domains(); track d) { <option [value]="d">{{ d }}</option> }
|
||||
</select>
|
||||
} @else {
|
||||
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200 whitespace-nowrap">{{ domain() }}</span>
|
||||
}
|
||||
</div></div>
|
||||
<div><label class="synth-label" for="mb-name">Name</label>
|
||||
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
|
||||
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
|
||||
<input id="mb-password" name="password" type="password" class="synth-input" [(ngModel)]="password" autocomplete="new-password"></div>
|
||||
<div class="relative"><input id="mb-password" name="password" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="password" autocomplete="new-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button></div></div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
|
||||
</form>
|
||||
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||
@@ -46,6 +64,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,19 +84,27 @@ 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);
|
||||
readonly error = signal('');
|
||||
readonly busy = signal(false);
|
||||
readonly me = computed(() => this.state.profile()?.email ?? '');
|
||||
readonly domain = computed(() => this.me().split('@')[1] ?? 'example.com');
|
||||
|
||||
email = '';
|
||||
readonly showPw = signal(false);
|
||||
readonly local = signal('');
|
||||
// Домены сервера (/api/config); пока не пришли — домен своего ящика
|
||||
readonly domains = signal<string[]>([]);
|
||||
readonly domain = signal('');
|
||||
name = '';
|
||||
password = '';
|
||||
|
||||
constructor() {
|
||||
this.domain.set(this.me().split('@')[1] ?? '');
|
||||
this.api.config().subscribe({
|
||||
next: c => { this.domains.set(c.domains); if (!c.domains.includes(this.domain())) { this.domain.set(c.domains[0] ?? ''); } },
|
||||
error: () => {}
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -87,14 +117,16 @@ export class MailboxesSection {
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const email = this.email.trim();
|
||||
if (!email) { this.error.set('Enter an address'); return; }
|
||||
const local = this.local().replace(/@.*$/, '');
|
||||
if (!local) { this.error.set('Enter an address'); return; }
|
||||
const email = local + '@' + this.domain();
|
||||
this.error.set('');
|
||||
this.busy.set(true);
|
||||
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
|
||||
next: created => {
|
||||
this.busy.set(false);
|
||||
this.email = this.name = this.password = '';
|
||||
this.local.set('');
|
||||
this.name = this.password = '';
|
||||
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
|
||||
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
|
||||
this.load();
|
||||
@@ -111,6 +143,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(),
|
||||
|
||||
@@ -42,11 +42,23 @@ import { MailboxesSection } from './mailboxes.section';
|
||||
<form (ngSubmit)="changePassword()" class="space-y-3">
|
||||
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||
<div><label class="synth-label" for="pw-current">Current password</label>
|
||||
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div>
|
||||
<div class="relative"><input id="pw-current" name="current" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="current" autocomplete="current-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button></div></div>
|
||||
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
|
||||
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div>
|
||||
<div class="relative"><input id="pw-new" name="next" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="next" autocomplete="new-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button></div></div>
|
||||
<div><label class="synth-label" for="pw-again">New password again</label>
|
||||
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div>
|
||||
<div class="relative"><input id="pw-again" name="again" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="again" autocomplete="new-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button></div></div>
|
||||
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
|
||||
</form>
|
||||
@@ -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));
|
||||
|
||||
@@ -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