Merge branch 'develop'
This commit is contained in:
commit
a645c2dc96
14
README.md
14
README.md
@ -8,12 +8,12 @@ Please feel free to contribute by making issues, bug reports and pull requests.
|
|||||||
|
|
||||||
## /!\ THIS PROJECT STILL LACKS ESSENTIAL FEATURES SUCH AS: /!\
|
## /!\ THIS PROJECT STILL LACKS ESSENTIAL FEATURES SUCH AS: /!\
|
||||||
|
|
||||||
- Change password
|
- [x] ~~Change password~~
|
||||||
- Password recovery (recovery emails are unused yet)
|
- [ ] Password recovery (recovery emails are unused yet)
|
||||||
- Quota management
|
- [ ] Quota management
|
||||||
- Editable terms of service
|
- [ ] Editable terms of service
|
||||||
- Complex permissions system
|
- [ ] Complex permissions system
|
||||||
- Redirections (can be manually setup with sql queries)
|
- [ ] Redirections (can be manually setup with sql queries)
|
||||||
- Probably many others, please make an issue so I can add them to this list
|
- Probably many others, please make an issue so I can add them to this list
|
||||||
|
|
||||||
## How does it work?
|
## How does it work?
|
||||||
@ -31,7 +31,7 @@ Note that a user only has one mailbox once they create their first address and c
|
|||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- mariadb (for the mysql database that will be operated)
|
- mariadb (for the mysql database that will be operated)
|
||||||
- redis (cache) (this may go as I think I'm not using it at all, but it won't start without it yet)
|
- redis (session, cache)
|
||||||
- A working mail smtp server (to send emails)
|
- A working mail smtp server (to send emails)
|
||||||
- git and yarn for updates
|
- git and yarn for updates
|
||||||
|
|
||||||
|
@ -12,10 +12,6 @@ td.actions {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
> *:not(:first-child) {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
form {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: inline;
|
display: inline;
|
||||||
@ -29,6 +25,10 @@ td.actions {
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> *:not(:first-child) {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body > header {
|
body > header {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rainbox.email",
|
"name": "rainbox.email",
|
||||||
"version": "2.1.2",
|
"version": "2.2.0",
|
||||||
"description": "ISP mail provider manager with mysql and integrated LDAP server",
|
"description": "ISP mail provider manager with mysql and integrated LDAP server",
|
||||||
"repository": "https://gitlab.com/ArisuOngaku/rainbox.email",
|
"repository": "https://gitlab.com/ArisuOngaku/rainbox.email",
|
||||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||||
|
@ -19,7 +19,6 @@ import AuthGuard from "wms-core/auth/AuthGuard";
|
|||||||
import {PasswordAuthProof} from "./models/UserPasswordComponent";
|
import {PasswordAuthProof} from "./models/UserPasswordComponent";
|
||||||
import LDAPServerComponent from "./LDAPServerComponent";
|
import LDAPServerComponent from "./LDAPServerComponent";
|
||||||
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
|
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
|
||||||
import packageJson = require('../package.json');
|
|
||||||
import DummyMigration from "wms-core/migrations/DummyMigration";
|
import DummyMigration from "wms-core/migrations/DummyMigration";
|
||||||
import DropLegacyLogsTable from "wms-core/migrations/DropLegacyLogsTable";
|
import DropLegacyLogsTable from "wms-core/migrations/DropLegacyLogsTable";
|
||||||
import AccountController from "./controllers/AccountController";
|
import AccountController from "./controllers/AccountController";
|
||||||
@ -37,9 +36,11 @@ import DropNameFromUsers from "wms-core/auth/migrations/DropNameFromUsers";
|
|||||||
import MagicLink from "wms-core/auth/models/MagicLink";
|
import MagicLink from "wms-core/auth/models/MagicLink";
|
||||||
import AddNameToUsers from "./migrations/AddNameToUsers";
|
import AddNameToUsers from "./migrations/AddNameToUsers";
|
||||||
import CreateMailTables from "./migrations/CreateMailTables";
|
import CreateMailTables from "./migrations/CreateMailTables";
|
||||||
import MailboxBackendController from "./controllers/MailboxBackendController";
|
import MailboxBackendController from "./controllers/backend/MailboxBackendController";
|
||||||
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
|
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
|
||||||
import MailAutoConfigController from "./controllers/MailAutoConfigController";
|
import MailAutoConfigController from "./controllers/MailAutoConfigController";
|
||||||
|
import AccountBackendController from "./controllers/backend/AccountBackendController";
|
||||||
|
import packageJson = require('../package.json');
|
||||||
|
|
||||||
export default class App extends Application {
|
export default class App extends Application {
|
||||||
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
|
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
|
||||||
@ -136,6 +137,7 @@ export default class App extends Application {
|
|||||||
this.use(new MagicLinkController(this.as(MagicLinkWebSocketListener)));
|
this.use(new MagicLinkController(this.as(MagicLinkWebSocketListener)));
|
||||||
this.use(new BackendController());
|
this.use(new BackendController());
|
||||||
this.use(new MailboxBackendController());
|
this.use(new MailboxBackendController());
|
||||||
|
this.use(new AccountBackendController());
|
||||||
this.use(new AuthController());
|
this.use(new AuthController());
|
||||||
this.use(new MailAutoConfigController()); // Needs to override MailController
|
this.use(new MailAutoConfigController()); // Needs to override MailController
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
|
|||||||
import MailIdentity from "../models/MailIdentity";
|
import MailIdentity from "../models/MailIdentity";
|
||||||
import UserNameComponent from "../models/UserNameComponent";
|
import UserNameComponent from "../models/UserNameComponent";
|
||||||
import {WhereOperator, WhereTest} from "wms-core/db/ModelQuery";
|
import {WhereOperator, WhereTest} from "wms-core/db/ModelQuery";
|
||||||
|
import UserPasswordComponent from "../models/UserPasswordComponent";
|
||||||
|
|
||||||
export default class AccountController extends Controller {
|
export default class AccountController extends Controller {
|
||||||
public getRoutesPrefix(): string {
|
public getRoutesPrefix(): string {
|
||||||
@ -20,6 +21,9 @@ export default class AccountController extends Controller {
|
|||||||
|
|
||||||
public routes(): void {
|
public routes(): void {
|
||||||
this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
|
this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
|
||||||
|
|
||||||
|
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
|
||||||
|
|
||||||
this.post('/add-recovery-email', this.addRecoveryEmail, 'add-recovery-email', RequireAuthMiddleware);
|
this.post('/add-recovery-email', this.addRecoveryEmail, 'add-recovery-email', RequireAuthMiddleware);
|
||||||
this.post('/set-main-email', this.postSetMainRecoveryEmail, 'set-main-recovery-email', RequireAuthMiddleware);
|
this.post('/set-main-email', this.postSetMainRecoveryEmail, 'set-main-recovery-email', RequireAuthMiddleware);
|
||||||
this.post('/remove-email', this.postRemoveRecoveryEmail, 'remove-recovery-email', RequireAuthMiddleware);
|
this.post('/remove-email', this.postRemoveRecoveryEmail, 'remove-recovery-email', RequireAuthMiddleware);
|
||||||
@ -33,6 +37,7 @@ export default class AccountController extends Controller {
|
|||||||
const userMailIdentity = user.as(UserMailIdentityComponent);
|
const userMailIdentity = user.as(UserMailIdentityComponent);
|
||||||
|
|
||||||
res.render('account', {
|
res.render('account', {
|
||||||
|
main_email: await user.mainEmail.get(),
|
||||||
emails: await user.emails.get(),
|
emails: await user.emails.get(),
|
||||||
mailboxIdentity: await (await userMailIdentity.mainMailIdentity.get())?.toEmail(),
|
mailboxIdentity: await (await userMailIdentity.mainMailIdentity.get())?.toEmail(),
|
||||||
identities: await Promise.all((await userMailIdentity.mailIdentities.get()).map(async identity => ({
|
identities: await Promise.all((await userMailIdentity.mailIdentities.get()).map(async identity => ({
|
||||||
@ -51,6 +56,27 @@ export default class AccountController extends Controller {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async postChangePassword(req: Request, res: Response): Promise<void> {
|
||||||
|
await this.validate({
|
||||||
|
'current_password': new Validator().defined(),
|
||||||
|
'new_password': new Validator().defined(),
|
||||||
|
'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password),
|
||||||
|
}, req.body);
|
||||||
|
|
||||||
|
const user = req.as(RequireAuthMiddleware).getUser();
|
||||||
|
if (!await user.as(UserPasswordComponent).verifyPassword(req.body.current_password)) {
|
||||||
|
req.flash('error', 'Invalid current password.');
|
||||||
|
res.redirectBack(Controller.route('account'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.as(UserPasswordComponent).setPassword(req.body.new_password, 'new_password');
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
req.flash('success', 'Password change successfully.');
|
||||||
|
res.redirectBack(Controller.route('account'));
|
||||||
|
}
|
||||||
|
|
||||||
protected async addRecoveryEmail(req: Request, res: Response): Promise<void> {
|
protected async addRecoveryEmail(req: Request, res: Response): Promise<void> {
|
||||||
await this.validate({
|
await this.validate({
|
||||||
email: new Validator().defined().regexp(EMAIL_REGEX),
|
email: new Validator().defined().regexp(EMAIL_REGEX),
|
||||||
|
65
src/controllers/backend/AccountBackendController.ts
Normal file
65
src/controllers/backend/AccountBackendController.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import Controller from "wms-core/Controller";
|
||||||
|
import BackendController from "wms-core/helpers/BackendController";
|
||||||
|
import {RequireAdminMiddleware, RequireAuthMiddleware} from "wms-core/auth/AuthComponent";
|
||||||
|
import {Request, Response} from "express";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
import {NotFoundHttpError} from "wms-core/HttpError";
|
||||||
|
import Validator from "wms-core/db/Validator";
|
||||||
|
import UserPasswordComponent from "../../models/UserPasswordComponent";
|
||||||
|
import UserNameComponent from "../../models/UserNameComponent";
|
||||||
|
|
||||||
|
export default class AccountBackendController extends Controller {
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
BackendController.registerMenuElement({
|
||||||
|
getLink: async () => Controller.route('backend-list-users'),
|
||||||
|
getDisplayString: async () => 'Users',
|
||||||
|
getDisplayIcon: async () => 'user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoutesPrefix(): string {
|
||||||
|
return '/backend/users';
|
||||||
|
}
|
||||||
|
|
||||||
|
public routes(): void {
|
||||||
|
this.get('/', this.getListUsers, 'backend-list-users', RequireAuthMiddleware, RequireAdminMiddleware);
|
||||||
|
this.get('/:user_id/change-password', this.getChangeUserPassword, 'backend-change-user-password', RequireAuthMiddleware, RequireAdminMiddleware);
|
||||||
|
this.post('/:user_id/change-password', this.postChangeUserPassword, 'backend-change-user-password', RequireAuthMiddleware, RequireAdminMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getListUsers(req: Request, res: Response): Promise<void> {
|
||||||
|
res.render('backend/users', {
|
||||||
|
accounts: await User.select()
|
||||||
|
.with('mainEmail')
|
||||||
|
.get(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getChangeUserPassword(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = await User.getById(req.params.user_id);
|
||||||
|
if (!user) throw new NotFoundHttpError('user', req.url);
|
||||||
|
|
||||||
|
res.render('backend/users-change-password', {
|
||||||
|
user: user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postChangeUserPassword(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = await User.getById(req.params.user_id);
|
||||||
|
if (!user) throw new NotFoundHttpError('user', req.url);
|
||||||
|
|
||||||
|
await this.validate({
|
||||||
|
'new_password': new Validator().defined(),
|
||||||
|
'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password),
|
||||||
|
}, req.body);
|
||||||
|
|
||||||
|
await user.as(UserPasswordComponent).setPassword(req.body.new_password, 'new_password');
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
req.flash('success', `New password set for ${user.as(UserNameComponent).name}`);
|
||||||
|
res.redirect(Controller.route('backend-list-users'));
|
||||||
|
}
|
||||||
|
}
|
@ -2,12 +2,12 @@ import Controller from "wms-core/Controller";
|
|||||||
import {Request, Response} from "express";
|
import {Request, Response} from "express";
|
||||||
import User from "wms-core/auth/models/User";
|
import User from "wms-core/auth/models/User";
|
||||||
import {WhereTest} from "wms-core/db/ModelQuery";
|
import {WhereTest} from "wms-core/db/ModelQuery";
|
||||||
import UserNameComponent from "../models/UserNameComponent";
|
import UserNameComponent from "../../models/UserNameComponent";
|
||||||
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
|
import UserMailIdentityComponent from "../../models/UserMailIdentityComponent";
|
||||||
import {NotFoundHttpError} from "wms-core/HttpError";
|
import {NotFoundHttpError} from "wms-core/HttpError";
|
||||||
import MailDomain from "../models/MailDomain";
|
import MailDomain from "../../models/MailDomain";
|
||||||
import BackendController from "wms-core/helpers/BackendController";
|
import BackendController from "wms-core/helpers/BackendController";
|
||||||
import MailIdentity from "../models/MailIdentity";
|
import MailIdentity from "../../models/MailIdentity";
|
||||||
import {RequireAdminMiddleware, RequireAuthMiddleware} from "wms-core/auth/AuthComponent";
|
import {RequireAdminMiddleware, RequireAuthMiddleware} from "wms-core/auth/AuthComponent";
|
||||||
|
|
||||||
export default class MailboxBackendController extends Controller {
|
export default class MailboxBackendController extends Controller {
|
||||||
@ -86,6 +86,7 @@ export default class MailboxBackendController extends Controller {
|
|||||||
res.render('backend/mailbox', {
|
res.render('backend/mailbox', {
|
||||||
mailbox: {
|
mailbox: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
userName: user.as(UserNameComponent).name,
|
||||||
name: await mainMailIdentity?.toEmail() || 'Not created.',
|
name: await mainMailIdentity?.toEmail() || 'Not created.',
|
||||||
},
|
},
|
||||||
domains: mailDomains.map(d => ({
|
domains: mailDomains.map(d => ({
|
@ -11,9 +11,9 @@ export default class UserPasswordComponent extends ModelComponent<User> {
|
|||||||
this.setValidation('password').acceptUndefined().maxLength(128);
|
this.setValidation('password').acceptUndefined().maxLength(128);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setPassword(rawPassword: string): Promise<void> {
|
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
|
||||||
await new Validator<string>().defined().minLength(8).maxLength(512)
|
await new Validator<string>().defined().minLength(8).maxLength(512)
|
||||||
.execute('password', rawPassword, true);
|
.execute(fieldName, rawPassword, true);
|
||||||
this.password = await argon2.hash(rawPassword, {
|
this.password = await argon2.hash(rawPassword, {
|
||||||
timeCost: 10,
|
timeCost: 10,
|
||||||
memoryCost: 65536,
|
memoryCost: 65536,
|
||||||
|
@ -4,12 +4,33 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<div class="panel">
|
|
||||||
<i data-feather="user"></i>
|
|
||||||
<h1>My account</h1>
|
<h1>My account</h1>
|
||||||
|
|
||||||
|
{% if emails | length <= 0 %}
|
||||||
|
{{ macros.message('warning', 'To avoid losing access to your account, please add a recovery email address.') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><i data-feather="user"></i> Personnal info</h2>
|
||||||
|
|
||||||
<p>Name: {{ user.name }}</p>
|
<p>Name: {{ user.name }}</p>
|
||||||
</div>
|
|
||||||
|
<p>Contact email: {{ main_email.email | default('-') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><i data-feather="key"></i> Change password</h2>
|
||||||
|
|
||||||
|
<form action="{{ route('change-password') }}" method="POST">
|
||||||
|
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
|
||||||
|
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
|
||||||
|
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
|
||||||
|
|
||||||
|
<button type="submit"><i data-feather="save"></i> Save</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2><i data-feather="shield"></i> Recovery email addresses</h2>
|
<h2><i data-feather="shield"></i> Recovery email addresses</h2>
|
||||||
|
@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{{ macros.breadcrumb(mailbox.name, [
|
{{ macros.breadcrumb(mailbox.userName + ' - ' + mailbox.name, [
|
||||||
{title: 'Backend', link: route('backend')},
|
{title: 'Backend', link: route('backend')},
|
||||||
{title: 'Mailboxes', link: route('backend-mailboxes')}
|
{title: 'Mailboxes', link: route('backend-mailboxes')}
|
||||||
]) }}
|
]) }}
|
||||||
|
<h1>Mailbox: {{ mailbox.userName }} - {{ mailbox.name }}</h1>
|
||||||
|
|
||||||
<div class="panel">
|
<section class="panel">
|
||||||
<i data-feather="mail"></i>
|
<h2><i data-feather="mail"></i> Identities</h2>
|
||||||
<h1>Mailbox: {{ mailbox.name }}</h1>
|
|
||||||
|
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -57,6 +57,6 @@
|
|||||||
|
|
||||||
{{ macros.csrf(getCsrfToken) }}
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
27
views/backend/users-change-password.njk
Normal file
27
views/backend/users-change-password.njk
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
|
{% set title = app.name + ' - Backend' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="container">
|
||||||
|
{{ macros.breadcrumb('Change ' + user.name + '\'s password', [
|
||||||
|
{title: 'Backend', link: route('backend')},
|
||||||
|
{title: 'Users', link: route('backend-list-users')}
|
||||||
|
]) }}
|
||||||
|
|
||||||
|
<h1>Accounts manager</h1>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><i data-feather="key"></i> Change {{ user.name }}'s password</h2>
|
||||||
|
|
||||||
|
<form action="{{ route('backend-change-user-password', user.id) }}" method="POST">
|
||||||
|
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
|
||||||
|
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
|
||||||
|
|
||||||
|
<button type="submit"><i data-feather="save"></i> Save</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
47
views/backend/users.njk
Normal file
47
views/backend/users.njk
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
|
{% set title = app.name + ' - Backend' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="container">
|
||||||
|
{{ macros.breadcrumb('Users', [
|
||||||
|
{title: 'Backend', link: route('backend')}
|
||||||
|
]) }}
|
||||||
|
|
||||||
|
<h1>Accounts manager</h1>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><i data-feather="user"></i> Accounts</h2>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for account in accounts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ account.id }}</td>
|
||||||
|
<td>{{ account.name }}</td>
|
||||||
|
<td>{{ account.mainEmail.getOrFail().email | default('-') }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="{{ route('backend-change-user-password', account.id) }}" class="button">
|
||||||
|
<i data-feather="key"></i> <span class="tip">Change password</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ route('backend-mailbox', account.id) }}" class="button">
|
||||||
|
<i data-feather="mail"></i> <span class="tip">Edit mailbox</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user