Compare commits

..

No commits in common. "master" and "v2.2.0" have entirely different histories.

48 changed files with 4963 additions and 4291 deletions

View File

@ -88,7 +88,6 @@
}, },
"ignorePatterns": [ "ignorePatterns": [
"jest.config.js", "jest.config.js",
"scripts/**/*",
"webpack.config.js", "webpack.config.js",
"dist/**/*", "dist/**/*",
"public/**/*", "public/**/*",

2
.gitignore vendored
View File

@ -3,5 +3,3 @@ node_modules
public public
dist dist
yarn-error.log yarn-error.log
src/package.json

View File

@ -9,7 +9,7 @@ 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: /!\
- [x] ~~Change password~~ - [x] ~~Change password~~
- [ ] Password reset (a magic link to remove your password, see [this issue](https://eternae.ink/arisu/swaf/issues/23)) - [ ] 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
@ -56,7 +56,7 @@ Note that a user only has one mailbox once they create their first address and c
### Configuration ### Configuration
Create a `config/local.json5` file and override all wanted parameters. See `config/{default,prouction}.json5` and `node_modules/swaf/config/{default,production}.json5` for defaults. Create a `config/local.json5` file and override all wanted parameters. See `config/{default,prouction}.json5` and `node_modules/wms-core/config/{default,production}.json5` for defaults.
## Postfix and dovecot mysql queries ## Postfix and dovecot mysql queries

View File

@ -1,6 +1,3 @@
# Please customize values i.e. paths, user, group, WorkingDirectory based on your environment. Do not use the same
# user and group for different applications.
[Unit] [Unit]
Description=Rainbox Email website Description=Rainbox Email website
After=network-online.target After=network-online.target

View File

@ -6,10 +6,9 @@ $secondaryForeground: $primaryForeground;
$backgroundColor: darken($primary, 4%); $backgroundColor: darken($primary, 4%);
$defaultTextColor: #ffffff; $defaultTextColor: #ffffff;
$headerBackground: transparent; $headerBackground: darken($primary, 7.5%);
$headerContainer: true; $footerBackground: lighten($headerBackground, 1%);
$footerBackground: transparent; $panelBackground: lighten($headerBackground, 1%);
$panelBackground: darken($backgroundColor, 3.2%);
$inputBackground: darken($panelBackground, 4%); $inputBackground: darken($panelBackground, 4%);
$info: #4499ff; $info: #4499ff;
@ -30,4 +29,3 @@ $errorColor: desaturate($errorText, 50%);
// Responsivity // Responsivity
$mobileThreshold: 632px; $mobileThreshold: 632px;
$desktopThreshold: 940px;

View File

@ -1,13 +1,110 @@
@import "layout"; @import "layout";
a {
&:hover {
color: lighten($secondary, 30%);
}
}
td.actions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
form {
padding: 0;
display: inline;
}
button, .button {
margin: 0;
padding: 8px;
.feather {
margin-right: 0;
}
}
> *:not(:first-child) {
margin-left: 8px;
}
}
body > header { body > header {
background: transparent;
line-height: 16px;
@include container;
padding-top: 8px;
padding-bottom: 8px;
align-items: center;
@media (max-width: $mobileThreshold) {
padding: 0;
}
.logo { .logo {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
max-width: 148px; max-width: 148px;
padding: 8px 0; padding: 8px;
font-size: 18px; font-size: 18px;
line-height: 18px; line-height: 18px;
> img {
margin: 0 8px 0 0;
width: initial;
height: 48px;
flex-shrink: 0;
}
}
nav {
ul {
li {
margin-left: 8px;
a, button {
display: flex;
flex-direction: row;
align-items: center;
height: auto;
padding: 8px;
border-radius: 3px;
.feather {
--icon-size: 16px;
}
.tip {
position: static;
display: block;
visibility: visible;
height: auto;
padding: 0 0 0 4px;
transform: none;
color: inherit;
opacity: 1;
background: transparent;
text-transform: uppercase;
font-size: 16px;
line-height: 16px;
}
}
button {
margin: 0;
}
}
}
} }
} }
body > footer {
background: transparent;
}

View File

@ -1,6 +1,5 @@
@import "vars"; @import "vars";
@import 'fonts'; @import 'fonts';
@import "responsivity_tools";
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -70,31 +69,21 @@ body {
body > header { body > header {
z-index: 50; z-index: 50;
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center;
$headerHeight: 64px; $headerHeight: 64px;
height: $headerHeight; height: $headerHeight;
line-height: $headerHeight; line-height: $headerHeight;
background: $headerBackground; background-color: $headerBackground;
@if $headerContainer {
@include container;
}
@media (max-width: $mobileThreshold) {
padding: 0;
}
.logo { .logo {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
padding: 0 16px 0 8px; padding: 0 24px 0 16px;
font-size: 24px; font-size: 32px;
color: $defaultTextColor; color: $defaultTextColor;
&:hover { &:hover {
@ -102,48 +91,34 @@ body > header {
} }
img { img {
width: initial; width: $headerHeight;
height: calc(#{$headerHeight} - 16px); height: $headerHeight;
margin-right: 8px; margin-right: 16px;
flex-shrink: 0;
} }
} }
nav { nav {
> ul { ul {
position: fixed;
z-index: -1;
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
transition: transform ease-out 150ms;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
margin: 0; margin: 0;
padding: $headerHeight 8px 8px; padding: 0;
font-size: 20px; font-size: 20px;
background: $panelBackground;
li { li {
position: relative; position: relative;
list-style: none; list-style: none;
margin-top: 8px;
a, button { a, button {
position: relative; position: relative;
height: 64px;
margin: 0; margin: 0;
padding: 0 24px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
height: auto;
padding: 8px;
border-radius: 3px;
&:hover, &:active { &:hover, &:active {
&:not(button) { &:not(button) {
@ -152,40 +127,13 @@ body > header {
} }
.feather { .feather {
--icon-size: 16px; --icon-size: 24px;
}
.tip {
position: static;
visibility: visible;
opacity: 1;
display: block;
height: auto;
margin-left: 8px;
padding: 0 0 0 4px;
transform: none;
font-size: 16px;
line-height: 16px;
color: inherit;
text-transform: uppercase;
font-weight: inherit;
background: transparent;
}
&:hover {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
} }
} }
button { button {
margin: 0; margin: 8px;
padding: 24px;
height: 32px; height: 32px;
.feather { .feather {
@ -210,117 +158,114 @@ body > header {
} }
.dropdown { .dropdown {
position: initial; position: absolute;
display: block; z-index: -1;
padding-left: 0; top: 100%;
right: 0;
white-space: nowrap;
background: $headerBackground;
border-radius: 0 0 3px 3px;
a {
padding: 0 8px;
}
} }
}
> li:not(:first-child) { &:hover .dropdown {
border-top: 1px solid transparentize($defaultTextColor, 0.8); display: block;
padding-top: 8px; }
}
&.open {
transform: translateX(0%);
box-shadow: 0 0 5px darken($panelBackground, 20%);
} }
} }
#menu-button { #menu-button {
position: fixed; display: none;
top: 0;
left: 0;
display: block;
margin: 0;
padding: 0 16px;
line-height: $headerHeight;
cursor: pointer;
background: transparent;
border-radius: 0;
.feather {
--icon-size: 28px;
margin: 0 8px;
}
}
hr {
border: 0;
border-bottom: 1px solid $defaultTextColor;
opacity: 0.2;
} }
} }
@media (min-width: $mobileThreshold) { @media (max-width: $mobileThreshold) {
flex-direction: row; flex-direction: row-reverse;
.logo {
padding: 0 16px 0 8px;
font-size: 24px;
img {
margin-right: 8px;
}
}
nav { nav {
#menu-button { #menu-button {
display: none; display: block;
margin: 0;
padding: 0 16px;
line-height: $headerHeight;
cursor: pointer;
background: transparent;
border-radius: 0;
.feather {
--icon-size: 28px;
margin: 0 8px;
}
} }
ul { > ul {
position: static; flex-direction: column;
flex-direction: row; position: absolute;
transform: none; z-index: 10;
padding: 0; left: 0;
background: transparent; transform: translateX(-100%);
transition: transform ease-out 150ms;
background-color: $headerBackground;
&.open {
transform: translateX(0%);
}
li { li {
margin-top: 0; a, button {
margin-left: 8px; .tip {
display: block;
&:last-child { margin-left: 8px;
a, button, .button { text-transform: inherit;
.tip { font-weight: inherit;
left: unset;
right: 4px;
transform: none;
}
} }
} }
.dropdown { .dropdown {
position: absolute; position: initial;
z-index: -1;
top: 100%;
right: 0;
display: none;
padding: 8px;
white-space: nowrap;
background: $panelBackground;
border-radius: 0 0 3px 3px;
box-shadow: 0 2px 2px transparentize(darken($panelBackground, 20%), 0.75);
border-top: 4px solid lighten($panelBackground, 5%);
li {
margin-left: 0;
&:not(:first-child) {
margin-top: 8px;
}
}
}
&:hover .dropdown {
display: block; display: block;
padding-left: 32px;
} }
} }
}
}
}
> li:not(:first-child) { @media (min-width: $mobileThreshold) {
border-top: 0; nav ul li {
padding-top: 0; a, button, .button {
@include tip;
}
&:last-child {
a, button, .button {
.tip {
left: unset;
right: 4px;
transform: none;
}
} }
} }
} }
} }
} }
body > footer { footer {
padding: 8px; padding: 8px;
margin-top: 8px; margin-top: 8px;
text-align: center; text-align: center;
@ -393,7 +338,7 @@ a {
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: lighten($secondary, 30%); color: lighten($secondary, 10%);
} }
.feather.feather-external-link { .feather.feather-external-link {
@ -407,7 +352,7 @@ form {
padding: 8px 16px; padding: 8px 16px;
text-align: center; text-align: center;
.form-field:not(.hidden) { .form-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 16px auto; margin: 16px auto;
@ -662,35 +607,6 @@ button, .button {
} }
} }
// ---
// --- Tables
// ---
td.actions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
form {
padding: 0;
display: inline;
}
button, .button {
margin: 0;
padding: 8px;
.feather {
margin-right: 0;
}
}
> *:not(:first-child) {
margin-left: 8px;
}
}
.data-table { .data-table {
width: 100%; width: 100%;
text-align: left; text-align: left;
@ -702,7 +618,6 @@ td.actions {
th { th {
border-bottom: 1px solid #39434a; border-bottom: 1px solid #39434a;
white-space: nowrap;
} }
tr:nth-child(even) { tr:nth-child(even) {
@ -718,10 +633,6 @@ td.actions {
} }
} }
// ---
// --- Breadcrumb widget
// ---
.breadcrumb { .breadcrumb {
list-style: none; list-style: none;
display: flex; display: flex;
@ -735,7 +646,6 @@ td.actions {
} }
} }
// --- // ---
// --- Layout helpers // --- Layout helpers
// --- // ---
@ -743,6 +653,24 @@ td.actions {
text-align: center; text-align: center;
} }
@mixin container {
width: $mobileThreshold;
padding: 0 16px;
@media (min-width: $mobileThreshold) {
margin: 0 auto;
}
@media (max-width: $mobileThreshold) {
width: 100%;
padding: 0 8px;
}
}
.container {
@include container;
}
.panel { .panel {
position: relative; position: relative;
margin: 16px 0 48px; margin: 16px 0 48px;
@ -869,7 +797,37 @@ td.actions {
} }
.content { .content {
width: 0; overflow: hidden;
white-space: nowrap;
padding: 8px;
}
.copy-button {
margin: 0;
padding: 0;
border-radius: 0;
.feather {
--icon-size: 20px;
margin: 8px;
}
}
}
.copyable-text {
display: flex;
flex-direction: row;
margin: 8px;
background-color: darken($backgroundColor, 2%);
border-radius: 5px;
overflow: hidden;
.title {
padding: 8px;
}
.content {
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@ -919,34 +877,3 @@ td.actions {
background: $secondary; background: $secondary;
} }
} }
.table-col-grow {
width: 100%;
}
.pagination {
ul {
display: flex;
flex-direction: row;
list-style: none;
padding: 8px;
justify-content: center;
li {
a, &.active, &.ellipsis {
display: block;
min-width: 40px;
height: 40px;
padding: 4px;
line-height: 32px;
text-align:center;
&:hover:not(.active):not(.ellipsis) {
background-color: #fff5;
}
}
}
}
}

View File

@ -1,19 +0,0 @@
@import "vars";
@mixin container {
width: 100%;
padding: 0 8px;
@media (min-width: $mobileThreshold) {
margin: 0 auto;
padding: 0 16px;
}
@media (min-width: $desktopThreshold) {
width: $desktopThreshold;
}
}
.container {
@include container;
}

View File

@ -1,7 +1,7 @@
/* /*
* For labels to update their state (css selectors based on the value attribute) * For labels to update their state (css selectors based on the value attribute)
*/ */
import {ValidationError} from "swaf/db/Validator"; import {ValidationError} from "wms-core/db/Validator";
export function updateInputs(): void { export function updateInputs(): void {
document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input, textarea').forEach(el => { document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input, textarea').forEach(el => {

View File

@ -1,75 +1,69 @@
{ {
app: { app: {
name: 'Rainbox Email', name: 'Rainbox Email',
contact_email: 'contact@rainbox.email', contact_email: 'contact@rainbox.email',
webmail_url: 'https://rc.rainbox.email', webmail_url: 'https://rc.rainbox.email',
}, },
log_level: "DEV", log_level: "DEV",
db_log_level: "ERROR", db_log_level: "ERROR",
public_url: "http://localhost:4899", public_url: "http://localhost:4899",
public_websocket_url: "ws://localhost:4899", public_websocket_url: "ws://localhost:4899",
port: 4899, port: 4899,
mysql: { mysql: {
connectionLimit: 10, connectionLimit: 10,
host: "localhost", host: "localhost",
user: "root", user: "root",
password: "", password: "",
database: "rainbox_email", database: "rainbox_email",
create_database_automatically: false, create_database_automatically: false,
}, },
redis: { redis: {
host: "127.0.0.1", host: "127.0.0.1",
port: 6379, port: 6379,
prefix: 'rainbox.email', prefix: 'rainbox.email',
}, },
session: { session: {
cookie: { cookie: {
secure: false, secure: false,
}, },
}, },
mail: { mail: {
host: "127.0.0.1", host: "127.0.0.1",
port: "1025", port: "1025",
secure: false, secure: false,
username: "", username: "",
password: "", password: "",
allow_invalid_tls: true, allow_invalid_tls: true,
from: 'contact@rainbox.email', from: 'contact@rainbox.email',
from_name: 'Rainbox Email', from_name: 'Rainbox Email',
// Unlimited },
max_public_identities_per_user: -1, view: {
}, cache: false,
view: { },
cache: false, approval_mode: false,
}, magic_link: {
auth: { validity_period: 20,
approval_mode: false, },
// 30 days mail_autoconfig: {
name_change_wait_period: 2592000000, main_domain: 'localhost',
}, display_name: 'Rainbox Email',
magic_link: { display_name_short: 'Rainbox',
validity_period: 20, username: '%EMAILADDRESS%',
}, auth_method: 'password-cleartext',
mail_autoconfig: { imap: {
main_domain: 'localhost', server: 'localhost',
display_name: 'Rainbox Email', port: '143',
display_name_short: 'Rainbox', method: 'STARTTLS',
username: '%EMAILADDRESS%', },
auth_method: 'password-cleartext', pop3: {
imap: { server: 'localhost',
server: 'localhost', port: '110',
port: '143', method: 'STARTTLS',
method: 'STARTTLS', },
}, smtp: {
pop3: { server: 'localhost',
server: 'localhost', port: '587',
port: '110', method: 'STARTTLS',
method: 'STARTTLS', },
}, },
smtp: {
server: 'localhost',
port: '587',
method: 'STARTTLS',
},
},
} }

View File

@ -1,43 +1,41 @@
{ {
log_level: "DEBUG", log_level: "DEBUG",
db_log_level: "ERROR", db_log_level: "ERROR",
public_url: "https://rainbox.email", public_url: "https://rainbox.email",
public_websocket_url: "wss://rainbox.email", public_websocket_url: "wss://rainbox.email",
session: { session: {
cookie: { cookie: {
secure: true, secure: true
}, }
}, },
mail: { mail: {
secure: true, secure: true,
allow_invalid_tls: false, allow_invalid_tls: false
}, },
auth: { approval_mode: true,
approval_mode: true, magic_link: {
}, validity_period: 900,
magic_link: { },
validity_period: 900, mail_autoconfig: {
}, main_domain: 'rainbox.email',
mail_autoconfig: { display_name: 'Rainbox Email',
main_domain: 'rainbox.email', display_name_short: 'Rainbox',
display_name: 'Rainbox Email', username: '%EMAILADDRESS%',
display_name_short: 'Rainbox', auth_method: 'password-cleartext',
username: '%EMAILADDRESS%', imap: {
auth_method: 'password-cleartext', server: 'rainbox.email',
imap: { port: '143',
server: 'rainbox.email', method: 'STARTTLS',
port: '143', },
method: 'STARTTLS', pop3: {
}, server: 'rainbox.email',
pop3: { port: '110',
server: 'rainbox.email', method: 'STARTTLS',
port: '110', },
method: 'STARTTLS', smtp: {
}, server: 'rainbox.email',
smtp: { port: '587',
server: 'rainbox.email', method: 'STARTTLS',
port: '587', },
method: 'STARTTLS', },
},
},
} }

View File

@ -6,7 +6,4 @@
database: "rainbox_email_test", database: "rainbox_email_test",
create_database_automatically: true create_database_automatically: true
}, },
mail: {
max_public_identities_per_user: 1,
},
} }

View File

@ -1,26 +1,27 @@
{ {
"name": "rainbox.email", "name": "rainbox.email",
"version": "2.4.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://eternae.ink/ashpie/rainbox.email", "repository": "https://gitlab.com/ArisuOngaku/rainbox.email",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
"main": "dist/main.js", "main": "dist/src/main.js",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"scripts": { "scripts": {
"test": "jest --verbose --runInBand", "dist-webpack": "webpack --mode production",
"clean": "node scripts/clean.js", "clean": "(test ! -d dist || rm -r dist)",
"prepare-sources": "node scripts/prepare-sources.js",
"compile": "yarn clean && tsc", "compile": "yarn clean && tsc",
"build": "yarn prepare-sources && yarn compile && webpack --mode production", "build": "yarn compile && yarn dist-webpack",
"dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"", "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"start": "yarn build && node", "dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"",
"lint": "eslint ." "start": "yarn build && node dist/src/main.js",
"test": "jest --verbose --runInBand"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.5", "@babel/preset-env": "^7.9.5",
"@fortawesome/fontawesome-free": "^5.14.0", "@fortawesome/fontawesome-free": "^5.14.0",
"@types/config": "^0.0.38", "@types/argon2": "^0.15.0",
"@types/config": "^0.0.36",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/express-session": "^1.17.0", "@types/express-session": "^1.17.0",
"@types/feather-icons": "^4.7.0", "@types/feather-icons": "^4.7.0",
@ -28,33 +29,32 @@
"@types/jest": "^26.0.4", "@types/jest": "^26.0.4",
"@types/ldapjs": "^1.0.7", "@types/ldapjs": "^1.0.7",
"@types/mysql": "^2.15.15", "@types/mysql": "^2.15.15",
"@types/node": "^15.0.1", "@types/node": "^14.6.3",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3", "@types/nunjucks": "^3.1.3",
"@types/ws": "^7.2.6", "@types/ws": "^7.2.6",
"@typescript-eslint/eslint-plugin": "^4.3.0", "@typescript-eslint/eslint-plugin": "^4.3.0",
"@typescript-eslint/parser": "^4.3.0", "@typescript-eslint/parser": "^4.3.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"concurrently": "^6.0.0", "concurrently": "^5.1.0",
"css-loader": "^5.0.0", "css-loader": "^5.0.0",
"eslint": "^7.10.0", "eslint": "^7.10.0",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"imagemin": "^7.0.0", "imagemin": "^7.0.1",
"imagemin-gifsicle": "^7.0.0", "imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^9.0.0", "imagemin-mozjpeg": "^9.0.0",
"imagemin-pngquant": "^9.0.0", "imagemin-pngquant": "^9.0.0",
"imagemin-svgo": "^9.0.0", "imagemin-svgo": "^8.0.0",
"img-loader": "^3.0.1", "img-loader": "^3.0.1",
"jest": "^26.1.0", "jest": "^26.1.0",
"maildev": "^1.1.0",
"mini-css-extract-plugin": "^1.2.1", "mini-css-extract-plugin": "^1.2.1",
"node-sass": "^5.0.0",
"nodemon": "^2.0.3", "nodemon": "^2.0.3",
"sass": "^1.32.12", "sass-loader": "^10.0.1",
"sass-loader": "^11.0.1",
"terser-webpack-plugin": "^5.0.3", "terser-webpack-plugin": "^5.0.3",
"ts-jest": "^26.1.1", "ts-jest": "^26.1.1",
"ts-loader": "^9.1.0", "ts-loader": "^8.0.4",
"typescript": "^4.0.2", "typescript": "^4.0.2",
"webpack": "^5.3.2", "webpack": "^5.3.2",
"webpack-cli": "^4.1.0" "webpack-cli": "^4.1.0"
@ -64,6 +64,6 @@
"config": "^3.3.1", "config": "^3.3.1",
"express": "^4.17.1", "express": "^4.17.1",
"ldapjs": "^2.0.0", "ldapjs": "^2.0.0",
"swaf": "^0.23.0" "wms-core": "^0.22.0"
} }
} }

View File

@ -1,10 +0,0 @@
const fs = require('fs');
[
'dist',
].forEach(file => {
if (fs.existsSync(file)) {
console.log('Cleaning', file, '...');
fs.rmSync(file, {recursive: true});
}
});

View File

@ -1,4 +0,0 @@
const fs = require('fs');
const path = require('path');
fs.copyFileSync('package.json', path.join('src', 'package.json'));

View File

@ -1,50 +1,50 @@
import Application from "swaf/Application"; import Application from "wms-core/Application";
import Migration, {MigrationType} from "swaf/db/Migration"; import Migration, {MigrationType} from "wms-core/db/Migration";
import ExpressAppComponent from "swaf/components/ExpressAppComponent"; import ExpressAppComponent from "wms-core/components/ExpressAppComponent";
import NunjucksComponent from "swaf/components/NunjucksComponent"; import NunjucksComponent from "wms-core/components/NunjucksComponent";
import MysqlComponent from "swaf/components/MysqlComponent"; import MysqlComponent from "wms-core/components/MysqlComponent";
import LogRequestsComponent from "swaf/components/LogRequestsComponent"; import LogRequestsComponent from "wms-core/components/LogRequestsComponent";
import RedisComponent from "swaf/components/RedisComponent"; import RedisComponent from "wms-core/components/RedisComponent";
import ServeStaticDirectoryComponent from "swaf/components/ServeStaticDirectoryComponent"; import ServeStaticDirectoryComponent from "wms-core/components/ServeStaticDirectoryComponent";
import MaintenanceComponent from "swaf/components/MaintenanceComponent"; import MaintenanceComponent from "wms-core/components/MaintenanceComponent";
import MailComponent from "swaf/components/MailComponent"; import MailComponent from "wms-core/components/MailComponent";
import SessionComponent from "swaf/components/SessionComponent"; import SessionComponent from "wms-core/components/SessionComponent";
import FormHelperComponent from "swaf/components/FormHelperComponent"; import FormHelperComponent from "wms-core/components/FormHelperComponent";
import CsrfProtectionComponent from "swaf/components/CsrfProtectionComponent"; import CsrfProtectionComponent from "wms-core/components/CsrfProtectionComponent";
import WebSocketServerComponent from "swaf/components/WebSocketServerComponent"; import WebSocketServerComponent from "wms-core/components/WebSocketServerComponent";
import HomeController from "./controllers/HomeController"; import HomeController from "./controllers/HomeController";
import AuthComponent from "swaf/auth/AuthComponent"; import AuthController from "./controllers/AuthController";
import AuthComponent from "wms-core/auth/AuthComponent";
import AuthGuard from "wms-core/auth/AuthGuard";
import {PasswordAuthProof} from "./models/UserPasswordComponent";
import LDAPServerComponent from "./LDAPServerComponent"; import LDAPServerComponent from "./LDAPServerComponent";
import AutoUpdateComponent from "swaf/components/AutoUpdateComponent"; import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
import DummyMigration from "swaf/migrations/DummyMigration"; import DummyMigration from "wms-core/migrations/DummyMigration";
import DropLegacyLogsTable from "swaf/migrations/DropLegacyLogsTable"; import DropLegacyLogsTable from "wms-core/migrations/DropLegacyLogsTable";
import AccountMailboxController from "./controllers/AccountMailboxController"; import AccountController from "./controllers/AccountController";
import CreateMigrationsTable from "swaf/migrations/CreateMigrationsTable"; import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable";
import MagicLinkWebSocketListener from "swaf/auth/magic_link/MagicLinkWebSocketListener"; import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable";
import BackendController from "swaf/helpers/BackendController"; import AddPasswordToUsers from "./migrations/AddPasswordToUsers";
import CreateMailTablesMigration from "./migrations/CreateMailTablesMigration"; import CreateMagicLinksTable from "wms-core/auth/migrations/CreateMagicLinksTable";
import MailController from "wms-core/auth/MailController";
import MagicLinkController from "./controllers/MagicLinkController";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener";
import BackendController from "wms-core/helpers/BackendController";
import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable";
import FixUserMainEmailRelation from "wms-core/auth/migrations/FixUserMainEmailRelation";
import DropNameFromUsers from "wms-core/auth/migrations/DropNameFromUsers";
import MagicLink from "wms-core/auth/models/MagicLink";
import AddNameToUsers from "./migrations/AddNameToUsers";
import CreateMailTables from "./migrations/CreateMailTables";
import MailboxBackendController from "./controllers/backend/MailboxBackendController"; import MailboxBackendController from "./controllers/backend/MailboxBackendController";
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
import MailAutoConfigController from "./controllers/MailAutoConfigController"; import MailAutoConfigController from "./controllers/MailAutoConfigController";
import AccountBackendController from "./controllers/backend/AccountBackendController"; import AccountBackendController from "./controllers/backend/AccountBackendController";
import CreateUsersAndUserEmailsTableMigration from "swaf/auth/migrations/CreateUsersAndUserEmailsTableMigration"; import packageJson = require('../package.json');
import AddPasswordToUsersMigration from "swaf/auth/password/AddPasswordToUsersMigration";
import CreateMagicLinksTableMigration from "swaf/auth/magic_link/CreateMagicLinksTableMigration";
import AddApprovedFieldToUsersTableMigration from "swaf/auth/migrations/AddApprovedFieldToUsersTableMigration";
import AddNameToUsersMigration from "swaf/auth/migrations/AddNameToUsersMigration";
import PreviousUrlComponent from "swaf/components/PreviousUrlComponent";
import MagicLinkAuthMethod from "swaf/auth/magic_link/MagicLinkAuthMethod";
import {MAGIC_LINK_MAIL} from "swaf/Mails";
import PasswordAuthMethod from "swaf/auth/password/PasswordAuthMethod";
import MagicLinkController from "swaf/auth/magic_link/MagicLinkController";
import AuthController from "swaf/auth/AuthController";
import MailController from "swaf/mail/MailController";
import AccountController from "swaf/auth/AccountController";
import packageJson = require('./package.json');
import AddUsedToMagicLinksMigration from "swaf/auth/magic_link/AddUsedToMagicLinksMigration";
import MakeMagicLinksSessionNotUniqueMigration from "swaf/auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
import AddNameChangedAtToUsersMigration from "swaf/auth/migrations/AddNameChangedAtToUsersMigration";
export default class App extends Application { export default class App extends Application {
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
public constructor( public constructor(
private readonly addr: string, private readonly addr: string,
private readonly port: number, private readonly port: number,
@ -56,18 +56,15 @@ export default class App extends Application {
return [ return [
CreateMigrationsTable, CreateMigrationsTable,
DummyMigration, DummyMigration,
CreateUsersAndUserEmailsTableMigration, CreateUsersAndUserEmailsTable,
AddPasswordToUsersMigration, AddPasswordToUsers,
CreateMagicLinksTableMigration, CreateMagicLinksTable,
AddApprovedFieldToUsersTableMigration, AddApprovedFieldToUsersTable,
DummyMigration, FixUserMainEmailRelation,
DummyMigration, DropNameFromUsers,
AddNameToUsersMigration, AddNameToUsers,
CreateMailTablesMigration, CreateMailTables,
DropLegacyLogsTable, DropLegacyLogsTable,
AddUsedToMagicLinksMigration,
MakeMagicLinksSessionNotUniqueMigration,
AddNameChangedAtToUsersMigration,
]; ];
} }
@ -78,8 +75,12 @@ export default class App extends Application {
} }
private registerComponents() { private registerComponents() {
const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent();
// Base // Base
this.use(new ExpressAppComponent(this.addr, this.port)); const expressAppComponent = new ExpressAppComponent(this.addr, this.port);
this.use(expressAppComponent);
this.use(new LogRequestsComponent()); this.use(new LogRequestsComponent());
// Static files // Static files
@ -88,21 +89,21 @@ export default class App extends Application {
// Dynamic views and routes // Dynamic views and routes
this.use(new NunjucksComponent()); this.use(new NunjucksComponent());
this.use(new PreviousUrlComponent()); this.use(new RedirectBackComponent());
// Maintenance // Maintenance
this.use(new MaintenanceComponent(this, () => { this.use(new MaintenanceComponent(this, () => {
return this.as(RedisComponent).canServe() && this.as(MysqlComponent).canServe(); return redisComponent.canServe() && mysqlComponent.canServe();
})); }));
this.use(new AutoUpdateComponent()); this.use(new AutoUpdateComponent());
// Services // Services
this.use(new MysqlComponent()); this.use(mysqlComponent);
this.use(new MailComponent()); this.use(new MailComponent());
// Session // Session
this.use(new RedisComponent()); this.use(redisComponent);
this.use(new SessionComponent(this.as(RedisComponent))); this.use(new SessionComponent(redisComponent));
// Utils // Utils
this.use(new FormHelperComponent()); this.use(new FormHelperComponent());
@ -111,33 +112,37 @@ export default class App extends Application {
this.use(new CsrfProtectionComponent()); this.use(new CsrfProtectionComponent());
// Auth // Auth
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this))); this.use(new AuthComponent(new class extends AuthGuard<PasswordAuthProof | MagicLink> {
public async getProofForSession(session: Express.Session): Promise<PasswordAuthProof | MagicLink | null> {
return PasswordAuthProof.getProofForSession(session) ||
await MagicLink.bySessionId(session.id);
}
}(this)));
// WebSocket server // WebSocket server
this.use(new WebSocketServerComponent(this, this.as(ExpressAppComponent), this.as(RedisComponent))); this.use(new WebSocketServerComponent(this, expressAppComponent, redisComponent));
// LDAP server // LDAP server
this.use(new LDAPServerComponent()); this.use(new LDAPServerComponent());
} }
private registerWebSocketListeners() { private registerWebSocketListeners() {
this.use(new MagicLinkWebSocketListener()); this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
this.use(this.magicLinkWebSocketListener);
} }
private registerControllers() { private registerControllers() {
// Priority routes / interrupting middlewares // Priority routes / interrupting middlewares
this.use(new MailAutoConfigController()); // Needs to override MailController
this.use(new MailController());
this.use(new AuthController());
this.use(new AccountController()); this.use(new AccountController());
this.use(new AccountMailboxController()); this.use(new MagicLinkController(this.as(MagicLinkWebSocketListener)));
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
// Core functionality
this.use(new BackendController()); this.use(new BackendController());
this.use(new MailboxBackendController()); this.use(new MailboxBackendController());
this.use(new AccountBackendController()); this.use(new AccountBackendController());
this.use(new AuthController());
this.use(new MailAutoConfigController()); // Needs to override MailController
// Core functionality
this.use(new MailController());
// Other functionnality // Other functionnality

View File

@ -1,9 +1,9 @@
import ApplicationComponent from "swaf/ApplicationComponent"; import ApplicationComponent from "wms-core/ApplicationComponent";
import ldap, {InvalidCredentialsError, Server} from "ldapjs"; import ldap, {InvalidCredentialsError, Server} from "ldapjs";
import {logger} from "swaf/Logger"; import {log} from "wms-core/Logger";
import Throttler from "swaf/Throttler"; import UserPasswordComponent from "./models/UserPasswordComponent";
import User from "swaf/auth/models/User"; import Throttler from "wms-core/Throttler";
import UserPasswordComponent from "swaf/auth/password/UserPasswordComponent"; import User from "wms-core/auth/models/User";
export default class LDAPServerComponent extends ApplicationComponent { export default class LDAPServerComponent extends ApplicationComponent {
private server?: Server; private server?: Server;
@ -24,12 +24,12 @@ export default class LDAPServerComponent extends ApplicationComponent {
} }
} }
logger.debug('Matrix authentication attempt:', username, email); log.debug('Matrix authentication attempt:', username, email);
try { try {
Throttler.throttle('ldap_auth', 3, 30 * 1000, username); Throttler.throttle('ldap_auth', 3, 30 * 1000, username);
} catch (e) { } catch (e) {
logger.debug('Too many auth requests'); log.debug('Too many auth requests');
next(new InvalidCredentialsError()); next(new InvalidCredentialsError());
return; return;
} }
@ -39,31 +39,31 @@ export default class LDAPServerComponent extends ApplicationComponent {
const email = await user.mainEmail.get(); const email = await user.mainEmail.get();
if (email) { if (email) {
if (await user.as(UserPasswordComponent).verifyPassword(req.credentials)) { if (await user.as(UserPasswordComponent).verifyPassword(req.credentials)) {
logger.debug('Success'); log.debug('Success');
res.end(); res.end();
return; return;
} }
} }
} }
logger.debug('Fail'); log.debug('Fail');
next(new InvalidCredentialsError()); next(new InvalidCredentialsError());
}); });
this.server.unbind((req: unknown, res: unknown, next: () => void) => { this.server.unbind((req: unknown, res: unknown, next: () => void) => {
logger.debug('Unbind', req); log.debug('Unbind', req);
next(); next();
}); });
this.server.listen(8389, '127.0.0.1', () => { this.server.listen(8389, '127.0.0.1', () => {
logger.info(`LDAP server listening on ${this.server?.url}`); log.info(`LDAP server listening on ${this.server?.url}`);
}); });
this.server.on('close', () => { this.server.on('close', () => {
logger.info('LDAP server closed.'); log.info('LDAP server closed.');
}); });
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
await new Promise<void>(resolve => { await new Promise(resolve => {
if (this.server) { if (this.server) {
this.server.close(() => { this.server.close(() => {
resolve(); resolve();

6
src/Mails.ts Normal file
View File

@ -0,0 +1,6 @@
import {MailTemplate} from "wms-core/Mail";
export const ADD_RECOVERY_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'add_recovery_email',
(data) => 'Add ' + data.email + ' as you recovery email.',
);

View File

@ -0,0 +1,215 @@
import Controller from "wms-core/Controller";
import {RequireAuthMiddleware} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import {ADD_RECOVERY_EMAIL_MAIL_TEMPLATE} from "../Mails";
import Validator, {EMAIL_REGEX, InvalidFormatValidationError, ValidationBag} from "wms-core/db/Validator";
import MagicLinkController from "./MagicLinkController";
import {MagicLinkActionType} from "./MagicLinkActionType";
import UserEmail from "wms-core/auth/models/UserEmail";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
import MailDomain from "../models/MailDomain";
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
import MailIdentity from "../models/MailIdentity";
import UserNameComponent from "../models/UserNameComponent";
import {WhereOperator, WhereTest} from "wms-core/db/ModelQuery";
import UserPasswordComponent from "../models/UserPasswordComponent";
export default class AccountController extends Controller {
public getRoutesPrefix(): string {
return '/account';
}
public routes(): void {
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('/set-main-email', this.postSetMainRecoveryEmail, 'set-main-recovery-email', RequireAuthMiddleware);
this.post('/remove-email', this.postRemoveRecoveryEmail, 'remove-recovery-email', RequireAuthMiddleware);
this.post('/create-mail-identity', this.postCreateMailIdentity, 'create-mail-identity', RequireAuthMiddleware);
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'delete-mail-identity', RequireAuthMiddleware);
}
protected async getAccount(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const userMailIdentity = user.as(UserMailIdentityComponent);
res.render('account', {
main_email: await user.mainEmail.get(),
emails: await user.emails.get(),
mailboxIdentity: await (await userMailIdentity.mainMailIdentity.get())?.toEmail(),
identities: await Promise.all((await userMailIdentity.mailIdentities.get()).map(async identity => ({
id: identity.id,
email: await identity.toEmail(),
}))),
domains: (await MailDomain.select()
.where('user_id', user.id)
.where('user_id', null, WhereTest.EQ, WhereOperator.OR)
.sortBy('user_id', 'DESC')
.get())
.map(d => ({
value: d.id,
display: d.name,
})),
});
}
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> {
await this.validate({
email: new Validator().defined().regexp(EMAIL_REGEX),
}, req.body);
const email = req.body.email;
// Existing email
if (await UserEmail.select().where('email', email).first()) {
const bag = new ValidationBag();
const error = new InvalidFormatValidationError('You already have this email.');
error.thingName = 'email';
bag.addMessage(error);
throw bag;
}
if (!req.sessionID) throw new ServerError('Session not initialized.');
await MagicLinkController.sendMagicLink(
this.getApp(),
req.sessionID,
MagicLinkActionType.ADD_RECOVERY_EMAIL,
Controller.route('account'),
email,
ADD_RECOVERY_EMAIL_MAIL_TEMPLATE,
{},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'),
}));
}
protected async postSetMainRecoveryEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== user.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === user.main_email_id)
throw new BadRequestError('This address is already your main address', 'Try refreshing the account page.', req.url);
user.main_email_id = userEmail.id;
await user.save();
req.flash('success', 'This email was successfully set as your main address.');
res.redirectBack();
}
protected async postRemoveRecoveryEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== user.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === user.main_email_id)
throw new BadRequestError('Cannot remove main email address', 'Try refreshing the account page.', req.url);
await userEmail.delete();
req.flash('success', 'This email was successfully removed from your account.');
res.redirectBack();
}
protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> {
const domain = await MailDomain.getById(req.body.mail_domain_id);
if (!domain) throw new NotFoundHttpError('domain', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
const mailIdentityComponent = user.as(UserMailIdentityComponent);
const identity = MailIdentity.create({
user_id: user.id,
name: req.body.name,
mail_domain_id: req.body.mail_domain_id,
});
// Check whether this identity can be created by this user
if (domain.isPublic()) {
await this.validate({
name: new Validator<string>().defined().equals(user.as(UserNameComponent).name),
}, req.body);
const actualPublicAddressesCount = await mailIdentityComponent.getPublicAddressesCount();
const maxPublicAddressesCount = mailIdentityComponent.getMaxPublicAddressesCount();
if (actualPublicAddressesCount >= maxPublicAddressesCount) {
req.flash('error', 'You have reached maximum public email addresses.');
res.redirectBack();
return;
}
} else {
if (!domain.canCreateAddresses(user)) {
throw new ForbiddenHttpError('domain', req.url);
}
}
// Save identity
await identity.save();
// Set main mail identity if not already set
if (!mailIdentityComponent.main_mail_identity_id) {
mailIdentityComponent.main_mail_identity_id = identity.id;
await user.save();
req.flash('info', 'Congratulations! You just created your mailbox.');
}
req.flash('success', 'Mail identity ' + await identity.toEmail() + ' successfully created.');
res.redirectBack();
}
protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const identity = await MailIdentity.getById(req.body.id);
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
if (identity.user_id !== user.id) throw new ForbiddenHttpError('mail identity', req.url);
if (user.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
req.flash('error', 'Cannot delete your mailbox identity.');
res.redirectBack();
return;
}
await identity.delete();
req.flash('success', 'Identity ' + await identity.toEmail() + ' successfully deleted.');
res.redirectBack();
}
}

View File

@ -1,106 +0,0 @@
import Controller from "swaf/Controller";
import {RequireAuthMiddleware} from "swaf/auth/AuthComponent";
import {Request, Response} from "express";
import Validator from "swaf/db/Validator";
import {ForbiddenHttpError, NotFoundHttpError} from "swaf/HttpError";
import MailDomain from "../models/MailDomain";
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
import MailIdentity from "../models/MailIdentity";
import {WhereOperator, WhereTest} from "swaf/db/ModelQuery";
import UserNameComponent from "swaf/auth/models/UserNameComponent";
export default class AccountMailboxController extends Controller {
public getRoutesPrefix(): string {
return '/account/mailbox';
}
public routes(): void {
this.get('/', this.getAccountMailbox, 'account-mailbox', RequireAuthMiddleware);
this.post('/create-mail-identity', this.postCreateMailIdentity, 'create-mail-identity', RequireAuthMiddleware);
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'delete-mail-identity', RequireAuthMiddleware);
}
protected async getAccountMailbox(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const userMailIdentity = user.as(UserMailIdentityComponent);
res.render('account-mailbox', {
mailboxIdentity: await (await userMailIdentity.mainMailIdentity.get())?.toEmail(),
identities: await Promise.all((await userMailIdentity.mailIdentities.get()).map(async identity => ({
id: identity.id,
email: await identity.toEmail(),
}))),
domains: (await MailDomain.select()
.where('user_id', user.id)
.where('user_id', null, WhereTest.EQ, WhereOperator.OR)
.sortBy('user_id', 'DESC')
.get())
.map(d => ({
value: d.id,
display: d.name,
})),
});
}
protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> {
const domain = await MailDomain.getById(req.body.mail_domain_id);
if (!domain) throw new NotFoundHttpError('domain', req.url);
const user = req.as(RequireAuthMiddleware).getUser();
const mailIdentityComponent = user.as(UserMailIdentityComponent);
const identity = MailIdentity.create({
user_id: user.id,
name: req.body.name,
mail_domain_id: req.body.mail_domain_id,
});
// Check whether this identity can be created by this user
if (!domain.canCreateAddresses(user)) {
throw new ForbiddenHttpError('domain', req.url);
}
if (domain.isPublic()) {
await Validator.validate({
name: new Validator<string>().defined().equals(user.as(UserNameComponent).getName()),
}, req.body);
const actualPublicAddressesCount = await mailIdentityComponent.getPublicAddressesCount();
const maxPublicAddressesCount = mailIdentityComponent.getMaxPublicAddressesCount();
if (maxPublicAddressesCount >= 0 && actualPublicAddressesCount >= maxPublicAddressesCount) {
req.flash('error', 'You have reached maximum public email addresses.');
res.redirect(Controller.route('account-mailbox'));
return;
}
}
// Save identity
await identity.save();
// Set main mail identity if not already set
if (!mailIdentityComponent.main_mail_identity_id) {
mailIdentityComponent.main_mail_identity_id = identity.id;
await user.save();
req.flash('info', 'Congratulations! You just created your mailbox.');
}
req.flash('success', 'Mail identity ' + await identity.toEmail() + ' successfully created.');
res.redirect(Controller.route('account-mailbox'));
}
protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const identity = await MailIdentity.getById(req.body.id);
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
if (identity.user_id !== user.id) throw new ForbiddenHttpError('mail identity', req.url);
if (user.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
req.flash('error', 'Cannot delete your mailbox identity.');
res.redirect(Controller.route('account-mailbox'));
return;
}
await identity.delete();
req.flash('success', 'Identity ' + await identity.toEmail() + ' successfully deleted.');
res.redirect(Controller.route('account-mailbox'));
}
}

View File

@ -0,0 +1,127 @@
import Controller from "wms-core/Controller";
import AuthComponent, {RequireAuthMiddleware, RequireGuestMiddleware} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import Validator, {InvalidFormatValidationError, ValidationBag} from "wms-core/db/Validator";
import UserPasswordComponent, {PasswordAuthProof} from "../models/UserPasswordComponent";
import UserNameComponent, {USERNAME_REGEXP} from "../models/UserNameComponent";
import _AuthController from "wms-core/auth/AuthController";
import {NotFoundHttpError, ServerError} from "wms-core/HttpError";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "wms-core/auth/AuthGuard";
import User from "wms-core/auth/models/User";
import Throttler from "wms-core/Throttler";
export default class AuthController extends _AuthController {
public routes(): void {
this.get('/login', this.getLogin, 'auth', RequireGuestMiddleware);
this.post('/login', this.postLogin, 'auth', RequireGuestMiddleware);
this.get('/register', this.getRegister, 'register', RequireGuestMiddleware);
this.post('/register', this.postRegister, 'register', RequireGuestMiddleware);
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
}
protected async getLogin(req: Request, res: Response): Promise<void> {
res.render('login');
}
protected async postLogin(req: Request, res: Response): Promise<void> {
await this.validate({
username: new Validator().defined().exists(User, 'name'),
password: new Validator().acceptUndefined(),
}, req.body);
const user = await User.select()
.where('name', req.body.username)
.first();
if (!user) throw new NotFoundHttpError(`Couldn't find a user with name ${req.body.username}`, req.url);
if (!req.session) throw new ServerError('Session not initialized.');
const passwordAuthProof = PasswordAuthProof.createProofForLogin(req.session);
passwordAuthProof.setResource(user);
await passwordAuthProof.authorize(req.body.password);
try {
await this.getApp().as(AuthComponent).getAuthGuard().authenticateOrRegister(req.session, passwordAuthProof);
} catch (e) {
if (e instanceof AuthError) {
Throttler.throttle('login_failed_attempts_user', 3, 180000, <string>user.getOrFail('name'), 1000, 60000);
Throttler.throttle('login_failed_attempts_ip', 5, 60000, req.ip, 1000, 60000);
if (e instanceof PendingApprovalAuthError) {
req.flash('error', 'Your account is still being reviewed.');
res.redirectBack();
return;
} else {
const bag = new ValidationBag();
const err = new InvalidFormatValidationError('Invalid password.');
err.thingName = 'password';
bag.addMessage(err);
throw bag;
}
} else {
throw e;
}
}
req.flash('success', `Welcome, ${user.name}.`);
res.redirect(Controller.route('home'));
}
protected async getRegister(req: Request, res: Response): Promise<void> {
res.render('register');
}
protected async postRegister(req: Request, res: Response): Promise<void> {
Throttler.throttle('register_password', 10, 30000, req.ip);
await this.validate({
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(User, 'name'),
password: new Validator().defined().minLength(8),
password_confirmation: new Validator().defined().sameAs('password', req.body.password),
terms: new Validator().defined(),
}, req.body);
if(!req.session) throw new ServerError('Session not initialized.');
const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.session);
try {
await this.getApp().as(AuthComponent).getAuthGuard().authenticateOrRegister(req.session, passwordAuthProof,
undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
// Password
await user.as(UserPasswordComponent).setPassword(req.body.password);
// Username
user.as(UserNameComponent).name = req.body.username;
return callbacks;
}, async (connection, user) => {
passwordAuthProof.setResource(user);
return [];
});
} catch (e) {
if (e instanceof PendingApprovalAuthError) {
req.flash('info', `Your account was successfully created and is pending review from an administrator.`);
res.redirect(Controller.route('home'));
return;
} else {
throw e;
}
}
const user = await passwordAuthProof.getResource();
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).name}.`);
res.redirect(Controller.route('home'));
}
protected async getCheckAuth(): Promise<void> {
throw new ServerError('Not implemented.');
}
protected async postAuth(): Promise<void> {
throw new ServerError('Not implemented.');
}
}

View File

@ -1,4 +1,4 @@
import Controller from "swaf/Controller"; import Controller from "wms-core/Controller";
import {Request, Response} from "express"; import {Request, Response} from "express";
import config from "config"; import config from "config";
@ -20,9 +20,9 @@ export default class HomeController extends Controller {
} }
/** /**
* This is to test and assert that swaf extended types are available * This is to test and assert that wms-core extended types are available
*/ */
protected async goBack(req: Request, res: Response): Promise<void> { protected async goBack(req: Request, res: Response): Promise<void> {
res.redirect(req.getPreviousUrl() || Controller.route('home')); res.redirectBack();
} }
} }

View File

@ -0,0 +1,3 @@
export enum MagicLinkActionType {
ADD_RECOVERY_EMAIL = 'Add a recovery email',
}

View File

@ -0,0 +1,51 @@
import _MagicLinkController from "wms-core/auth/magic_link/MagicLinkController";
import MagicLink from "wms-core/auth/models/MagicLink";
import {Request, Response} from "express";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener";
import {MagicLinkActionType} from "./MagicLinkActionType";
import Controller from "wms-core/Controller";
import {BadOwnerMagicLink} from "wms-core/auth/magic_link/MagicLinkAuthController";
import UserEmail from "wms-core/auth/models/UserEmail";
import App from "../App";
import AuthComponent from "wms-core/auth/AuthComponent";
export default class MagicLinkController extends _MagicLinkController<App> {
public constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener<App>) {
super(magicLinkWebSocketListener);
}
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
if (magicLink.action_type === MagicLinkActionType.ADD_RECOVERY_EMAIL) {
if (!req.session || !req.sessionID || magicLink.session_id !== req.sessionID) throw new BadOwnerMagicLink();
await magicLink.delete();
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const proof = await authGuard.isAuthenticated(req.session);
const user = await proof?.getResource();
if (!user) return;
const email = await magicLink.getOrFail('email');
if (await UserEmail.select().with('user').where('email', email).first()) {
req.flash('error', 'An account already exists with this email address. Please first remove it there before adding it here.');
res.redirect(Controller.route('account'));
return;
}
const userEmail = UserEmail.create({
user_id: user.id,
email: email,
main: false,
});
await userEmail.save();
if (!user.main_email_id) {
user.main_email_id = userEmail.id;
await user.save();
}
req.flash('success', `Recovery email ${userEmail.email} successfully added.`);
res.redirect(Controller.route('account'));
}
}
}

View File

@ -1,4 +1,4 @@
import Controller from "swaf/Controller"; import Controller from "wms-core/Controller";
import {Request, Response} from "express"; import {Request, Response} from "express";
import MailDomain from "../models/MailDomain"; import MailDomain from "../models/MailDomain";
import config from "config"; import config from "config";

View File

@ -1,12 +1,12 @@
import Controller from "swaf/Controller"; import Controller from "wms-core/Controller";
import BackendController from "swaf/helpers/BackendController"; import BackendController from "wms-core/helpers/BackendController";
import {RequireAdminMiddleware, RequireAuthMiddleware} from "swaf/auth/AuthComponent"; import {RequireAdminMiddleware, RequireAuthMiddleware} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express"; import {Request, Response} from "express";
import User from "swaf/auth/models/User"; import User from "wms-core/auth/models/User";
import {NotFoundHttpError} from "swaf/HttpError"; import {NotFoundHttpError} from "wms-core/HttpError";
import Validator from "swaf/db/Validator"; import Validator from "wms-core/db/Validator";
import UserPasswordComponent from "swaf/auth/password/UserPasswordComponent"; import UserPasswordComponent from "../../models/UserPasswordComponent";
import UserNameComponent from "swaf/auth/models/UserNameComponent"; import UserNameComponent from "../../models/UserNameComponent";
export default class AccountBackendController extends Controller { export default class AccountBackendController extends Controller {
@ -51,7 +51,7 @@ export default class AccountBackendController extends Controller {
const user = await User.getById(req.params.user_id); const user = await User.getById(req.params.user_id);
if (!user) throw new NotFoundHttpError('user', req.url); if (!user) throw new NotFoundHttpError('user', req.url);
await Validator.validate({ await this.validate({
'new_password': new Validator().defined(), 'new_password': new Validator().defined(),
'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password), 'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password),
}, req.body); }, req.body);
@ -59,7 +59,7 @@ export default class AccountBackendController extends Controller {
await user.as(UserPasswordComponent).setPassword(req.body.new_password, 'new_password'); await user.as(UserPasswordComponent).setPassword(req.body.new_password, 'new_password');
await user.save(); await user.save();
req.flash('success', `New password set for ${user.as(UserNameComponent).getName()}`); req.flash('success', `New password set for ${user.as(UserNameComponent).name}`);
res.redirect(Controller.route('backend-list-users')); res.redirect(Controller.route('backend-list-users'));
} }
} }

View File

@ -1,23 +1,18 @@
import Controller from "swaf/Controller"; import Controller from "wms-core/Controller";
import {Request, Response} from "express"; import {Request, Response} from "express";
import User from "swaf/auth/models/User"; import User from "wms-core/auth/models/User";
import {WhereTest} from "swaf/db/ModelQuery"; import {WhereTest} from "wms-core/db/ModelQuery";
import UserNameComponent from "../../models/UserNameComponent";
import UserMailIdentityComponent from "../../models/UserMailIdentityComponent"; import UserMailIdentityComponent from "../../models/UserMailIdentityComponent";
import {NotFoundHttpError, ServerError} from "swaf/HttpError"; import {NotFoundHttpError} from "wms-core/HttpError";
import MailDomain from "../../models/MailDomain"; import MailDomain from "../../models/MailDomain";
import BackendController from "swaf/helpers/BackendController"; import BackendController from "wms-core/helpers/BackendController";
import MailIdentity from "../../models/MailIdentity"; import MailIdentity from "../../models/MailIdentity";
import {RequireAdminMiddleware, RequireAuthMiddleware} from "swaf/auth/AuthComponent"; import {RequireAdminMiddleware, RequireAuthMiddleware} from "wms-core/auth/AuthComponent";
import UserNameComponent from "swaf/auth/models/UserNameComponent";
export default class MailboxBackendController extends Controller { export default class MailboxBackendController extends Controller {
public constructor() { public constructor() {
super(); super();
BackendController.registerMenuElement({
getLink: async () => Controller.route('backend-mail-domains'),
getDisplayString: async () => 'Mail domains',
getDisplayIcon: async () => 'globe',
});
BackendController.registerMenuElement({ BackendController.registerMenuElement({
getLink: async () => Controller.route('backend-mailboxes'), getLink: async () => Controller.route('backend-mailboxes'),
getDisplayString: async () => 'Mailboxes', getDisplayString: async () => 'Mailboxes',
@ -31,20 +26,23 @@ export default class MailboxBackendController extends Controller {
public routes(): void { public routes(): void {
this.get('/', this.getMailboxesBackend, 'backend-mailboxes', RequireAuthMiddleware, RequireAdminMiddleware); this.get('/', this.getMailboxesBackend, 'backend-mailboxes', RequireAuthMiddleware, RequireAdminMiddleware);
this.get('/mailbox/:id', this.getMailboxBackend, 'backend-mailbox', RequireAuthMiddleware, RequireAdminMiddleware); this.get('/:id', this.getMailboxBackend, 'backend-mailbox', RequireAuthMiddleware, RequireAdminMiddleware);
this.get('/domains', this.getDomainsBackend, 'backend-mail-domains', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/add-domain', this.postAddDomain, 'backend-add-domain', RequireAuthMiddleware, RequireAdminMiddleware); this.post('/add-domain', this.postAddDomain, 'backend-add-domain', RequireAuthMiddleware, RequireAdminMiddleware);
this.get('/edit-domain/:id', this.getEditDomain, 'backend-edit-domain', RequireAuthMiddleware, RequireAdminMiddleware); this.get('/edit-domain/:id', this.getEditDomain, 'backend-edit-domain', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/edit-domain/:id', this.postEditDomain, 'backend-edit-domain', RequireAuthMiddleware, RequireAdminMiddleware); this.post('/edit-domain/:id', this.postEditDomain, 'backend-edit-domain', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/remove-domain', this.postRemoveDomain, 'backend-remove-domain', RequireAuthMiddleware, RequireAdminMiddleware); this.post('/remove-domain', this.postRemoveDomain, 'backend-remove-domain', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/:id/create-mail-identity', this.postCreateMailIdentity, 'backend-create-mail-identity', RequireAuthMiddleware, RequireAdminMiddleware); this.post('/:id/create-mail-identity', this.postCreateMailIdentity, 'backend-create-mail-identity', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/set-main-mail-identity', this.postSetMainMailIdentity, 'backend-set-main-mail-identity', RequireAuthMiddleware, RequireAdminMiddleware);
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'backend-delete-mail-identity', RequireAuthMiddleware, RequireAdminMiddleware); this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'backend-delete-mail-identity', RequireAuthMiddleware, RequireAdminMiddleware);
} }
protected async getMailboxesBackend(req: Request, res: Response): Promise<void> { protected async getMailboxesBackend(req: Request, res: Response): Promise<void> {
const mailDomains = await MailDomain.select()
.with('owner')
.with('identities')
.get();
const users = await User.select() const users = await User.select()
.where('main_mail_identity_id', null, WhereTest.NE) .where('main_mail_identity_id', null, WhereTest.NE)
.with('mainMailIdentity') .with('mainMailIdentity')
@ -52,9 +50,22 @@ export default class MailboxBackendController extends Controller {
.get(); .get();
res.render('backend/mailboxes', { res.render('backend/mailboxes', {
domains: await Promise.all(mailDomains.map(async domain => ({
id: domain.id,
name: domain.name,
owner_name: (await domain.owner.get())?.as(UserNameComponent).name,
identity_count: (await domain.identities.get()).length,
}))),
users: [{
value: 0,
display: 'Public',
}, ...(await User.select().get()).map(u => ({
value: u.id,
display: u.name,
}))],
mailboxes: await Promise.all(users.map(async user => ({ mailboxes: await Promise.all(users.map(async user => ({
id: user.id, id: user.id,
username: user.as(UserNameComponent).getName(), username: user.as(UserNameComponent).name,
name: await (await user.as(UserMailIdentityComponent).mainMailIdentity.get())?.toEmail(), name: await (await user.as(UserMailIdentityComponent).mainMailIdentity.get())?.toEmail(),
identity_count: (await user.as(UserMailIdentityComponent).mailIdentities.get()).length, identity_count: (await user.as(UserMailIdentityComponent).mailIdentities.get()).length,
domain_count: (await user.as(UserMailIdentityComponent).mailDomains.get()).length, domain_count: (await user.as(UserMailIdentityComponent).mailDomains.get()).length,
@ -75,9 +86,8 @@ 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).getName(), userName: user.as(UserNameComponent).name,
name: await mainMailIdentity?.toEmail() || 'Not created.', name: await mainMailIdentity?.toEmail() || 'Not created.',
exists: !!mainMailIdentity,
}, },
domains: mailDomains.map(d => ({ domains: mailDomains.map(d => ({
display: d.name, display: d.name,
@ -90,34 +100,11 @@ export default class MailboxBackendController extends Controller {
}); });
} }
protected async getDomainsBackend(req: Request, res: Response): Promise<void> {
const mailDomains = await MailDomain.select()
.with('owner')
.with('identities')
.get();
res.render('backend/mail_domains', {
domains: await Promise.all(mailDomains.map(async domain => ({
id: domain.id,
name: domain.name,
owner_name: (await domain.owner.get())?.as(UserNameComponent).getName(),
identity_count: (await domain.identities.get()).length,
}))),
users: [{
value: 0,
display: 'Public',
}, ...(await User.select().get()).map(u => ({
value: u.id,
display: u.name,
}))],
});
}
protected async postAddDomain(req: Request, res: Response): Promise<void> { protected async postAddDomain(req: Request, res: Response): Promise<void> {
const domain = MailDomain.create(req.body); const domain = MailDomain.create(req.body);
await domain.save(); await domain.save();
req.flash('success', `Domain ${domain.name} successfully added with owner ${(await domain.owner.get())?.name}`); req.flash('success', `Domain ${domain.name} successfully added with owner ${(await domain.owner.get())?.name}`);
res.redirect(Controller.route('backend-edit-domain', domain.id)); res.redirectBack();
} }
protected async getEditDomain(req: Request, res: Response): Promise<void> { protected async getEditDomain(req: Request, res: Response): Promise<void> {
@ -146,7 +133,7 @@ export default class MailboxBackendController extends Controller {
await domain.save(); await domain.save();
req.flash('success', `Domain ${domain.name} updated successfully.`); req.flash('success', `Domain ${domain.name} updated successfully.`);
res.redirect(Controller.route('backend-edit-domain', domain.id)); res.redirectBack();
} }
protected async postRemoveDomain(req: Request, res: Response): Promise<void> { protected async postRemoveDomain(req: Request, res: Response): Promise<void> {
@ -159,14 +146,14 @@ export default class MailboxBackendController extends Controller {
// Don't delete that domain if it still has identities // Don't delete that domain if it still has identities
if ((await domain.identities.get()).length > 0) { if ((await domain.identities.get()).length > 0) {
req.flash('error', `This domain still has identities. Please remove all of these first (don't forget to rename mailboxes).`); req.flash('error', `This domain still has identities. Please remove all of these first (don't forget to rename mailboxes).`);
res.redirect(Controller.route('backend-edit-domain', domain.id)); res.redirectBack();
return; return;
} }
await domain.delete(); await domain.delete();
req.flash('success', `Domain ${domain.name} successfully deleted.`); req.flash('success', `Domain ${domain.name} successfully deleted.`);
res.redirect(Controller.route('backend-mailboxes')); res.redirectBack();
} }
protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> { protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> {
@ -197,7 +184,7 @@ export default class MailboxBackendController extends Controller {
} }
req.flash('success', 'Mail identity ' + await identity.toEmail() + ' successfully created.'); req.flash('success', 'Mail identity ' + await identity.toEmail() + ' successfully created.');
res.redirect(Controller.route('backend-mailbox', user.id)); res.redirectBack();
} }
protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> { protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> {
@ -207,38 +194,15 @@ export default class MailboxBackendController extends Controller {
.first(); .first();
if (!identity) throw new NotFoundHttpError('Mail identity', req.url); if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
const user = identity.user.getOrFail(); const user = await identity.user.get();
if (!user) throw new NotFoundHttpError('Mail identity owner', req.url); if (user?.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
if (user.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
req.flash('error', `Cannot delete this user's mailbox identity.`); req.flash('error', `Cannot delete this user's mailbox identity.`);
res.redirect(Controller.route('backend-mailbox', user.id)); res.redirectBack();
return; return;
} }
await identity.delete(); await identity.delete();
req.flash('success', 'Identity ' + await identity.toEmail() + ' successfully deleted.'); req.flash('success', 'Identity ' + await identity.toEmail() + ' successfully deleted.');
res.redirect(Controller.route('backend-mailbox', user.id)); res.redirectBack();
}
protected async postSetMainMailIdentity(req: Request, res: Response): Promise<void> {
const identity = await MailIdentity.select()
.where('id', req.body.id)
.with('user.mainMailIdentity')
.first();
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
const user = await identity.user.getOrFail();
if (!user) throw new NotFoundHttpError('Mail identity owner', req.url);
const mailIdentityComponent = user.as(UserMailIdentityComponent);
const mainMailIdentity = mailIdentityComponent.mainMailIdentity.getOrFail();
if (!mainMailIdentity) throw new ServerError('Could not find this users main mail identity.');
mailIdentityComponent.main_mail_identity_id = identity.id;
await user.save();
req.flash('success', 'User ' + user.id + ' main mail identity set to ' + await identity.toEmail());
req.flash('warning', 'Please rename user\'s mailbox folder to correspond to changing from ' + await mainMailIdentity.toEmail() + ' to ' + await identity.toEmail());
res.redirect(Controller.route('backend-mailbox', user.id));
} }
} }

View File

@ -1,20 +1,20 @@
import {delimiter} from "path"; import {delimiter} from "path";
// Load config from specified path or default + swaf/config (default defaults) // Load config from specified path or default + wms-core/config (default defaults)
process.env['NODE_CONFIG_DIR'] = process.env['NODE_CONFIG_DIR'] =
__dirname + '/../node_modules/swaf/config/' __dirname + '/../../node_modules/wms-core/config/'
+ delimiter + delimiter
+ (process.env['NODE_CONFIG_DIR'] || __dirname + '/../config/'); + (process.env['NODE_CONFIG_DIR'] || __dirname + '/../../config/');
import {logger} from "swaf/Logger"; import {log} from "wms-core/Logger";
import App from "./App"; import App from "./App";
import config from "config"; import config from "config";
(async () => { (async () => {
logger.debug('Config path:', process.env['NODE_CONFIG_DIR']); log.debug('Config path:', process.env['NODE_CONFIG_DIR']);
const app = new App(config.get<string>('listen_addr'), config.get<number>('port')); const app = new App(config.get<string>('listen_addr'), config.get<number>('port'));
await app.start(); await app.start();
})().catch(err => { })().catch(err => {
logger.error(err); log.error(err);
}); });

View File

@ -0,0 +1,20 @@
import Migration from "wms-core/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory";
import User from "wms-core/auth/models/User";
import UserNameComponent from "../models/UserNameComponent";
import {Connection} from "mysql";
export default class AddNameToUsers extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN name VARCHAR(64) UNIQUE NOT NULL`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('ALTER TABLE users DROP COLUMN name', connection);
}
public registerModels(): void {
ModelFactory.get(User).addComponent(UserNameComponent);
}
}

View File

@ -0,0 +1,21 @@
import Migration from "wms-core/db/Migration";
import {Connection} from "mysql";
import ModelFactory from "wms-core/db/ModelFactory";
import User from "wms-core/auth/models/User";
import UserPasswordComponent from "../models/UserPasswordComponent";
export default class AddPasswordToUsers extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN password VARCHAR(128) NOT NULL`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users
DROP COLUMN password`, connection);
}
public registerModels(): void {
ModelFactory.get(User).addComponent(UserPasswordComponent);
}
}

