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
Liosha84andClaude Opus 5.5 3d9fc77201 Левая панель: комбобокс ящиков; добавление ящиков — в Настройки; выделение в цвете темы
continuous-integration/drone/pr Build is passing
- MAILBOX: <select> со своими, общими и добавленными ящиками (непрочитанные в скобках),
  выбор — переключение; список и форма «добавить» из левой панели убраны.
- Settings → MY MAILBOXES (у всех): список ящиков, «убрать» добавленный, «добавить» по паролю.
- Выбранная папка, ссылка и палитра — nav-active в --primary-color темы
  (было фиксированное розовое — в Neon Cyan баклажановое).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:10:54 +01:00
15 changed files with 346 additions and 101 deletions
@@ -40,7 +40,12 @@ public class MailAdmins {
return admins.contains(key) || granted.contains(key); 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) { public void grant(String email, boolean admin) {
String key = email.toLowerCase(Locale.ROOT); String key = email.toLowerCase(Locale.ROOT);
if (admin) { if (admin) {
@@ -6,8 +6,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; 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 @Component
public class MailAdminsLoader { public class MailAdminsLoader {
@@ -23,10 +24,13 @@ public class MailAdminsLoader {
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void load() { 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)) .doOnNext(user -> admins.grant(user.getEmail(), true))
.count() .count()
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n), .subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage())); 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 Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD; 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, 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 */ /** @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@). */ /** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap") @PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) { 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()) 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(), .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(), 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) { private static ResponseStatusException bad(String message) {
@@ -186,6 +186,32 @@ class MailboxAdminControllerTest {
verify(mail, never()).deleteMailbox(admin); 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 @Test
void administratorOpensAnyMailboxOthersCanNot() { void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token"); 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: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
{ path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) }, { 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: '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: '' } { path: '**', redirectTo: '' }
+3
View File
@@ -55,6 +55,9 @@ export class ApiService {
setMailboxActive(id: number, active: boolean): Observable<Mailbox> { setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active }); 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> { deleteMailbox(id: number): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/${id}`); return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
} }
+8 -1
View File
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Folder, Profile } from './models'; import { Folder, MailboxEntry, Profile } from './models';
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */ /** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -9,6 +9,8 @@ export class MailStateService {
readonly folders = signal<Folder[]>([]); readonly folders = signal<Folder[]>([]);
readonly profile = signal<Profile | null>(null); readonly profile = signal<Profile | null>(null);
/** own, shared and attached mailboxes of the login — sidebar combobox, Settings → MY MAILBOXES */
readonly boxes = signal<MailboxEntry[]>([]);
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null); readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0); readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
@@ -28,8 +30,13 @@ export class MailStateService {
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} }); this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
} }
refreshBoxes(): void {
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
}
reset(): void { reset(): void {
this.folders.set([]); this.folders.set([]);
this.profile.set(null); this.profile.set(null);
this.boxes.set([]);
} }
} }
+2
View File
@@ -79,6 +79,8 @@ export interface Mailbox {
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; 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) */ /** 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"> <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="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> <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" @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> } [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" @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"
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { MailStateService } from '../../core/mail-state.service';
import { MailboxEntry, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Settings → MY MAILBOXES (everyone): mailboxes in the sidebar combobox — add one by its password, remove an added one. */
@Component({
selector: 'app-my-mailboxes-section',
imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block' },
template: `
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="my-boxes-title">
<h2 id="my-boxes-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">alternate_email</mat-icon> MY MAILBOXES
</h2>
<ul class="space-y-1 mb-4" aria-label="Your mailboxes">
@for (b of state.boxes(); track b.email) {
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15">
<mat-icon class="!text-lg text-slate-400 shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate font-mono text-sm text-slate-200">{{ b.email }}</span>
<span class="text-[11px] font-mono text-slate-500">{{ b.kind === 'OWN' ? 'own' : b.shared ? 'shared' : 'added' }}</span>
@if (b.kind !== 'OWN') {
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="leave(b)"
[attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'" title="Remove from my mailboxes">
<mat-icon>close</mat-icon></button>
}
</li>
}
</ul>
<form class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-end" (ngSubmit)="attach()" aria-label="Add a mailbox">
<div><label class="synth-label" for="att-email">Mailbox</label>
<input id="att-email" name="attEmail" class="synth-input" [(ngModel)]="email" autocomplete="off" placeholder="name&#64;domain"></div>
<div><label class="synth-label" for="att-pw">Its password</label>
<input id="att-pw" name="attPw" type="password" class="synth-input" [(ngModel)]="password" autocomplete="off"></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
</form>
</section>
`
})
export class MyMailboxesSection {
readonly state = inject(MailStateService);
private api = inject(ApiService);
private auth = inject(AuthService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
email = '';
password = '';
constructor() {
this.state.refreshBoxes();
}
attach(): void {
const email = this.email.trim().toLowerCase();
if (!email || !this.password) { this.toasts.error('Mailbox and its password'); return; }
this.api.attachMailbox(email, this.password).subscribe({
next: b => {
this.email = this.password = '';
this.toasts.ok(b.email + ' added — pick it in the MAILBOX list on the left');
this.state.refreshBoxes();
},
error: e => this.toasts.error(errorText(e))
});
}
leave(b: MailboxEntry): void {
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
? 'It disappears from your list. An administrator can give it back.'
: 'It disappears from your list. Add it again with its password.', 'Remove', true).subscribe(ok => {
if (ok === null) return;
this.api.leaveMailbox(b.email).subscribe({
next: () => {
this.toasts.ok(b.email + ' removed');
if (b.email === this.auth.email()) {
// removed the one we are in — back to the own mailbox
this.api.mailboxSession(this.state.boxes().find(x => x.kind === 'OWN')?.email ?? '').subscribe({
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
error: () => location.assign('/')
});
return;
}
this.state.refreshBoxes();
},
error: e => this.toasts.error(errorText(e))
});
});
}
}
@@ -7,13 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
import { errorText, formatBytes } from '../../core/models'; import { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service'; import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section'; import { MyMailboxesSection } from './my-mailboxes.section';
import { ListsSection } from './lists.section';
/** Profile name, password, storage, look. */ /** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection], imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -93,7 +92,7 @@ import { ListsSection } from './lists.section';
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@for (t of themes; track t.value) { @for (t of themes; track t.value) {
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value" <button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value"
[class.!border-pink-500]="theme.theme() === t.value" [class.!text-pink-300]="theme.theme() === t.value" [class.nav-active]="theme.theme() === t.value"
(click)="theme.theme.set(t.value)">{{ t.label }}</button> (click)="theme.theme.set(t.value)">{{ t.label }}</button>
} }
</div> </div>
@@ -112,10 +111,8 @@ import { ListsSection } from './lists.section';
</div> </div>
</section> </section>
@if (state.profile()?.admin) { <app-my-mailboxes-section />
<app-mailboxes-section />
<app-lists-section />
}
</div> </div>
` `
}) })
+24 -82
View File
@@ -81,43 +81,15 @@ import { ToastService } from '../../ui/toast.service';
<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"> <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 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()" <nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders"> aria-label="Mail folders">
<div class="flex items-center justify-between mb-2 px-1"> <label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2> <select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of" (ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())"> @for (b of state.boxes(); track b.email) {
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button> <option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
</div> } @empty {
<ul class="space-y-1 mb-3" aria-label="Your mailboxes"> <option [value]="auth.email()">{{ auth.email() }}</option>
@for (b of boxes(); track b.email) {
<li class="flex items-center gap-1">
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
[class.text-slate-300]="b.email !== auth.email()"
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
@if (b.unread) {
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
}
</button>
@if (b.kind !== 'OWN') {
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
}
</li>
} }
</ul> </select>
@if (attachOpen()) {
<form class="space-y-2 mb-4 p-2 rounded-lg border border-cyan-500/20" (ngSubmit)="attach()" aria-label="Add a mailbox">
<label class="synth-label" for="att-email">Mailbox</label>
<input id="att-email" name="attEmail" class="synth-input !py-1.5" [(ngModel)]="attachEmail" autocomplete="off" placeholder="name&#64;domain">
<label class="synth-label" for="att-pw">Its password</label>
<input id="att-pw" name="attPw" type="password" class="synth-input !py-1.5" [(ngModel)]="attachPassword" autocomplete="off">
<button type="submit" class="cyber-btn w-full py-1.5 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
</form>
}
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)"> <a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
<mat-icon>edit</mat-icon> COMPOSE <mat-icon>edit</mat-icon> COMPOSE
@@ -126,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1"> <ul class="space-y-1">
@for (folder of systemFolders(); track folder.id) { @for (folder of systemFolders(); track folder.id) {
<li> <li>
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40" <a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold" class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon> <mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
@@ -148,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1"> <ul class="space-y-1">
@for (folder of ownFolders(); track folder.id) { @for (folder of ownFolders(); track folder.id) {
<li class="flex items-center gap-1"> <li class="flex items-center gap-1">
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40" <a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0" class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0"
(click)="menuOpen.set(false)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">folder</mat-icon> <mat-icon class="!text-xl">folder</mat-icon>
@@ -166,12 +138,19 @@ import { ToastService } from '../../ui/toast.service';
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1"> <div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
@for (link of links; track link.path) { @for (link of links; track link.path) {
<a [routerLink]="link.path" routerLinkActive="!text-pink-300" <a [routerLink]="link.path" 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" 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)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }} <mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
</a> </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> </div>
@if (state.profile(); as p) { @if (state.profile(); as p) {
@@ -203,17 +182,12 @@ export class ShellComponent {
readonly themes = THEMES; readonly themes = THEMES;
readonly menuOpen = signal(false); readonly menuOpen = signal(false);
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
readonly boxes = signal<MailboxEntry[]>([]);
readonly attachOpen = signal(false);
attachEmail = '';
attachPassword = '';
query = ''; query = '';
readonly links = [ readonly links = [
{ path: '/contacts', label: 'Contacts', icon: 'contacts' }, { path: '/contacts', label: 'Contacts', icon: 'contacts' },
{ path: '/blocked', label: 'Blocked senders', icon: 'block' }, { path: '/blocked', label: 'Blocked senders', icon: 'block' },
{ path: '/settings', label: 'Settings', icon: 'settings' } { path: '/settings', label: 'User panel', icon: 'person' }
]; ];
readonly folderActions: RowAction[] = [ readonly folderActions: RowAction[] = [
@@ -238,8 +212,8 @@ export class ShellComponent {
constructor() { constructor() {
this.state.refreshFolders(); this.state.refreshFolders();
this.state.refreshProfile(); this.state.refreshProfile();
this.loadBoxes(); this.state.refreshBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000); const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 30000);
inject(DestroyRef).onDestroy(() => clearInterval(timer)); inject(DestroyRef).onDestroy(() => clearInterval(timer));
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => { this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const q = this.router.parseUrl(this.router.url).queryParams['q']; const q = this.router.parseUrl(this.router.url).queryParams['q'];
@@ -300,10 +274,6 @@ export class ShellComponent {
} }
} }
loadBoxes(): void {
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
}
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */ /** Another mailbox of this login: its session, full reload — fresh folders and profile. */
switchTo(b: MailboxEntry): void { switchTo(b: MailboxEntry): void {
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; } if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
@@ -313,37 +283,9 @@ export class ShellComponent {
}); });
} }
attach(): void { pick(email: string): void {
const email = this.attachEmail.trim().toLowerCase(); const b = this.state.boxes().find(x => x.email === email);
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; } if (b) this.switchTo(b);
this.api.attachMailbox(email, this.attachPassword).subscribe({
next: b => {
this.attachEmail = this.attachPassword = '';
this.attachOpen.set(false);
this.toasts.ok(b.email + ' added to your mailboxes');
this.loadBoxes();
},
error: e => this.toasts.error(errorText(e))
});
}
leave(b: MailboxEntry): void {
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
? 'It disappears from your menu. An administrator can give it back.'
: 'It disappears from your menu. Add it again with its password.', 'Remove', true).subscribe(ok => {
if (ok === null) return;
this.api.leaveMailbox(b.email).subscribe({
next: () => {
this.toasts.ok(b.email + ' removed');
if (b.email === this.auth.email()) {
const own = this.boxes().find(x => x.kind === 'OWN');
if (own) { this.switchTo(own); return; }
}
this.loadBoxes();
},
error: e => this.toasts.error(errorText(e))
});
});
} }
backToAdmin(): void { backToAdmin(): void {
+9
View File
@@ -300,6 +300,15 @@ body {
} }
.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); } .synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); }
.synth-input::placeholder { color: #64748b; } .synth-input::placeholder { color: #64748b; }
/* Selected item of the sidebar (folder, link) — in the colour of the theme, not a fixed pink */
.nav-active {
background-color: color-mix(in srgb, var(--primary-color) 18%, transparent) !important;
border-color: color-mix(in srgb, var(--primary-color) 45%, transparent) !important;
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 */ /* 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; } select { color-scheme: dark; }
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; } select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }