Compare commits

..
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 b280744ff7 Веб-почта: MAILBOXES на две колонки (col-span на элементе компонента — он и есть ячейка сетки)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:19:30 +01:00
Liosha84andClaude Opus 5.5 aedda06f2e Веб-почта: при создании ящика — только имя, @домен подставляется (выбор, если доменов несколько); глазок «показать пароль» у всех полей пароля
continuous-integration/drone/pr Build is passing
Регистрация, смена пароля, создание ящика (вход уже был).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:11:42 +01:00
Liosha84andClaude Opus 5.5 91bb8b9b2d Администратор открывает любой ящик: MAILBOXES → OPEN, полоса «вернуться в admin@»
continuous-integration/drone/pr Build is passing
POST /api/admin/mailboxes/{id}/open — сессия этого ящика без его пароля, только для
MAIL_ADMINS; каждое открытие — в лог (кто какой ящик открыл). В веб-почте: кнопка OPEN
у ящика, сверху розовая полоса «Вы в ящике X как администратор — Back to admin@».
Тесты: +1 (всего 75), ng build OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:09:29 +01:00
Liosha84 84ae0b6c7d Merge pull request 'README: начальный пароль admin@ — из файла, не из лога' (#19) from docs/password-file-not-log into master
continuous-integration/drone/push Build is passing
Reviewed-on: #19
2026-11-07 02:50:58 +01:00
Liosha84andClaude Opus 5.5 0d0f4800f0 README: пароль admin@jambocheck.com (по решению владельца)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:22 +01:00
Liosha84andClaude Opus 5.5 b009af972a README: где пароли ящиков — SECRETS.md закрытого репозитория Jambotron (этот репозиторий публичный)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:08 +01:00
Liosha84andClaude Opus 5.5 100ab741f3 README: начальный пароль admin@ — из файла в томе, не из лога (лог пропадает при передеплое)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:49:14 +01:00
Liosha84 bdca471b97 Merge pull request 'README: «Забыл пароль — где взять» первым разделом' (#18) from docs/forgot-admin-password into master
continuous-integration/drone/push Build is passing
Reviewed-on: #18
2026-11-07 02:41:25 +01:00
Liosha84andClaude Opus 5.5 639ab0a6e9 README: первым разделом — забыл пароль, где взять (admin@ из логов, сброс, MAILBOXES)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:40:52 +01:00
Liosha84 f1d66d4c7d Merge pull request 'Веб-почта по HTTPS: https://mail.jambocheck.com (порт 443)' (#17) from feat/https-443 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2026-11-07 02:22:03 +01:00
9 changed files with 176 additions and 17 deletions
+17 -1
View File
@@ -2,7 +2,23 @@
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend.
Назначение — наша настоящая почта **https://mail.jambocheck.com** (prod Jambotron, системная ВМ, адрес `103.109.235.154`) и тестовый проект на Jambotron.
## Забыл пароль — где взять
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
| Ситуация | Что делать |
| :--- | :--- |
| Пароль `admin@` ещё не меняли | файл `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет; лежит в томе `data`, передеплой его не стирает). С компьютера: `ssh root@103.109.235.150`, затем `incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt`. В логе `mail` строка `Mailbox created…` есть **только до первого передеплоя** |
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
## Как устроено
@@ -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();
}
+3
View File
@@ -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 });
}
+31
View File
@@ -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 {
+11 -2
View File
@@ -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">&#64;</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();