Роли 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>
This commit is contained in:
2026-11-07 20:18:18 +01:00
co-authored by Claude Opus 5.5
parent 402d2c403b
commit cf87b60b38
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) {