Compare commits

..
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 cf87b60b38 Роли ADMIN и USER, User panel и Admin panel
continuous-integration/drone/pr Build is passing
- Роль ADMIN = users.is_admin; встроенный администратор (mailserver.admins, по
  умолчанию admin@) всегда ADMIN и получает роль в базе при старте.
- PUT /api/admin/mailboxes/{id}/role {ADMIN|USER}: только ADMIN; не свою, не
  встроенного, общий ящик не может быть ADMIN.
- Admin panel (/admin, только ADMIN): USERS (логины и роли, MAKE ADMIN / MAKE USER),
  MAILBOXES, LISTS. User panel (/settings): профиль, пароль, хранилище, вид, мои ящики.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:18:18 +01:00
Liosha84 402d2c403b Merge pull request 'Левая панель: комбобокс ящиков; добавление — в Настройки; выделение в цвете темы' (#32) from feat/mailbox-combobox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #32
2026-11-07 20:12:14 +01:00
Liosha84 8eb69b32e5 Merge pull request 'Селекты в тёмной теме: выпадающий список не белый' (#31) from fix/select-theme into master
continuous-integration/drone/push Build is passing
Reviewed-on: #31
2026-11-07 20:12:01 +01:00
13 changed files with 215 additions and 18 deletions
@@ -40,7 +40,12 @@ public class MailAdmins {
return admins.contains(key) || granted.contains(key);
}
/** Given / taken by Jambotron (users.is_admin) — loaded at start and kept here on every change. */
/** Built-in administrator from the configuration (admin@ by default): the role can not be taken away. */
public boolean isBuiltIn(String email) {
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
}
/** Role ADMIN in users.is_admin (given by an administrator in Admin panel or by Jambotron) — loaded at start, kept here on every change. */
public void grant(String email, boolean admin) {
String key = email.toLowerCase(Locale.ROOT);
if (admin) {
@@ -6,8 +6,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */
/** At start: role ADMIN (users.is_admin) → {@link MailAdmins}; the built-in administrator gets the role in the database too. */
@Component
public class MailAdminsLoader {
@@ -23,10 +24,13 @@ public class MailAdminsLoader {
@EventListener(ApplicationReadyEvent.class)
public void load() {
users.findAll().filter(user -> user.isAdmin())
users.findAll()
.concatMap(user -> !user.isAdmin() && admins.isBuiltIn(user.getEmail())
? Mono.fromSupplier(() -> { user.setAdmin(true); return user; }).flatMap(users::save) : Mono.just(user))
.filter(user -> user.isAdmin())
.doOnNext(user -> admins.grant(user.getEmail(), true))
.count()
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n),
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage()));
.subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
e -> log.error("Mail administrators not loaded: {}", e.getMessage()));
}
}
@@ -45,8 +45,13 @@ public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
/** @param role ADMIN or USER; @param builtIn the administrator from the configuration — the role stays */
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt) {
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt,
String role, boolean builtIn) {
}
public record RoleRequest(String role) {
}
/** @param password empty — the server generates one and returns it once */
@@ -171,6 +176,30 @@ public class MailboxAdminController {
}));
}
/** Role ADMIN (manages mailboxes, lists, roles — Admin panel) or USER. Not your own, not the built-in administrator. */
@PutMapping("/{id}/role")
public Mono<MailboxView> role(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<RoleRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (!"ADMIN".equals(request.role()) && !"USER".equals(request.role())) {
return Mono.error(bad("Role: ADMIN or USER"));
}
boolean toAdmin = "ADMIN".equals(request.role());
if (user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not change your own role"));
}
if (!toAdmin && admins.isBuiltIn(user.getEmail())) {
return Mono.error(bad("The built-in administrator stays ADMIN"));
}
if (toAdmin && user.isShared()) {
return Mono.error(bad("A shared mailbox has no login of its own — it can not be ADMIN"));
}
return users.setAdmin(user, toAdmin).doOnNext(u -> {
admins.grant(u.getEmail(), toAdmin);
log.warn("Mailbox {} role {} given by administrator {}", u.getEmail(), request.role(), me);
});
}))).flatMap(this::view);
}
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
@@ -237,7 +266,7 @@ public class MailboxAdminController {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail())));
}
private static ResponseStatusException bad(String message) {
@@ -186,6 +186,32 @@ class MailboxAdminControllerTest {
verify(mail, never()).deleteMailbox(admin);
}
@Test
void administratorGivesAndTakesRoleAdminNotOwnNotBuiltInNotShared() {
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setAdmin(i.getArgument(1));
return Mono.just(u);
});
var made = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block();
assertThat(made.role()).isEqualTo("ADMIN");
assertThat(admins.isAdmin("alice@jambocheck.com")).isTrue();
// alice is ADMIN now — she manages roles too, but not the built-in one and not her own
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
var back = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block();
assertThat(back.role()).isEqualTo("USER");
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
alice.setShared(true);
assertThat(status(() -> controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
+2 -1
View File
@@ -15,7 +15,8 @@ export const routes: Routes = [
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
{ path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) }
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) },
{ path: 'admin', loadComponent: () => import('./pages/admin/admin.page').then(m => m.AdminPage) }
]
},
{ path: '**', redirectTo: '' }
+3
View File
@@ -55,6 +55,9 @@ export class ApiService {
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/role`, { role });
}
deleteMailbox(id: number): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
}
+2
View File
@@ -79,6 +79,8 @@ export interface Mailbox {
usedBytes: number;
quotaBytes: number;
createdAt: string;
role: 'ADMIN' | 'USER'; // ADMIN — Admin panel (users, mailboxes, lists)
builtIn: boolean; // administrator from the server configuration — stays ADMIN
}
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MailStateService } from '../../core/mail-state.service';
import { ListsSection } from '../settings/lists.section';
import { MailboxesSection } from '../settings/mailboxes.section';
import { UsersSection } from './users.section';
/** Admin panel (role ADMIN): users and their roles, mailboxes, mailing lists. */
@Component({
selector: 'app-admin',
imports: [MatIconModule, UsersSection, MailboxesSection, ListsSection],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (state.profile(); as p) {
@if (p.admin) {
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
<app-users-section />
<app-mailboxes-section />
<app-lists-section />
</div>
} @else {
<section class="glass-panel static p-6 text-slate-300 font-rajdhani" role="alert">
<mat-icon class="align-middle text-pink-500">lock</mat-icon> Admin panel is only for users with the role ADMIN.
</section>
}
}
`
})
export class AdminPage {
readonly state = inject(MailStateService);
}
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Admin panel → USERS: everyone with a login of their own (not shared mailboxes) and their role — ADMIN or USER. */
@Component({
selector: 'app-users-section',
imports: [MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block xl:col-span-2' },
template: `
<section class="glass-panel static p-5 md:p-6" aria-labelledby="users-title">
<h2 id="users-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-1 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">manage_accounts</mat-icon> USERS
</h2>
<p class="text-xs text-slate-400 font-mono mb-3">ADMIN — this Admin panel: users, mailboxes, lists. USER — own mail only.</p>
@if (error()) { <p class="text-sm text-rose-300 mb-2" role="alert">{{ error() }}</p> }
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-[11px] font-mono text-slate-400 border-b border-cyan-500/20">
<th class="py-2 pr-3 font-normal">User</th>
<th class="py-2 pr-3 font-normal">Name</th>
<th class="py-2 pr-3 font-normal">Role</th>
<th class="py-2 pr-3 font-normal">Status</th>
<th class="py-2 font-normal"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (u of users(); track u.id) {
<tr class="border-b border-cyan-500/10">
<td class="py-2 pr-3 font-mono" [class.text-slate-500]="!u.active">{{ u.email }}</td>
<td class="py-2 pr-3 text-slate-300">{{ u.displayName }}</td>
<td class="py-2 pr-3">
<span class="text-[11px] font-mono px-1.5 rounded border"
[class.role-admin]="u.role === 'ADMIN'" [class.border-slate-500]="u.role !== 'ADMIN'"
[class.text-slate-300]="u.role !== 'ADMIN'">{{ u.role }}</span>
@if (u.builtIn) { <span class="text-[10px] font-mono text-slate-500 ml-1" title="From the server configuration">built-in</span> }
</td>
<td class="py-2 pr-3 text-xs font-mono" [class.text-emerald-300]="u.active" [class.text-slate-500]="!u.active">{{ u.active ? 'active' : 'off' }}</td>
<td class="py-2 text-right whitespace-nowrap">
@if (u.email !== me() && !(u.builtIn && u.role === 'ADMIN')) {
<button type="button" class="ghost-btn !py-1 text-xs" (click)="toggle(u)"
[attr.aria-label]="(u.role === 'ADMIN' ? 'Make USER: ' : 'Make ADMIN: ') + u.email">
<mat-icon>{{ u.role === 'ADMIN' ? 'person' : 'admin_panel_settings' }}</mat-icon>
{{ u.role === 'ADMIN' ? 'MAKE USER' : 'MAKE ADMIN' }}</button>
}
</td>
</tr>
} @empty {
<tr><td colspan="5" class="py-3 text-slate-500 font-mono text-xs">No users</td></tr>
}
</tbody>
</table>
</div>
</section>
`
})
export class UsersSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private readonly all = signal<Mailbox[]>([]);
readonly users = computed(() => this.all().filter(m => !m.shared));
readonly me = computed(() => this.state.profile()?.email ?? '');
readonly error = signal('');
constructor() {
this.load();
}
load(): void {
this.api.mailboxes().subscribe({ next: list => this.all.set(list), error: e => this.error.set(errorText(e)) });
}
toggle(u: Mailbox): void {
const role = u.role === 'ADMIN' ? 'USER' : 'ADMIN';
const text = role === 'ADMIN'
? `${u.email} will manage users, mailboxes and lists (Admin panel).`
: `${u.email} keeps only own mail, no Admin panel.`;
this.dialogs.confirm(`Role ${role} for ${u.email}?`, text, 'Change role', role === 'USER').subscribe(ok => {
if (ok === null) return;
this.api.setMailboxRole(u.id, role).subscribe({
next: () => { this.toasts.ok(u.email + ' is ' + role + ' now'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
});
}
}
@@ -66,7 +66,6 @@ import { AuthService } from '../../core/auth.service';
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<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> }
@if (m.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
@@ -7,14 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
import { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section';
import { ListsSection } from './lists.section';
import { MyMailboxesSection } from './my-mailboxes.section';
/** Profile name, password, storage, look. */
/** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */
@Component({
selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection, MyMailboxesSection],
imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -115,10 +113,6 @@ import { MyMailboxesSection } from './my-mailboxes.section';
<app-my-mailboxes-section />
@if (state.profile()?.admin) {
<app-mailboxes-section />
<app-lists-section />
}
</div>
`
})
@@ -144,6 +144,13 @@ import { ToastService } from '../../ui/toast.service';
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
</a>
}
@if (state.profile()?.admin) {
<a routerLink="/admin" routerLinkActive="nav-active-text"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">admin_panel_settings</mat-icon>Admin panel
</a>
}
</div>
@if (state.profile(); as p) {
@@ -180,7 +187,7 @@ export class ShellComponent {
readonly links = [
{ path: '/contacts', label: 'Contacts', icon: 'contacts' },
{ path: '/blocked', label: 'Blocked senders', icon: 'block' },
{ path: '/settings', label: 'Settings', icon: 'settings' }
{ path: '/settings', label: 'User panel', icon: 'person' }
];
readonly folderActions: RowAction[] = [
+1
View File
@@ -307,6 +307,7 @@ body {
color: var(--primary-color) !important;
}
.nav-active-text { color: var(--primary-color) !important; }
.role-admin { color: var(--primary-color); border-color: color-mix(in srgb, var(--primary-color) 50%, transparent); }
/* Selects: the opened list is drawn by the browser — without this it is white (light OS theme) with our light text */
select { color-scheme: dark; }