Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
continuous-integration/drone/pr Build is passing
continuous-integration/drone/pr Build is passing
- V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by
- /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю
(LoginGuard), убрать из меню
- общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by
- SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot)
- открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403)
- админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address};
выключение аккаунта убирает и из общих ящиков
- UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках,
«Answered by» у письма
- 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models';
|
||||
|
||||
/** The mail server REST API (same origin, /api). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -19,8 +19,22 @@ export class ApiService {
|
||||
|
||||
// mailboxes — administrators only
|
||||
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
|
||||
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
|
||||
createMailbox(email: string, displayName: string, password: string, shared = false): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password, shared });
|
||||
}
|
||||
setMailboxMembers(id: number, members: string[]): Observable<Mailbox> {
|
||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/members`, { members });
|
||||
}
|
||||
// the logged-in user's mailboxes (left menu)
|
||||
myMailboxes(): Observable<MailboxEntry[]> { return this.http.get<MailboxEntry[]>('/api/mailboxes'); }
|
||||
mailboxSession(email: string): Observable<{ token: string; email: string }> {
|
||||
return this.http.post<{ token: string; email: string }>(`/api/mailboxes/${encodeURIComponent(email)}/session`, {});
|
||||
}
|
||||
attachMailbox(email: string, password: string): Observable<MailboxEntry> {
|
||||
return this.http.post<MailboxEntry>('/api/mailboxes/attach', { email, password });
|
||||
}
|
||||
leaveMailbox(email: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/mailboxes/${encodeURIComponent(email)}`);
|
||||
}
|
||||
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
|
||||
@@ -67,8 +81,8 @@ export class ApiService {
|
||||
// compose
|
||||
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
|
||||
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
|
||||
send(draft: Draft, draftId: number | null): Observable<Message> {
|
||||
return this.http.post<Message>('/api/send', { ...draft, draftId });
|
||||
send(draft: Draft, draftId: number | null, replyToId: number | null = null): Observable<Message> {
|
||||
return this.http.post<Message>('/api/send', { ...draft, draftId, replyToId });
|
||||
}
|
||||
|
||||
// attachments
|
||||
|
||||
@@ -40,6 +40,18 @@ export class AuthService {
|
||||
this.openedBy.set(read('adminEmail'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to another mailbox of the same login (left menu). Going back to the mailbox an administrator came
|
||||
* from ends the administrator's visit.
|
||||
*/
|
||||
switchTo(response: TokenResponse): void {
|
||||
if (this.openedBy() && response.email === this.openedBy()) {
|
||||
this.backToAdmin();
|
||||
return;
|
||||
}
|
||||
this.store(response);
|
||||
}
|
||||
|
||||
/** Back to the administrator's own mailbox. */
|
||||
backToAdmin(): void {
|
||||
const token = read('adminToken');
|
||||
|
||||
@@ -23,6 +23,16 @@ export interface Message {
|
||||
receivedAt: string;
|
||||
hasAttachments: boolean;
|
||||
attachmentCount: number;
|
||||
answeredBy?: string | null; // login that answered it from the web client (shared mailboxes)
|
||||
}
|
||||
|
||||
/** MailboxAccess.Entry — a mailbox of the logged-in user (left menu) */
|
||||
export interface MailboxEntry {
|
||||
email: string;
|
||||
displayName: string;
|
||||
kind: 'OWN' | 'MEMBER' | 'ATTACHED';
|
||||
shared: boolean;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
@@ -63,7 +73,9 @@ export interface Mailbox {
|
||||
displayName: string;
|
||||
active: boolean;
|
||||
admin: boolean;
|
||||
trap: boolean; // what it sends stays in its own INBOX, nothing leaves the server (staging@)
|
||||
trap: boolean; // what it sends to outside addresses stays in its own INBOX (staging@)
|
||||
shared: boolean; // shared mailbox: no login of its own, members open it
|
||||
members: string[]; // who may open it
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -141,6 +141,8 @@ export class ComposePage {
|
||||
readonly body = signal('');
|
||||
readonly showCc = signal(false);
|
||||
readonly draftId = signal<number | null>(null);
|
||||
/** the message being answered: marked answered (and by whom) when this one is sent */
|
||||
private replyToId: number | null = null;
|
||||
readonly attachments = signal<Attachment[]>([]);
|
||||
readonly dirty = signal(false);
|
||||
readonly saving = signal(false);
|
||||
@@ -207,6 +209,7 @@ export class ComposePage {
|
||||
}
|
||||
|
||||
private fromReply(m: Message, all: boolean): void {
|
||||
this.replyToId = m.id;
|
||||
const me = (this.state.profile()?.email ?? '').toLowerCase();
|
||||
this.to.set(m.senderEmail);
|
||||
if (all) {
|
||||
@@ -306,7 +309,7 @@ export class ComposePage {
|
||||
const go = () => {
|
||||
this.sending.set(true);
|
||||
this.error.set('');
|
||||
this.api.send(this.current(), this.draftId()).subscribe({
|
||||
this.api.send(this.current(), this.draftId(), this.replyToId).subscribe({
|
||||
next: () => {
|
||||
this.done = true;
|
||||
this.toasts.ok('Message sent');
|
||||
|
||||
@@ -73,6 +73,10 @@ import { ToastService } from '../../ui/toast.service';
|
||||
}
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
|
||||
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
|
||||
@if (m.answeredBy) {
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Answered</dt>
|
||||
<dd class="text-emerald-300 font-mono text-xs pt-0.5 flex items-center gap-1"><mat-icon class="!text-sm">reply</mat-icon> by {{ m.answeredBy }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ import { AuthService } from '../../core/auth.service';
|
||||
<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>
|
||||
<label class="md:col-span-4 flex items-center gap-1.5 text-sm text-slate-300">
|
||||
<input type="checkbox" name="shared" [(ngModel)]="shared">
|
||||
shared mailbox (support@, billing@): no password of its own — members open it from their mailbox, read/unread is common
|
||||
</label>
|
||||
</form>
|
||||
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||
|
||||
@@ -63,6 +67,10 @@ import { AuthService } from '../../core/auth.service';
|
||||
<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"
|
||||
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
|
||||
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
|
||||
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</span> }
|
||||
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
||||
@@ -70,16 +78,38 @@ import { AuthService } from '../../core/auth.service';
|
||||
<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)="editMembers(m)" [attr.aria-expanded]="membersOf() === m.id"
|
||||
[attr.aria-label]="'Who may open ' + m.email" title="Who may open this mailbox"><mat-icon>group</mat-icon></button>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="trap(m)" [class.text-amber-300]="m.trap"
|
||||
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
|
||||
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
|
||||
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></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.shared) {
|
||||
<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)"
|
||||
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
|
||||
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
|
||||
}
|
||||
@if (membersOf() === m.id) {
|
||||
<div class="basis-full mt-1 p-2 rounded-lg border border-cyan-500/20">
|
||||
<div class="text-xs text-slate-400 mb-1">Who may open <span class="font-mono text-cyan-300">{{ m.email }}</span> from their own mailbox:</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
@for (o of mailboxes(); track o.id) {
|
||||
@if (o.id !== m.id && !o.shared) {
|
||||
<label class="flex items-center gap-1.5 text-sm font-mono">
|
||||
<input type="checkbox" [checked]="picked().has(o.email)" (change)="pick(o.email)"> {{ o.email }}
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="button" class="cyber-btn px-3 py-1 rounded-lg text-xs font-bold" (click)="saveMembers(m)"><mat-icon>save</mat-icon> SAVE</button>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="membersOf.set(null)">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -104,6 +134,10 @@ export class MailboxesSection {
|
||||
readonly domain = signal('');
|
||||
name = '';
|
||||
password = '';
|
||||
shared = false;
|
||||
/** mailbox whose members are being edited */
|
||||
readonly membersOf = signal<number | null>(null);
|
||||
readonly picked = signal<Set<string>>(new Set());
|
||||
|
||||
constructor() {
|
||||
this.domain.set(this.me().split('@')[1] ?? '');
|
||||
@@ -128,11 +162,12 @@ export class MailboxesSection {
|
||||
const email = local + '@' + this.domain();
|
||||
this.error.set('');
|
||||
this.busy.set(true);
|
||||
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
|
||||
this.api.createMailbox(email, this.name.trim(), this.password, this.shared).subscribe({
|
||||
next: created => {
|
||||
this.busy.set(false);
|
||||
this.local.set('');
|
||||
this.name = this.password = '';
|
||||
this.shared = false;
|
||||
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
|
||||
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
|
||||
this.load();
|
||||
@@ -166,6 +201,25 @@ export class MailboxesSection {
|
||||
});
|
||||
}
|
||||
|
||||
editMembers(m: Mailbox): void {
|
||||
if (this.membersOf() === m.id) { this.membersOf.set(null); return; }
|
||||
this.picked.set(new Set(m.members));
|
||||
this.membersOf.set(m.id);
|
||||
}
|
||||
|
||||
pick(email: string): void {
|
||||
const next = new Set(this.picked());
|
||||
if (next.has(email)) { next.delete(email); } else { next.add(email); }
|
||||
this.picked.set(next);
|
||||
}
|
||||
|
||||
saveMembers(m: Mailbox): void {
|
||||
this.api.setMailboxMembers(m.id, [...this.picked()]).subscribe({
|
||||
next: () => { this.toasts.ok('Members of ' + m.email + ' saved'); this.membersOf.set(null); this.load(); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
toggle(m: Mailbox): void {
|
||||
this.api.setMailboxActive(m.id, !m.active).subscribe({
|
||||
next: () => this.load(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { filter } from 'rxjs';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { Folder, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
|
||||
import { Folder, MailboxEntry, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
|
||||
import { BackgroundComponent } from '../../ui/background.component';
|
||||
import { DialogService } from '../../ui/dialog.service';
|
||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||
@@ -81,6 +81,44 @@ 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">
|
||||
<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">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
|
||||
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
|
||||
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
|
||||
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
|
||||
</div>
|
||||
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
|
||||
@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>
|
||||
@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@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)">
|
||||
<mat-icon>edit</mat-icon> COMPOSE
|
||||
</a>
|
||||
@@ -165,6 +203,11 @@ export class ShellComponent {
|
||||
|
||||
readonly themes = THEMES;
|
||||
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 = '';
|
||||
|
||||
readonly links = [
|
||||
@@ -195,7 +238,8 @@ export class ShellComponent {
|
||||
constructor() {
|
||||
this.state.refreshFolders();
|
||||
this.state.refreshProfile();
|
||||
const timer = setInterval(() => this.state.refreshFolders(), 30000);
|
||||
this.loadBoxes();
|
||||
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
||||
const q = this.router.parseUrl(this.router.url).queryParams['q'];
|
||||
@@ -256,6 +300,52 @@ 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. */
|
||||
switchTo(b: MailboxEntry): void {
|
||||
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
|
||||
this.api.mailboxSession(b.email).subscribe({
|
||||
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
const email = this.attachEmail.trim().toLowerCase();
|
||||
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
|
||||
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 {
|
||||
this.auth.backToAdmin();
|
||||
location.assign('/settings');
|
||||
|
||||
Reference in New Issue
Block a user