View File

@ -1,12 +1,13 @@
import Migration from "swaf/db/Migration"; import Migration from "wms-core/db/Migration";
import ModelFactory from "swaf/db/ModelFactory"; import {Connection} from "mysql";
import ModelFactory from "wms-core/db/ModelFactory";
import MailDomain from "../models/MailDomain"; import MailDomain from "../models/MailDomain";
import MailIdentity from "../models/MailIdentity"; import MailIdentity from "../models/MailIdentity";
import User from "swaf/auth/models/User"; import User from "wms-core/auth/models/User";
import UserMailIdentityComponent from "../models/UserMailIdentityComponent"; import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
export default class CreateMailTablesMigration extends Migration { export default class CreateMailTables extends Migration {
public async install(): Promise<void> { public async install(connection: Connection): Promise<void> {
await this.query(`CREATE TABLE mail_domains await this.query(`CREATE TABLE mail_domains
( (
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
@ -14,7 +15,7 @@ export default class CreateMailTablesMigration extends Migration {
user_id INT, user_id INT,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
)`); )`, connection);
await this.query(`CREATE TABLE mail_identities await this.query(`CREATE TABLE mail_identities
( (
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
@ -26,17 +27,17 @@ export default class CreateMailTablesMigration extends Migration {
UNIQUE (mail_domain_id, name), UNIQUE (mail_domain_id, name),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (mail_domain_id) REFERENCES mail_domains (id) ON DELETE CASCADE FOREIGN KEY (mail_domain_id) REFERENCES mail_domains (id) ON DELETE CASCADE
)`); )`, connection);
await this.query(`ALTER TABLE users await this.query(`ALTER TABLE users
ADD COLUMN main_mail_identity_id INT, ADD COLUMN main_mail_identity_id INT,
ADD FOREIGN KEY main_mail_identity_fk (main_mail_identity_id) REFERENCES mail_identities (id)`); ADD FOREIGN KEY main_mail_identity_fk (main_mail_identity_id) REFERENCES mail_identities (id)`, connection);
} }
public async rollback(): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users await this.query(`ALTER TABLE users
DROP FOREIGN KEY main_mail_identity_fk, DROP FOREIGN KEY main_mail_identity_fk,
DROP COLUMN main_mail_identity_id`); DROP COLUMN main_mail_identity_id`, connection);
await this.query(`DROP TABLE IF EXISTS mail_identities, mail_domains`); await this.query(`DROP TABLE IF EXISTS mail_identities, mail_domains`, connection);
} }
public registerModels(): void { public registerModels(): void {

View File

@ -1,8 +1,7 @@
import Model from "swaf/db/Model"; import Model from "wms-core/db/Model";
import User from "swaf/auth/models/User"; import User from "wms-core/auth/models/User";
import {ManyModelRelation, OneModelRelation} from "swaf/db/ModelRelation"; import {ManyModelRelation, OneModelRelation} from "wms-core/db/ModelRelation";
import MailIdentity from "./MailIdentity"; import MailIdentity from "./MailIdentity";
import UserNameComponent from "swaf/auth/models/UserNameComponent";
export default class MailDomain extends Model { export default class MailDomain extends Model {
public id?: number = undefined; public id?: number = undefined;
@ -39,6 +38,6 @@ export default class MailDomain extends Model {
} }
public canCreateAddresses(user: User): boolean { public canCreateAddresses(user: User): boolean {
return this.user_id === user.id || this.isPublic() && user.as(UserNameComponent).hasName(); return this.user_id === user.id || this.isPublic();
} }
} }

View File

@ -1,8 +1,8 @@
import Model from "swaf/db/Model"; import Model from "wms-core/db/Model";
import User from "swaf/auth/models/User"; import User from "wms-core/auth/models/User";
import MailDomain from "./MailDomain"; import MailDomain from "./MailDomain";
import {OneModelRelation} from "swaf/db/ModelRelation"; import {OneModelRelation} from "wms-core/db/ModelRelation";
import {EMAIL_REGEX} from "swaf/db/Validator"; import {EMAIL_REGEX} from "wms-core/db/Validator";
export default class MailIdentity extends Model { export default class MailIdentity extends Model {
public static get table(): string { public static get table(): string {

View File

@ -1,9 +1,8 @@
import ModelComponent from "swaf/db/ModelComponent"; import ModelComponent from "wms-core/db/ModelComponent";
import User from "swaf/auth/models/User"; import User from "wms-core/auth/models/User";
import {ManyModelRelation, OneModelRelation} from "swaf/db/ModelRelation"; import {ManyModelRelation, OneModelRelation} from "wms-core/db/ModelRelation";
import MailIdentity from "./MailIdentity"; import MailIdentity from "./MailIdentity";
import MailDomain from "./MailDomain"; import MailDomain from "./MailDomain";
import config from "config";
export default class UserMailIdentityComponent extends ModelComponent<User> { export default class UserMailIdentityComponent extends ModelComponent<User> {
public main_mail_identity_id?: number = undefined; public main_mail_identity_id?: number = undefined;
@ -28,7 +27,7 @@ export default class UserMailIdentityComponent extends ModelComponent<User> {
}); });
public getMaxPublicAddressesCount(): number { public getMaxPublicAddressesCount(): number {
return config.get<number>('mail.max_public_identities_per_user'); return 1;
} }
public async getPublicAddressesCount(): Promise<number> { public async getPublicAddressesCount(): Promise<number> {

View File

@ -0,0 +1,13 @@
import User from "wms-core/auth/models/User";
import ModelComponent from "wms-core/db/ModelComponent";
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
export default class UserNameComponent extends ModelComponent<User> {
public name?: string = undefined;
public init(): void {
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
}
}

View File

@ -0,0 +1,109 @@
import Validator from "wms-core/db/Validator";
import User from "wms-core/auth/models/User";
import argon2, {argon2id} from "argon2";
import AuthProof from "wms-core/auth/AuthProof";
import ModelComponent from "wms-core/db/ModelComponent";
export default class UserPasswordComponent extends ModelComponent<User> {
private password?: string = undefined;
public init(): void {
this.setValidation('password').acceptUndefined().maxLength(128);
}
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
await new Validator<string>().defined().minLength(8).maxLength(512)
.execute(fieldName, rawPassword, true);
this.password = await argon2.hash(rawPassword, {
timeCost: 10,
memoryCost: 65536,
parallelism: 4,
type: argon2id,
hashLength: 32,
});
}
public async verifyPassword(passwordGuess: string): Promise<boolean> {
if (!this.password) return false;
return await argon2.verify(this.password, passwordGuess);
}
}
export class PasswordAuthProof implements AuthProof<User> {
public static getProofForSession(session: Express.Session): PasswordAuthProof | null {
return session.auth_password_proof ? new PasswordAuthProof(session) : null;
}
public static createAuthorizedProofForRegistration(session: Express.Session): PasswordAuthProof {
const proofForSession = new PasswordAuthProof(session);
proofForSession.authorized = true;
proofForSession.forRegistration = true;
proofForSession.save();
return proofForSession;
}
public static createProofForLogin(session: Express.Session): PasswordAuthProof {
return new PasswordAuthProof(session);
}
private readonly session: Express.Session;
private authorized: boolean;
private forRegistration: boolean = false;
private userId: number | null;
private userPassword: UserPasswordComponent | null = null;
private constructor(session: Express.Session) {
this.session = session;
this.authorized = session.auth_password_proof?.authorized || false;
this.forRegistration = session.auth_password_proof?.forRegistration || false;
this.userId = session.auth_password_proof?.userId || null;
}
public async getResource(): Promise<User | null> {
if (typeof this.userId !== 'number') return null;
return await User.getById(this.userId);
}
public setResource(user: User): void {
this.userId = user.getOrFail('id');
this.save();
}
public async isAuthorized(): Promise<boolean> {
return this.authorized;
}
public async isValid(): Promise<boolean> {
return (this.forRegistration || Boolean(await this.getResource())) &&
await this.isAuthorized();
}
public async revoke(): Promise<void> {
this.session.auth_password_proof = undefined;
}
private async getUserPassword(): Promise<UserPasswordComponent | null> {
if (!this.userPassword) {
this.userPassword = (await User.getById(this.userId))?.as(UserPasswordComponent) || null;
}
return this.userPassword;
}
public async authorize(passwordGuess: string): Promise<boolean> {
const password = await this.getUserPassword();
if (!password || !await password.verifyPassword(passwordGuess)) return false;
this.authorized = true;
this.save();
return true;
}
private save() {
this.session.auth_password_proof = {
authorized: this.authorized,
forRegistration: this.forRegistration,
userId: this.userId,
};
}
}

View File

@ -2,7 +2,6 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "public/js", "outDir": "public/js",
"rootDir": "./assets",
"target": "ES6", "target": "ES6",
"strict": true, "strict": true,
"lib": [ "lib": [

View File

@ -3,7 +3,6 @@
"module": "CommonJS", "module": "CommonJS",
"esModuleInterop": true, "esModuleInterop": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "./src",
"target": "ES6", "target": "ES6",
"strict": true, "strict": true,
"lib": [ "lib": [
@ -17,6 +16,6 @@
}, },
"include": [ "include": [
"src/**/*", "src/**/*",
"node_modules/swaf/types" "node_modules/wms-core/types"
] ]
} }

View File

@ -11,7 +11,7 @@
<p>Rainbox Email is a <abbr title="Free and Open Source Software">FOSS</abbr> service that allows anyone to <p>Rainbox Email is a <abbr title="Free and Open Source Software">FOSS</abbr> service that allows anyone to
manage an <abbr title="Internet Service Provider">ISP</abbr>-like mail database.</p> manage an <abbr title="Internet Service Provider">ISP</abbr>-like mail database.</p>
<p>You can find the source code on <p>You can find the source code on
<a href="https://eternae.ink/arisu/rainbox.email" target="_blank">eternae.ink</a>.</p> <a href="https://gitlab.com/ArisuOngaku/rainbox.email" target="_blank">GitLab</a>.</p>
{{ macros.message('info', '<a href="' + instance_url + '">' + instance_url + '</a> is just one instance of ' + app.name, true, true) }} {{ macros.message('info', '<a href="' + instance_url + '">' + instance_url + '</a> is just one instance of ' + app.name, true, true) }}
</section> </section>

View File

@ -1,68 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Account' %}
{% block body %}
<main class="container">
<h1>My mailbox</h1>
<section class="panel">
<h2><i data-feather="mail"></i> {{ mailboxIdentity | default('Mailbox') }}</h2>
<p class="center">{% if mailboxIdentity == null %}(Not created yet){% else %}{{ mailboxIdentity }}{% endif %}</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for identity in identities %}
<tr>
<td>{{ identity.id }}</td>
<td>{{ identity.email }}</td>
<td class="actions">
<form action="{{ route('delete-mail-identity') }}" method="POST">
<input type="hidden" name="id" value="{{ identity.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ identity.email }}?')">
<i data-feather="trash"></i> <span class="tip">Delete</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3">No identity (yet).</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="{{ route('create-mail-identity') }}" method="POST" class="sub-panel">
<h3>{% if mailboxIdentity == null %}Create your mailbox{% else %}Create a new mail identity{% endif %}</h3>
<div class="inline-fields">
{{ macros.field(_locals, 'text', 'name', user.name, 'Email name', null, 'required') }}
<span>@</span>
{{ macros.field(_locals, 'select', 'mail_domain_id', null, 'Choose the email domain', null, 'required', domains) }}
</div>
<div class="hint">
<i data-feather="info"></i>
If using a "public" domain, can only be set to your username.
</div>
<button><i data-feather="plus"></i> Create</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</section>
</main>
{% endblock %}

159
views/account.njk Normal file
View File

@ -0,0 +1,159 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Account' %}
{% block body %}
<main class="container">
<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>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">
<h2><i data-feather="shield"></i> Recovery email addresses</h2>
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for email in emails %}
{% if email.id == user.main_email_id %}
<tr>
<td>Main</td>
<td>{{ email.email }}</td>
<td></td>
</tr>
{% endif %}
{% endfor %}
{% for email in emails %}
{% if email.id != user.main_email_id %}
<tr>
<td>Secondary</td>
<td>{{ email.email }}</td>
<td class="actions">
<form action="{{ route('set-main-recovery-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button class="warning"
onclick="return confirm('Are you sure you want to set {{ email.email }} as your main address?');">
<i data-feather="refresh-ccw"></i> <span class="tip">Set as main address</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
<form action="{{ route('remove-recovery-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ email.email }}?');">
<i data-feather="trash"></i> <span class="tip">Remove</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<form action="{{ route('add-recovery-email') }}" method="POST" class="sub-panel">
<h3>Add a recovery email address:</h3>
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email we can use to identify you in case you lose access to your account', 'required') }}
<button><i data-feather="plus"></i> Add recovery email</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</section>
<section class="panel">
<h2><i data-feather="mail"></i> Mailbox</h2>
<p class="center">{% if mailboxIdentity == null %}(Not created yet){% else %}{{ mailboxIdentity }}{% endif %}</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for identity in identities %}
<tr>
<td>{{ identity.id }}</td>
<td>{{ identity.email }}</td>
<td class="actions">
<form action="{{ route('delete-mail-identity') }}" method="POST">
<input type="hidden" name="id" value="{{ identity.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ identity.email }}?')">
<i data-feather="trash"></i> <span class="tip">Delete</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3">No identity (yet).</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="{{ route('create-mail-identity') }}" method="POST" class="sub-panel">
<h3>{% if mailboxIdentity == null %}Create your mailbox{% else %}Create a new mail identity{% endif %}</h3>
<div class="inline-fields">
{{ macros.field(_locals, 'text', 'name', user.name, 'Email name', null, 'required') }}
<span>@</span>
{{ macros.field(_locals, 'select', 'mail_domain_id', null, 'Choose the email domain', null, 'required', domains) }}
</div>
<div class="hint">
<i data-feather="info"></i>
If using a "public" domain, can only be set to your username.
</div>
<button><i data-feather="plus"></i> Create</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</section>
</main>
{% endblock %}

View File

@ -4,9 +4,9 @@
{% block body %} {% block body %}
<div class="container"> <div class="container">
{{ macros.breadcrumb(domain.name, [ {{ macros.breadcrumb('Mail domain: ' + domain.name, [
{title: 'Backend', link: route('backend')}, {title: 'Backend', link: route('backend')},
{title: 'Mail domains', link: route('backend-mail-domains')} {title: 'Mailboxes', link: route('backend-mailboxes')}
]) }} ]) }}
<div class="panel"> <div class="panel">

View File

@ -1,67 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Backend' %}
{% block body %}
<div class="container">
{{ macros.breadcrumb('Mail domains', [
{title: 'Backend', link: route('backend')}
]) }}
<h1>Domain manager</h1>
<section class="panel">
<h2><i data-feather="globe"></i> Domains</h2>
<form action="{{ route('backend-add-domain') }}" method="POST" class="sub-panel">
<h3>Add domain</h3>
{{ macros.field(_locals, 'text', 'name', null, 'Domain name', null, 'required') }}
{{ macros.field(_locals, 'select', 'user_id', undefined, 'Owner', null, 'required', users) }}
<button><i data-feather="plus"></i> Add domain</button>
{{ macros.csrf(getCsrfToken) }}
</form>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Owner</th>
<th>Identities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for domain in domains %}
<tr>
<td>{{ domain.id }}</td>
<td>{{ domain.name }}</td>
<td>{{ domain.owner_name | default('Public') }}</td>
<td>{{ domain.identity_count }}</td>
<td class="actions">
<a href="{{ route('backend-edit-domain', domain.id) }}" class="button">
<i data-feather="edit-2"></i> <span class="tip">Edit</span>
</a>
<form action="{{ route('backend-remove-domain') }}" method="POST">
<input type="hidden" name="id" value="{{ domain.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ domain.name }}?')">
<i data-feather="trash"></i> <span class="tip">Remove</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock %}

View File

@ -28,29 +28,16 @@
<td>{{ identity.id }}</td> <td>{{ identity.id }}</td>
<td>{{ identity.email }}</td> <td>{{ identity.email }}</td>
<td class="actions"> <td class="actions">
{% if mailbox.name != identity.email %} <form action="{{ route('backend-delete-mail-identity') }}" method="POST">
<form action="{{ route('backend-set-main-mail-identity') }}" method="POST"> <input type="hidden" name="id" value="{{ identity.id }}">
<input type="hidden" name="id" value="{{ identity.id }}">
<button class="" <button class="danger"
onclick="return confirm('Are you sure you want to set {{ identity.email }} as this mailbox\'s identity? This requires moving existing emails on the mail server afterwards.')"> onclick="return confirm('Are you sure you want to delete {{ identity.email }}?')">
<i data-feather="tag"></i> <span class="tip">Set as main identity</span> <i data-feather="trash"></i> <span class="tip">Delete</span>
</button> </button>
{{ macros.csrf(getCsrfToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
<form action="{{ route('backend-delete-mail-identity') }}" method="POST">
<input type="hidden" name="id" value="{{ identity.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ identity.email }}?')">
<i data-feather="trash"></i> <span class="tip">Delete</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -58,7 +45,7 @@
</table> </table>
<form action="{{ route('backend-create-mail-identity', mailbox.id) }}" method="POST" class="sub-panel"> <form action="{{ route('backend-create-mail-identity', mailbox.id) }}" method="POST" class="sub-panel">
<h3>{% if not mailbox.exists %}Create a mailbox{% else %}Create a new mail identity{% endif %}</h3> <h3>{% if mailboxIdentity == null %}Create a mailbox{% else %}Create a new mail identity{% endif %}</h3>
<div class="inline-fields"> <div class="inline-fields">
{{ macros.field(_locals, 'text', 'name', user.name, 'Email name', null, 'required') }} {{ macros.field(_locals, 'text', 'name', user.name, 'Email name', null, 'required') }}

View File

@ -10,6 +10,60 @@
<h1>Mailbox manager</h1> <h1>Mailbox manager</h1>
<section class="panel">
<h2><i data-feather="globe"></i> Domains</h2>
<form action="{{ route('backend-add-domain') }}" method="POST" class="sub-panel">
<h3>Add domain</h3>
{{ macros.field(_locals, 'text', 'name', null, 'Domain name', null, 'required') }}
{{ macros.field(_locals, 'select', 'user_id', undefined, 'Owner', null, 'required', users) }}
<button><i data-feather="plus"></i> Add domain</button>
{{ macros.csrf(getCsrfToken) }}
</form>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Owner</th>
<th>Identities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for domain in domains %}
<tr>
<td>{{ domain.id }}</td>
<td>{{ domain.name }}</td>
<td>{{ domain.owner_name | default('Public') }}</td>
<td>{{ domain.identity_count }}</td>
<td class="actions">
<a href="{{ route('backend-edit-domain', domain.id) }}" class="button">
<i data-feather="edit-2"></i> <span class="tip">Edit</span>
</a>
<form action="{{ route('backend-remove-domain') }}" method="POST">
<input type="hidden" name="id" value="{{ domain.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ domain.name }}?')">
<i data-feather="trash"></i> <span class="tip">Remove</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel"> <section class="panel">
<h2><i data-feather="mail"></i> Mailboxes</h2> <h2><i data-feather="mail"></i> Mailboxes</h2>

View File

@ -17,26 +17,24 @@
<button id="menu-button"><i data-feather="menu"></i></button> <button id="menu-button"><i data-feather="menu"></i></button>
<ul id="main-menu"> <ul id="main-menu">
<li><a href="{{ app.webmail_url }}" target="_blank"><i data-feather="mail"></i> <span class="tip">Webmail</span></a></li> <li><a href="{{ app.webmail_url }}" target="_blank"><i data-feather="mail"></i> <span class="tip">Webmail</span></a></li>
{# <li><a href="{{ route('about') }}"><i data-feather="info"></i> <span class="tip">About</span></a></li>#}
{% if user %} {% if user %}
{% if user.is_admin %} {% if user.is_admin %}
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li> <li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
{% endif %} {% endif %}
<li><a href="{{ route('account') }}"><i data-feather="user"></i><span class="tip">{{ user.name }}</span></a> <li><a href="{{ route('account') }}"><i data-feather="user"></i>
<span class="tip">{{ user.name }}</span></a></li>
<ul class="dropdown"> <li>
<li><a href="{{ route('account-mailbox') }}"><i data-feather="settings"></i> <span class="tip">Mailbox settings</span></a></li> <form action="{{ route('logout') }}" method="POST">
<li> <button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
<hr> {{ macros.csrf(getCsrfToken) }}
<form action="{{ route('logout') }}" method="POST"> </form>
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
{{ macros.csrf(getCsrfToken) }}
</form>
</li>
</ul>
</li> </li>
{% else %} {% else %}
<li><a href="{{ route('auth') }}"><i data-feather="log-in"></i> <span class="tip">Login / Register</span></a></li> <li><a href="{{ route('auth') }}"><i data-feather="log-in"></i> <span class="tip">Login</span></a></li>
<li><a href="{{ route('register') }}"><i data-feather="user-plus"></i>
<span class="tip">Register</span></a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>

View File

@ -0,0 +1,27 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
Add this email as recovery for {{ app.name }}
</mj-text>
<mj-text>
Someone wants to add <strong>{{ mail_to }}</strong> as a recovery email to their account.
<br><br>
<strong>Do not click on this if this is not you!</strong>
</mj-text>
<mj-button href="{{ link | safe }}">
Add as recovery email
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to add {{ mail_to }} as a recovery email to their account.
To add it as a recovery email, please follow this link: {{ link|safe }}
{% endblock %}

7001
yarn.lock

File diff suppressed because it is too large Load Diff