diff --git a/backend/src/main/java/com/mailserver/config/MailAdmins.java b/backend/src/main/java/com/mailserver/config/MailAdmins.java index 14f24f5..25ec7b4 100644 --- a/backend/src/main/java/com/mailserver/config/MailAdmins.java +++ b/backend/src/main/java/com/mailserver/config/MailAdmins.java @@ -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) { diff --git a/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java b/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java index 325b36a..68588bb 100644 --- a/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java +++ b/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java @@ -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())); } } diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java index 6e66e87..2bb5df4 100644 --- a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -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 members, long usedBytes, long quotaBytes, Instant createdAt) { + boolean shared, List 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 role(Mono auth, @PathVariable Long id, @RequestBody Mono 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 trap(Mono auth, @PathVariable Long id, @RequestBody Mono 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) { diff --git a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java index 611cd92..055c4bf 100644 --- a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java @@ -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"); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 8e926e4..92714e3 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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: '' } diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index 49a55ce..e0410f5 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -55,6 +55,9 @@ export class ApiService { setMailboxActive(id: number, active: boolean): Observable { return this.http.put(`/api/admin/mailboxes/${id}/active`, { active }); } + setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable { + return this.http.put(`/api/admin/mailboxes/${id}/role`, { role }); + } deleteMailbox(id: number): Observable { return this.http.delete(`/api/admin/mailboxes/${id}`); } diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index e92f304..41566a9 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -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) */ diff --git a/frontend/src/app/pages/admin/admin.page.ts b/frontend/src/app/pages/admin/admin.page.ts new file mode 100644 index 0000000..7adf077 --- /dev/null +++ b/frontend/src/app/pages/admin/admin.page.ts @@ -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) { +
+ + + +
+ } @else { + + } + } + ` +}) +export class AdminPage { + readonly state = inject(MailStateService); +} diff --git a/frontend/src/app/pages/admin/users.section.ts b/frontend/src/app/pages/admin/users.section.ts new file mode 100644 index 0000000..3b63ee8 --- /dev/null +++ b/frontend/src/app/pages/admin/users.section.ts @@ -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: ` +
+

+ manage_accounts USERS +

+

ADMIN — this Admin panel: users, mailboxes, lists. USER — own mail only.

+ @if (error()) { } +
+ + + + + + + + + + + + @for (u of users(); track u.id) { + + + + + + + + } @empty { + + } + +
UserNameRoleStatusActions
{{ u.email }}{{ u.displayName }} + {{ u.role }} + @if (u.builtIn) { built-in } + {{ u.active ? 'active' : 'off' }} + @if (u.email !== me() && !(u.builtIn && u.role === 'ADMIN')) { + + } +
No users
+
+
+ ` +}) +export class UsersSection { + private api = inject(ApiService); + private state = inject(MailStateService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + + private readonly all = signal([]); + 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)) + }); + }); + } +} diff --git a/frontend/src/app/pages/settings/mailboxes.section.ts b/frontend/src/app/pages/settings/mailboxes.section.ts index c87ffd0..5a002b1 100644 --- a/frontend/src/app/pages/settings/mailboxes.section.ts +++ b/frontend/src/app/pages/settings/mailboxes.section.ts @@ -66,7 +66,6 @@ import { AuthService } from '../../core/auth.service';
  • {{ m.email }} {{ m.displayName }} - @if (m.admin) { ADMIN } @if (m.shared) { SHARED · {{ m.members.length }} } @if (!m.shared && m.members.length) { @@ -115,10 +113,6 @@ import { MyMailboxesSection } from './my-mailboxes.section'; - @if (state.profile()?.admin) { - - - } ` }) diff --git a/frontend/src/app/pages/shell/shell.component.ts b/frontend/src/app/pages/shell/shell.component.ts index e46d81c..bf662e3 100644 --- a/frontend/src/app/pages/shell/shell.component.ts +++ b/frontend/src/app/pages/shell/shell.component.ts @@ -144,6 +144,13 @@ import { ToastService } from '../../ui/toast.service'; {{ link.icon }}{{ link.label }} } + @if (state.profile()?.admin) { + + admin_panel_settingsAdmin panel + + } @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[] = [ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d76eb78..164d9e6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }