Merge branch 'develop'
This commit is contained in:
commit
0416dbb86b
16
app.service
Normal file
16
app.service
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=WMS website
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=wms
|
||||||
|
Group=wms
|
||||||
|
WorkingDirectory=/home/wms/live
|
||||||
|
Restart=on-success
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/bin/node .
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
@ -7,65 +7,55 @@
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
width="24"
|
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
version="1.1"
|
|
||||||
id="svg6"
|
|
||||||
sodipodi:docname="logo.svg"
|
sodipodi:docname="logo.svg"
|
||||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
id="svg4"
|
||||||
inkscape:export-filename="/r/arisu/dev/streams/wms/assets/img/logox128.png"
|
version="1.1"
|
||||||
inkscape:export-xdpi="512"
|
class="feather feather-key"
|
||||||
inkscape:export-ydpi="512">
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
height="24"
|
||||||
|
width="24">
|
||||||
<metadata
|
<metadata
|
||||||
id="metadata12">
|
id="metadata10">
|
||||||
<rdf:RDF>
|
<rdf:RDF>
|
||||||
<cc:Work
|
<cc:Work
|
||||||
rdf:about="">
|
rdf:about="">
|
||||||
<dc:format>image/svg+xml</dc:format>
|
<dc:format>image/svg+xml</dc:format>
|
||||||
<dc:type
|
<dc:type
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
<dc:title />
|
<dc:title></dc:title>
|
||||||
</cc:Work>
|
</cc:Work>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</metadata>
|
</metadata>
|
||||||
<defs
|
<defs
|
||||||
id="defs10" />
|
id="defs8" />
|
||||||
<sodipodi:namedview
|
<sodipodi:namedview
|
||||||
pagecolor="#ffffff"
|
inkscape:current-layer="svg4"
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1"
|
|
||||||
objecttolerance="10"
|
|
||||||
gridtolerance="10"
|
|
||||||
guidetolerance="10"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:window-width="2560"
|
|
||||||
inkscape:window-height="1381"
|
|
||||||
id="namedview8"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="45.254834"
|
|
||||||
inkscape:cx="9.0192696"
|
|
||||||
inkscape:cy="11.978294"
|
|
||||||
inkscape:window-x="1920"
|
|
||||||
inkscape:window-y="32"
|
|
||||||
inkscape:window-maximized="1"
|
inkscape:window-maximized="1"
|
||||||
inkscape:current-layer="svg6">
|
inkscape:window-y="32"
|
||||||
<inkscape:grid
|
inkscape:window-x="1920"
|
||||||
spacingx="0.5"
|
inkscape:cy="12.237144"
|
||||||
spacingy="0.5"
|
inkscape:cx="15.6616"
|
||||||
type="xygrid"
|
inkscape:zoom="25.573695"
|
||||||
id="grid4542" />
|
showgrid="false"
|
||||||
</sodipodi:namedview>
|
id="namedview6"
|
||||||
|
inkscape:window-height="1381"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
guidetolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
objecttolerance="10"
|
||||||
|
borderopacity="1"
|
||||||
|
bordercolor="#666666"
|
||||||
|
pagecolor="#ffffff" />
|
||||||
<path
|
<path
|
||||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#00766c;fill-opacity:1;fill-rule:nonzero;stroke:#00766c;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
|
style="fill:none;stroke:#004698;stroke-width:1.70404;stroke-opacity:1"
|
||||||
d="m 4.6020263,4.6020261 c -4.08077722,4.0820101 -4.08077722,10.7139369 0,14.7959479 m 0,0 0.6656889,-0.665689 c -3.7210992,-3.722224 -3.7210992,-9.7423466 0,-13.4645699 L 4.6020263,4.6020261 m 14.7959477,0 -0.665689,0.665689 c 3.721099,3.7222233 3.721099,9.7423459 0,13.4645699 l 0.665689,0.665689 m 0,0 c 4.080777,-4.082011 4.080777,-10.7139378 0,-14.7959479 M 8.0959735,15.904026 c -2.1644289,-2.162022 -2.1644289,-5.655225 0,-7.8172472 m 0,0 L 7.4302847,7.419251 c -2.5246945,2.5218873 -2.5246945,6.628577 0,9.150465 l 0.6656888,-0.66569 M 15.904026,8.0959736 c 2.164429,2.1620214 2.164429,5.6552244 0,7.8172474 m 0,0 0.66569,0.667527 c 2.524693,-2.521887 2.524693,-6.6285761 0,-9.1504633 l -0.66569,0.6656889"
|
id="path2"
|
||||||
id="path4"
|
d="M 19.678517,3.8520054 17.974481,5.5560416 M 11.490623,12.0399 a 4.6860997,4.6860997 0 1 1 -6.6269966,6.626997 4.6860997,4.6860997 0 0 1 6.6261446,-6.626145 z m 0,0 3.501794,-3.5017949 m 0,0 2.556054,2.5560539 2.982065,-2.982063 -2.556055,-2.5560544 m -2.982064,2.9820635 2.982064,-2.9820635" />
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
sodipodi:nodetypes="cccccccccccccccccccccccc" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 27 KiB |
Binary file not shown.
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.9 KiB |
@ -1,6 +1,8 @@
|
|||||||
import './external_links';
|
import './external_links';
|
||||||
import './message_icons';
|
import './message_icons';
|
||||||
import './forms';
|
import './forms';
|
||||||
|
import './copyable_text';
|
||||||
|
import './main_menu';
|
||||||
|
|
||||||
import '../sass/app.scss';
|
import '../sass/app.scss';
|
||||||
|
|
||||||
|
12
assets/js/copyable_text.js
Normal file
12
assets/js/copyable_text.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.copyable-text').forEach(el => {
|
||||||
|
const contentEl = el.querySelector('.content');
|
||||||
|
contentEl.addEventListener('click', () => {
|
||||||
|
window.getSelection().selectAllChildren(contentEl);
|
||||||
|
});
|
||||||
|
el.querySelector('.copy-button').addEventListener('click', () => {
|
||||||
|
window.getSelection().selectAllChildren(contentEl);
|
||||||
|
document.execCommand('copy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -9,3 +9,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.applyFormMessages = function (formElement, messages) {
|
||||||
|
for (const fieldName of Object.keys(messages)) {
|
||||||
|
const field = formElement.querySelector('#field-' + fieldName);
|
||||||
|
let parent = field.parentElement;
|
||||||
|
while (parent && !parent.classList.contains('form-field')) parent = parent.parentElement;
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
let err = field.querySelector('.error');
|
||||||
|
if (!err) {
|
||||||
|
err = document.createElement('div');
|
||||||
|
err.classList.add('error');
|
||||||
|
parent.insertBefore(err, parent.querySelector('.hint') || parent);
|
||||||
|
}
|
||||||
|
err.innerHTML = `<i data-feather="x-circle"></i> ${messages[fieldName].message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
assets/js/main_menu.js
Normal file
17
assets/js/main_menu.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const menuButton = document.getElementById('menu-button');
|
||||||
|
const mainMenu = document.getElementById('main-menu');
|
||||||
|
|
||||||
|
menuButton.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
mainMenu.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
mainMenu.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
mainMenu.classList.remove('open');
|
||||||
|
});
|
||||||
|
});
|
@ -1,23 +1,30 @@
|
|||||||
$primary: #1e2932;
|
$primary: darken(#333333, 2%);
|
||||||
$primaryForeground: #f0f0f0;
|
$primaryForeground: #f0f0f0;
|
||||||
$secondary: #00766c;
|
$secondary: lighten(#004698, 10%);
|
||||||
$secondaryForeground: $primaryForeground;
|
$secondaryForeground: $primaryForeground;
|
||||||
|
|
||||||
$backgroundColor: $primary;
|
$backgroundColor: darken($primary, 4%);
|
||||||
$defaultTextColor: $primaryForeground;
|
$defaultTextColor: #ffffff;
|
||||||
|
|
||||||
$info: #49fb;
|
$headerBackground: darken($primary, 7.5%);
|
||||||
|
$footerBackground: lighten($headerBackground, 1%);
|
||||||
|
$panelBackground: lighten($headerBackground, 1%);
|
||||||
|
|
||||||
|
$info: #4499ff;
|
||||||
$infoText: darken($info, 42%);
|
$infoText: darken($info, 42%);
|
||||||
$infoColor: desaturate($infoText, 50%);
|
$infoColor: desaturate($infoText, 50%);
|
||||||
|
|
||||||
$success: #5f5b;
|
$success: #55ff55;
|
||||||
$successText: darken($success, 45%);
|
$successText: darken($success, 45%);
|
||||||
$successColor: desaturate($successText, 50%);
|
$successColor: desaturate($successText, 50%);
|
||||||
|
|
||||||
$warning: #fc0b;
|
$warning: #ffcc00;
|
||||||
$warningText: darken($warning, 30%);
|
$warningText: darken($warning, 30%);
|
||||||
$warningColor: desaturate($warningText, 50%);
|
$warningColor: desaturate($warningText, 50%);
|
||||||
|
|
||||||
$error: #f00b;
|
$error: #ff0000;
|
||||||
$errorText: darken($error, 30%);
|
$errorText: darken($error, 30%);
|
||||||
$errorColor: desaturate($errorText, 50%);
|
$errorColor: desaturate($errorText, 50%);
|
||||||
|
|
||||||
|
// Responsivity
|
||||||
|
$menuLayoutSwitchTreshold: 700px;
|
||||||
|
@ -1 +1,7 @@
|
|||||||
@import "layout";
|
@import "layout";
|
||||||
|
|
||||||
|
td.actions {
|
||||||
|
form {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -30,7 +30,6 @@ header {
|
|||||||
height: $headerHeight;
|
height: $headerHeight;
|
||||||
line-height: $headerHeight;
|
line-height: $headerHeight;
|
||||||
|
|
||||||
$headerBackground: darken($primary, 1.75%);
|
|
||||||
background-color: $headerBackground;
|
background-color: $headerBackground;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@ -52,7 +51,8 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nav ul {
|
nav {
|
||||||
|
ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -63,21 +63,163 @@ header {
|
|||||||
li {
|
li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
a, span {
|
a, button {
|
||||||
|
position: relative;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 64px;
|
|
||||||
padding: 0 24px;
|
&:hover, &:active {
|
||||||
|
&:not(button) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity ease-out 100ms;
|
||||||
|
transition-delay: 150ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.feather {
|
.feather {
|
||||||
--icon-size: 24px;
|
--icon-size: 24px;
|
||||||
margin-right: 10px;
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.tip {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity ease-out 100ms;
|
||||||
|
transition-delay: 150ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
button {
|
||||||
background-color: #fff1;
|
margin: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
.feather {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
text-transform: initial;
|
||||||
|
font-weight: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $menuLayoutSwitchTreshold) {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
padding: 0 16px 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
#menu-button {
|
||||||
|
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 {
|
||||||
|
flex-direction: column;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform ease-out 150ms;
|
||||||
|
|
||||||
|
background-color: $headerBackground;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
a, button {
|
||||||
|
.tip {
|
||||||
|
display: block;
|
||||||
|
margin-left: 8px;
|
||||||
|
text-transform: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: $menuLayoutSwitchTreshold) {
|
||||||
|
nav ul li {
|
||||||
|
a, button {
|
||||||
|
.tip {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
line-height: 22px;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: $defaultTextColor;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity ease-out 100ms, visibility step-end 150ms;
|
||||||
|
transition-delay: 0ms;
|
||||||
|
background-color: #000;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
a, button {
|
||||||
|
.tip {
|
||||||
|
left: unset;
|
||||||
|
right: 4px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,7 +229,7 @@ footer {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background-color: darken($primary, 3%);
|
background-color: $footerBackground;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@ -119,15 +261,23 @@ section > h2, .panel > h2 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 16px;
|
margin-top: 4px;
|
||||||
|
|
||||||
&::before, &::after {
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
.feather {
|
||||||
|
margin: 0 16px 0 0;
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 32px;
|
margin: 0 16px;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-bottom: 1px solid $defaultTextColor;
|
border-bottom: 1px solid $defaultTextColor;
|
||||||
opacity: 0.2;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,7 +294,7 @@ a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: lighten($secondary, 10%);
|
color: lighten($secondary, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feather.feather-external-link {
|
.feather.feather-external-link {
|
||||||
@ -190,13 +340,12 @@ form {
|
|||||||
|
|
||||||
input, select, .input-group {
|
input, select, .input-group {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid #0008;
|
|
||||||
color: $defaultTextColor;
|
color: $defaultTextColor;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: lighten($panelBackground, 4%);
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: 5px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
||||||
&:focus, &:not([value=""]) {
|
&:focus, &:not([value=""]), &[type="file"] {
|
||||||
~ label {
|
~ label {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -208,6 +357,7 @@ form {
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 32px 8px 8px 8px;
|
padding: 32px 8px 8px 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@ -221,7 +371,7 @@ form {
|
|||||||
|
|
||||||
& + .feather {
|
& + .feather {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1;
|
pointer-events: none;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
|
||||||
@ -286,7 +436,7 @@ form {
|
|||||||
.inline-fields {
|
.inline-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
margin: 16px auto;
|
margin: 16px auto;
|
||||||
|
|
||||||
.form-field {
|
.form-field {
|
||||||
@ -349,10 +499,10 @@ button, .button {
|
|||||||
|
|
||||||
&, &.primary {
|
&, &.primary {
|
||||||
color: $primaryForeground;
|
color: $primaryForeground;
|
||||||
background-color: $secondary;
|
background-color: darken($secondary, 10%);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: lighten($secondary, 10%);
|
background-color: $secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,6 +516,10 @@ button, .button {
|
|||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
background-color: $warningColor;
|
background-color: $warningColor;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($warningColor, 10%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error, &.danger {
|
&.error, &.danger {
|
||||||
@ -395,15 +549,15 @@ button, .button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
border-bottom: 1px solid #ffffff17;
|
border-bottom: 1px solid #39434a;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) {
|
tr:nth-child(even) {
|
||||||
background-color: #ffffff08;
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover {
|
tr:hover {
|
||||||
background-color: #ffffff18;
|
background-color: rgba(255, 255, 255, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
thead tr:hover {
|
thead tr:hover {
|
||||||
@ -425,26 +579,33 @@ button, .button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
margin: 16px 0;
|
position: relative;
|
||||||
|
margin: 16px 0 48px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
background-color: $panelBackground;
|
||||||
background-color: rgba(255, 255, 255, 0.03);
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 16px 8px;
|
margin: 16px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .feather:first-child {
|
||||||
|
position: absolute;
|
||||||
|
--icon-size: 24px;
|
||||||
|
opacity: 0.1;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-panel {
|
.sub-panel {
|
||||||
margin: 32px -18px;
|
margin: 32px 0;
|
||||||
padding: 1px 16px;
|
padding: 1px 16px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.05);
|
border: 2px solid lighten($panelBackground, 4%);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
input, select, .input-group {
|
form > & {
|
||||||
border-radius: 5px !important;
|
margin: 32px -18px;
|
||||||
border-width: 0 !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,7 +644,7 @@ button, .button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not(&-discreet) {
|
&:not(&-discreet) {
|
||||||
background-color: #fff5;
|
background-color: rgba(255, 255, 255, 0.33);
|
||||||
|
|
||||||
&[data-type=info], &[data-type=question] {
|
&[data-type=info], &[data-type=question] {
|
||||||
background-color: $infoColor;
|
background-color: $infoColor;
|
||||||
@ -503,7 +664,7 @@ button, .button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-discreet {
|
&-discreet {
|
||||||
opacity: 0.75;
|
color: mix($panelBackground, #fff, 35%);
|
||||||
|
|
||||||
.feather {
|
.feather {
|
||||||
--icon-size: 20px;
|
--icon-size: 20px;
|
||||||
@ -549,3 +710,67 @@ button, .button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
.feather {
|
||||||
|
--icon-size: 20px;
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
margin: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #fff1;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: var(--progress);
|
||||||
|
height: 100%;
|
||||||
|
transition: width ease-out 150ms;
|
||||||
|
|
||||||
|
background: $secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,16 +1,49 @@
|
|||||||
export default Object.assign(require("wms-core/config/default").default, {
|
export default Object.assign(require("wms-core/config/default").default, {
|
||||||
|
app: {
|
||||||
|
name: 'ALDAP',
|
||||||
|
contact_email: 'contact@toot.party',
|
||||||
|
},
|
||||||
|
log_level: "DEV",
|
||||||
|
db_log_level: "ERROR",
|
||||||
|
base_url: "http://localhost:4899",
|
||||||
|
public_websocket_url: "ws://localhost:4899",
|
||||||
|
domain: 'localhost:4899',
|
||||||
|
port: 4899,
|
||||||
mysql: {
|
mysql: {
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
user: "root",
|
user: "root",
|
||||||
password: "",
|
password: "",
|
||||||
database: "aldap",
|
database: "aldap",
|
||||||
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: 'aldap'
|
prefix: 'aldap',
|
||||||
},
|
},
|
||||||
'prelaunch-password': '$argon2i$v=19$m=4096,t=3,p=1$V7njt+IBmIQ/epc7tuQcfA$ypJCNauYSPrjOhtb5UqTbRlqCHkEGikBApOrYmbdYC0',
|
session: {
|
||||||
|
secret: "very_secret_not_known",
|
||||||
|
cookie: {
|
||||||
|
secure: false,
|
||||||
|
maxAge: 30 * 24 * 3600 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mail: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: "1025",
|
||||||
|
secure: false,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
allow_invalid_tls: true,
|
||||||
|
from: 'contact@toot.party',
|
||||||
|
from_name: 'ALDAP - toot.party',
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
cache: false,
|
||||||
|
},
|
||||||
|
magic_link: {
|
||||||
|
validity_period: 20,
|
||||||
|
},
|
||||||
|
approval_mode: false,
|
||||||
});
|
});
|
@ -1,3 +1,20 @@
|
|||||||
export default Object.assign(require("wms-core/config/production").default, {
|
export default Object.assign(require("wms-core/config/production").default, {
|
||||||
'prelaunch-password': 'CHANGE ME',
|
log_level: "DEBUG",
|
||||||
|
db_log_level: "ERROR",
|
||||||
|
base_url: "https://aldap.toot.party",
|
||||||
|
public_websocket_url: "wss://aldap.toot.party",
|
||||||
|
domain: 'aldap.toot.party',
|
||||||
|
session: {
|
||||||
|
cookie: {
|
||||||
|
secure: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mail: {
|
||||||
|
secure: true,
|
||||||
|
allow_invalid_tls: false
|
||||||
|
},
|
||||||
|
magic_link: {
|
||||||
|
validity_period: 900,
|
||||||
|
},
|
||||||
|
approval_mode: true,
|
||||||
});
|
});
|
30
package.json
30
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aldap",
|
"name": "aldap",
|
||||||
"version": "0.3.0",
|
"version": "1.0.0",
|
||||||
"description": "Authentication LDAP server",
|
"description": "Authentication LDAP server",
|
||||||
"repository": "git@gitlab.com:ArisuOngaku/aldap.git",
|
"repository": "git@gitlab.com:ArisuOngaku/aldap.git",
|
||||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||||
@ -10,7 +10,7 @@
|
|||||||
"test": "jest --verbose --runInBand",
|
"test": "jest --verbose --runInBand",
|
||||||
"dist-webpack": "webpack --mode production",
|
"dist-webpack": "webpack --mode production",
|
||||||
"dist": "tsc && npm run dist-webpack",
|
"dist": "tsc && npm run dist-webpack",
|
||||||
"dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon dist/main.js\" \"webpack --watch --mode development\" \"maildev\"",
|
"dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"",
|
||||||
"start": "yarn dist && node dist/main.js"
|
"start": "yarn dist && node dist/main.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -19,26 +19,32 @@
|
|||||||
"@types/argon2": "^0.15.0",
|
"@types/argon2": "^0.15.0",
|
||||||
"@types/config": "^0.0.36",
|
"@types/config": "^0.0.36",
|
||||||
"@types/express": "^4.17.6",
|
"@types/express": "^4.17.6",
|
||||||
"@types/jest": "^25.2.1",
|
"@types/express-session": "^1.17.0",
|
||||||
|
"@types/formidable": "^1.0.31",
|
||||||
|
"@types/jest": "^26.0.4",
|
||||||
"@types/ldapjs": "^1.0.7",
|
"@types/ldapjs": "^1.0.7",
|
||||||
"@types/node": "^13.13.2",
|
"@types/mysql": "^2.15.15",
|
||||||
|
"@types/node": "^14.0.23",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
|
"@types/nunjucks": "^3.1.3",
|
||||||
|
"@types/ws": "^7.2.6",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
"concurrently": "^5.1.0",
|
"concurrently": "^5.1.0",
|
||||||
"css-loader": "^3.5.2",
|
"css-loader": "^4.0.0",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"file-loader": "^6.0.0",
|
"file-loader": "^6.0.0",
|
||||||
"imagemin": "^7.0.1",
|
"imagemin": "^7.0.1",
|
||||||
"imagemin-gifsicle": "^7.0.0",
|
"imagemin-gifsicle": "^7.0.0",
|
||||||
"imagemin-mozjpeg": "^8.0.0",
|
"imagemin-mozjpeg": "^9.0.0",
|
||||||
"imagemin-pngquant": "^8.0.0",
|
"imagemin-pngquant": "^9.0.0",
|
||||||
"imagemin-svgo": "^7.1.0",
|
"imagemin-svgo": "^8.0.0",
|
||||||
"img-loader": "^3.0.1",
|
"img-loader": "^3.0.1",
|
||||||
"jest": "^25.4.0",
|
"jest": "^26.1.0",
|
||||||
"mini-css-extract-plugin": "^0.9.0",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"node-sass": "^4.14.0",
|
"node-sass": "^4.14.0",
|
||||||
"nodemon": "^2.0.3",
|
"nodemon": "^2.0.3",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^9.0.2",
|
||||||
"ts-jest": "^25.4.0",
|
"ts-jest": "^26.1.1",
|
||||||
"typescript": "^3.8.3",
|
"typescript": "^3.8.3",
|
||||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
"uglifyjs-webpack-plugin": "^2.2.0",
|
||||||
"webpack": "^4.43.0",
|
"webpack": "^4.43.0",
|
||||||
@ -49,6 +55,6 @@
|
|||||||
"argon2": "^0.26.2",
|
"argon2": "^0.26.2",
|
||||||
"config": "^3.3.1",
|
"config": "^3.3.1",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"ldapjs": "^1.0.2"
|
"ldapjs": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,6 @@ import ServeStaticDirectoryComponent from "wms-core/components/ServeStaticDirect
|
|||||||
import MaintenanceComponent from "wms-core/components/MaintenanceComponent";
|
import MaintenanceComponent from "wms-core/components/MaintenanceComponent";
|
||||||
import MailComponent from "wms-core/components/MailComponent";
|
import MailComponent from "wms-core/components/MailComponent";
|
||||||
import SessionComponent from "wms-core/components/SessionComponent";
|
import SessionComponent from "wms-core/components/SessionComponent";
|
||||||
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
|
|
||||||
import FormHelperComponent from "wms-core/components/FormHelperComponent";
|
import FormHelperComponent from "wms-core/components/FormHelperComponent";
|
||||||
import CsrfProtectionComponent from "wms-core/components/CsrfProtectionComponent";
|
import CsrfProtectionComponent from "wms-core/components/CsrfProtectionComponent";
|
||||||
import WebSocketServerComponent from "wms-core/components/WebSocketServerComponent";
|
import WebSocketServerComponent from "wms-core/components/WebSocketServerComponent";
|
||||||
@ -18,13 +17,31 @@ import HomeController from "./controllers/HomeController";
|
|||||||
import AuthController from "./controllers/AuthController";
|
import AuthController from "./controllers/AuthController";
|
||||||
import AuthComponent from "wms-core/auth/AuthComponent";
|
import AuthComponent from "wms-core/auth/AuthComponent";
|
||||||
import AuthGuard from "wms-core/auth/AuthGuard";
|
import AuthGuard from "wms-core/auth/AuthGuard";
|
||||||
import {PasswordAuthProof} from "./models/UserPassword";
|
import {PasswordAuthProof} from "./models/UserPasswordComponent";
|
||||||
import {MIGRATIONS} from "./migrations";
|
|
||||||
import LDAPServerComponent from "./LDAPServerComponent";
|
import LDAPServerComponent from "./LDAPServerComponent";
|
||||||
import PreLaunchWall from "./controllers/PreLaunchWall";
|
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
|
||||||
|
import AccountController from "./controllers/AccountController";
|
||||||
|
import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable";
|
||||||
|
import CreateLogsTable from "wms-core/migrations/CreateLogsTable";
|
||||||
|
import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable";
|
||||||
|
import AddPasswordToUsers from "./migrations/AddPasswordToUsers";
|
||||||
|
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/MailboxBackendController";
|
||||||
|
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
|
||||||
|
|
||||||
export default class Aldap extends Application {
|
export default class App extends Application {
|
||||||
private readonly port: number;
|
private readonly port: number;
|
||||||
|
private magicLinkWebSocketListener?: MagicLinkWebSocketListener;
|
||||||
|
|
||||||
constructor(port: number) {
|
constructor(port: number) {
|
||||||
super(require('../package.json').version);
|
super(require('../package.json').version);
|
||||||
@ -32,7 +49,18 @@ export default class Aldap extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getMigrations(): Type<Migration>[] {
|
protected getMigrations(): Type<Migration>[] {
|
||||||
return MIGRATIONS;
|
return [
|
||||||
|
CreateMigrationsTable,
|
||||||
|
CreateLogsTable,
|
||||||
|
CreateUsersAndUserEmailsTable,
|
||||||
|
AddPasswordToUsers,
|
||||||
|
CreateMagicLinksTable,
|
||||||
|
AddApprovedFieldToUsersTable,
|
||||||
|
FixUserMainEmailRelation,
|
||||||
|
DropNameFromUsers,
|
||||||
|
AddNameToUsers,
|
||||||
|
CreateMailTables,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async init(): Promise<void> {
|
protected async init(): Promise<void> {
|
||||||
@ -42,22 +70,27 @@ export default class Aldap extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerComponents() {
|
private registerComponents() {
|
||||||
|
const expressAppComponent = new ExpressAppComponent(this.port);
|
||||||
const redisComponent = new RedisComponent();
|
const redisComponent = new RedisComponent();
|
||||||
const mysqlComponent = new MysqlComponent();
|
const mysqlComponent = new MysqlComponent();
|
||||||
|
|
||||||
const expressAppComponent = new ExpressAppComponent(this.port);
|
// Base
|
||||||
this.use(expressAppComponent);
|
this.use(expressAppComponent);
|
||||||
this.use(new NunjucksComponent());
|
|
||||||
this.use(new LogRequestsComponent());
|
this.use(new LogRequestsComponent());
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
this.use(new ServeStaticDirectoryComponent('public'));
|
this.use(new ServeStaticDirectoryComponent('public'));
|
||||||
this.use(new ServeStaticDirectoryComponent('node_modules/feather-icons/dist', '/icons'));
|
this.use(new ServeStaticDirectoryComponent('node_modules/feather-icons/dist', '/icons'));
|
||||||
|
|
||||||
|
// Dynamic views and routes
|
||||||
|
this.use(new NunjucksComponent());
|
||||||
|
this.use(new RedirectBackComponent());
|
||||||
|
|
||||||
// Maintenance
|
// Maintenance
|
||||||
this.use(new MaintenanceComponent(this, () => {
|
this.use(new MaintenanceComponent(this, () => {
|
||||||
return redisComponent.canServe() && mysqlComponent.canServe();
|
return redisComponent.canServe() && mysqlComponent.canServe();
|
||||||
}));
|
}));
|
||||||
|
this.use(new AutoUpdateComponent());
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
this.use(mysqlComponent);
|
this.use(mysqlComponent);
|
||||||
@ -68,16 +101,16 @@ export default class Aldap extends Application {
|
|||||||
this.use(new SessionComponent(redisComponent));
|
this.use(new SessionComponent(redisComponent));
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
this.use(new RedirectBackComponent());
|
|
||||||
this.use(new FormHelperComponent());
|
this.use(new FormHelperComponent());
|
||||||
|
|
||||||
// Middlewares
|
// Middlewares
|
||||||
this.use(new CsrfProtectionComponent());
|
this.use(new CsrfProtectionComponent());
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
this.use(new AuthComponent(new class extends AuthGuard<PasswordAuthProof> {
|
this.use(new AuthComponent(new class extends AuthGuard<PasswordAuthProof | MagicLink> {
|
||||||
public async getProofForSession(session: Express.Session): Promise<any | null> {
|
public async getProofForSession(session: Express.Session): Promise<PasswordAuthProof | MagicLink | null> {
|
||||||
return PasswordAuthProof.getProofForSession(session);
|
return PasswordAuthProof.getProofForSession(session) ||
|
||||||
|
MagicLink.bySessionID(session.id);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -89,11 +122,24 @@ export default class Aldap extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerWebSocketListeners() {
|
private registerWebSocketListeners() {
|
||||||
|
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
|
||||||
|
this.use(this.magicLinkWebSocketListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerControllers() {
|
private registerControllers() {
|
||||||
this.use(new PreLaunchWall());
|
// Priority routes / interrupting middlewares
|
||||||
this.use(new HomeController());
|
this.use(new AccountController());
|
||||||
|
this.use(new MagicLinkController(this.magicLinkWebSocketListener!))
|
||||||
|
this.use(new BackendController());
|
||||||
|
this.use(new MailboxBackendController());
|
||||||
this.use(new AuthController());
|
this.use(new AuthController());
|
||||||
|
|
||||||
|
// Core functionality
|
||||||
|
this.use(new MailController());
|
||||||
|
|
||||||
|
// Other functionnality
|
||||||
|
|
||||||
|
// Semi-static routes
|
||||||
|
this.use(new HomeController());
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,27 +1,18 @@
|
|||||||
import ApplicationComponent from "wms-core/ApplicationComponent";
|
import ApplicationComponent from "wms-core/ApplicationComponent";
|
||||||
import {Express, Router} from "express";
|
import {Express} from "express";
|
||||||
import ldap, {InsufficientAccessRightsError, InvalidCredentialsError, Server} from "ldapjs";
|
import ldap, {InvalidCredentialsError, Server} from "ldapjs";
|
||||||
import Logger from "wms-core/Logger";
|
import Logger from "wms-core/Logger";
|
||||||
import Username from "./models/Username";
|
import UserPasswordComponent from "./models/UserPasswordComponent";
|
||||||
import UserEmail from "wms-core/auth/models/UserEmail";
|
|
||||||
import {PasswordAuthProof} from "./models/UserPassword";
|
|
||||||
import Throttler from "wms-core/Throttler";
|
import Throttler from "wms-core/Throttler";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
|
||||||
export default class LDAPServerComponent extends ApplicationComponent<void> {
|
export default class LDAPServerComponent extends ApplicationComponent<void> {
|
||||||
private server?: Server;
|
private server?: Server;
|
||||||
|
|
||||||
public async start(app: Express, router: Router): Promise<void> {
|
public async start(app: Express): Promise<void> {
|
||||||
this.server = ldap.createServer({
|
this.server = ldap.createServer({
|
||||||
log: console
|
log: console
|
||||||
});
|
});
|
||||||
let authorize = (req: any, res: any, next: any) => {
|
|
||||||
Logger.debug(req);
|
|
||||||
|
|
||||||
if (!req.connection.ldap.bindDN.equals('cn=root'))
|
|
||||||
return next(new InsufficientAccessRightsError());
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
this.server.bind('ou=users,dc=toot,dc=party', async (req: any, res: any, next: any) => {
|
this.server.bind('ou=users,dc=toot,dc=party', async (req: any, res: any, next: any) => {
|
||||||
const rdns = req.dn.toString().split(', ').map((rdn: string) => rdn.split('='));
|
const rdns = req.dn.toString().split(', ').map((rdn: string) => rdn.split('='));
|
||||||
let username;
|
let username;
|
||||||
@ -44,12 +35,11 @@ export default class LDAPServerComponent extends ApplicationComponent<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await Username.getUserFromUsername(username);
|
const user = await User.select().where('name', username).first();
|
||||||
if (user) {
|
if (user) {
|
||||||
const email = await UserEmail.getMainFromUser(user.id!);
|
const email = await user.mainEmail.get();
|
||||||
if (email) {
|
if (email) {
|
||||||
const authProof = new PasswordAuthProof(email.email!);
|
if (await user.as(UserPasswordComponent).verifyPassword(req.credentials)) {
|
||||||
if (await authProof.authorize(req.credentials)) {
|
|
||||||
Logger.debug('Success');
|
Logger.debug('Success');
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
@ -68,7 +58,4 @@ export default class LDAPServerComponent extends ApplicationComponent<void> {
|
|||||||
Logger.info(`LDAP server listening on ${this.server!.url}`);
|
Logger.info(`LDAP server listening on ${this.server!.url}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
}
|
|
||||||
}
|
}
|
6
src/Mails.ts
Normal file
6
src/Mails.ts
Normal 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.',
|
||||||
|
);
|
179
src/controllers/AccountController.ts
Normal file
179
src/controllers/AccountController.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import Controller from "wms-core/Controller";
|
||||||
|
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
||||||
|
import {NextFunction, Request, Response} from "express";
|
||||||
|
import {ADD_RECOVERY_EMAIL_MAIL_TEMPLATE} from "../Mails";
|
||||||
|
import Validator, {InvalidFormatValidationError, ValidationBag} from "wms-core/db/Validator";
|
||||||
|
import {EMAIL_REGEX} from "wms-core/db/Model";
|
||||||
|
import MagicLinkController from "./MagicLinkController";
|
||||||
|
import {MagicLinkActionType} from "./MagicLinkActionType";
|
||||||
|
import UserEmail from "wms-core/auth/models/UserEmail";
|
||||||
|
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} 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";
|
||||||
|
|
||||||
|
export default class AccountController extends Controller {
|
||||||
|
public getRoutesPrefix(): string {
|
||||||
|
return '/account';
|
||||||
|
}
|
||||||
|
|
||||||
|
routes(): void {
|
||||||
|
this.get('/', this.getAccount, 'account', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
|
this.post('/add-recovery-email', this.addRecoveryEmail, 'add-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
|
this.post('/set-main-email', this.postSetMainRecoveryEmail, 'set-main-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
|
this.post('/remove-email', this.postRemoveRecoveryEmail, 'remove-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
|
|
||||||
|
this.post('/create-mail-identity', this.postCreateMailIdentity, 'create-mail-identity', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
|
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'delete-mail-identity', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
const user = req.models.user!;
|
||||||
|
const userMailIdentity = user.as(UserMailIdentityComponent);
|
||||||
|
|
||||||
|
res.render('account', {
|
||||||
|
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 addRecoveryEmail(req: Request, res: Response, next: NextFunction): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MagicLinkController.sendMagicLink(
|
||||||
|
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 userEmail = await UserEmail.getById(req.body.id);
|
||||||
|
if (!userEmail)
|
||||||
|
throw new NotFoundHttpError('email', req.url);
|
||||||
|
if (userEmail.user_id !== req.models.user!.id)
|
||||||
|
throw new ForbiddenHttpError('email', req.url);
|
||||||
|
if (userEmail.id === req.models.user!.main_email_id)
|
||||||
|
throw new BadRequestError('This address is already your main address', 'Try refreshing the account page.', req.url);
|
||||||
|
|
||||||
|
req.models.user!.main_email_id = userEmail.id;
|
||||||
|
await req.models.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 userEmail = await UserEmail.getById(req.body.id);
|
||||||
|
if (!userEmail)
|
||||||
|
throw new NotFoundHttpError('email', req.url);
|
||||||
|
if (userEmail.user_id !== req.models.user!.id)
|
||||||
|
throw new ForbiddenHttpError('email', req.url);
|
||||||
|
if (userEmail.id === req.models.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.models.user!;
|
||||||
|
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);
|
||||||
|
if ((await mailIdentityComponent.getPublicAddressesCount()) >= mailIdentityComponent.getMaxPublicAddressesCount()) {
|
||||||
|
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 identity = await MailIdentity.getById(req.body.id);
|
||||||
|
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
|
||||||
|
if (identity.user_id !== req.models.user!.id) throw new ForbiddenHttpError('mail identity', req.url);
|
||||||
|
if (req.models.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();
|
||||||
|
}
|
||||||
|
}
|
@ -1,103 +1,121 @@
|
|||||||
import Controller from "wms-core/Controller";
|
import Controller from "wms-core/Controller";
|
||||||
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_GUEST_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_GUEST_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
||||||
import {Request, Response} from "express";
|
import {NextFunction, Request, Response} from "express";
|
||||||
import Validator from "wms-core/db/Validator";
|
import Validator, {InvalidFormatValidationError, ValidationBag} from "wms-core/db/Validator";
|
||||||
import {EMAIL_REGEX} from "wms-core/db/Model";
|
import UserPasswordComponent, {PasswordAuthProof} from "../models/UserPasswordComponent";
|
||||||
import {PasswordAuthProof} from "../models/UserPassword";
|
import UserNameComponent, {USERNAME_REGEXP} from "../models/UserNameComponent";
|
||||||
import UserEmail from "wms-core/auth/models/UserEmail";
|
import _AuthController from "wms-core/auth/AuthController";
|
||||||
import Username, {USERNAME_REGEXP} from "../models/Username";
|
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 Controller {
|
export default class AuthController extends _AuthController {
|
||||||
routes(): void {
|
routes(): void {
|
||||||
this.get('/login', this.getLogin, 'login', REQUIRE_GUEST_MIDDLEWARE);
|
this.get('/login', this.getLogin, 'auth', REQUIRE_GUEST_MIDDLEWARE);
|
||||||
this.post('/login', this.postLogin, 'login', REQUIRE_GUEST_MIDDLEWARE);
|
this.post('/login', this.postLogin, 'auth', REQUIRE_GUEST_MIDDLEWARE);
|
||||||
this.get('/register', this.getRegister, 'register', REQUIRE_GUEST_MIDDLEWARE);
|
this.get('/register', this.getRegister, 'register', REQUIRE_GUEST_MIDDLEWARE);
|
||||||
this.post('/register', this.postRegister, 'register', REQUIRE_GUEST_MIDDLEWARE);
|
this.post('/register', this.postRegister, 'register', REQUIRE_GUEST_MIDDLEWARE);
|
||||||
this.get('/logout', this.getLogout, 'logout', REQUIRE_AUTH_MIDDLEWARE);
|
this.post('/logout', this.postLogout, 'logout', REQUIRE_AUTH_MIDDLEWARE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLogin(req: Request, res: Response): Promise<void> {
|
protected async getLogin(req: Request, res: Response): Promise<void> {
|
||||||
res.render('login');
|
res.render('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postLogin(req: Request, res: Response): Promise<void> {
|
protected async postLogin(req: Request, res: Response): Promise<void> {
|
||||||
await this.validate({
|
await this.validate({
|
||||||
email: new Validator().defined().regexp(EMAIL_REGEX),
|
username: new Validator().defined().exists(User, 'name'),
|
||||||
password: new Validator().acceptUndefined(),
|
password: new Validator().acceptUndefined(),
|
||||||
}, req.body);
|
}, req.body);
|
||||||
|
|
||||||
const passwordAuthProof = new PasswordAuthProof(req.body.email);
|
const user = await User.select()
|
||||||
const user = await passwordAuthProof.getUser();
|
.where('name', req.body.username)
|
||||||
if (!user) {
|
.first();
|
||||||
req.flash('error', 'Unknown email address');
|
|
||||||
res.redirect(Controller.route('login'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await passwordAuthProof.authorize(req.body.password, req.session);
|
if (!user) throw new NotFoundHttpError(`Couldn't find a user with name ${req.body.username}`, req.url);
|
||||||
|
|
||||||
|
const passwordAuthProof = PasswordAuthProof.createProofForLogin(req.session!);
|
||||||
|
passwordAuthProof.setResource(user);
|
||||||
|
|
||||||
|
await passwordAuthProof.authorize(req.body.password);
|
||||||
|
try {
|
||||||
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof);
|
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
Throttler.throttle('login_failed_attempts_user', 3, 180000, user.as(UserNameComponent).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}.`);
|
req.flash('success', `Welcome, ${user.name}.`);
|
||||||
res.redirect(Controller.route('home'));
|
res.redirect(Controller.route('home'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getRegister(req: Request, res: Response): Promise<void> {
|
protected async getRegister(req: Request, res: Response): Promise<void> {
|
||||||
res.render('register');
|
res.render('register');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postRegister(req: Request, res: Response): Promise<void> {
|
protected async postRegister(req: Request, res: Response): Promise<void> {
|
||||||
const validationMap: any = {
|
Throttler.throttle('register_password', 10, 30000, req.ip);
|
||||||
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(Username),
|
|
||||||
|
await this.validate({
|
||||||
|
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(User, 'name'),
|
||||||
password: new Validator().defined().minLength(8),
|
password: new Validator().defined().minLength(8),
|
||||||
password_confirmation: new Validator().defined().sameAs('password', req.body.password),
|
password_confirmation: new Validator().defined().sameAs('password', req.body.password),
|
||||||
terms: new Validator().defined(),
|
terms: new Validator().defined(),
|
||||||
};
|
}, req.body);
|
||||||
|
|
||||||
let email: string;
|
const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.session!);
|
||||||
if (req.body.create_email) {
|
try {
|
||||||
validationMap['domain'] = new Validator().defined().regexp(/^(toot\.party)$/);
|
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof, undefined, async (connection, user) => {
|
||||||
validationMap['recovery_email'] = new Validator().acceptUndefined(true).regexp(EMAIL_REGEX).unique(UserEmail, 'email');
|
passwordAuthProof.setResource(user);
|
||||||
email = req.body.email = req.body.username + '@' + req.body.domain;
|
|
||||||
validationMap['email'] = new Validator().defined().regexp(EMAIL_REGEX).unique(UserEmail, 'email');
|
|
||||||
} else {
|
|
||||||
validationMap['recovery_email'] = new Validator().defined().regexp(EMAIL_REGEX).unique(UserEmail, 'email');
|
|
||||||
email = req.body.recovery_email;
|
|
||||||
}
|
|
||||||
await this.validate(validationMap, req.body);
|
|
||||||
|
|
||||||
const passwordAuthProof = new PasswordAuthProof(email, false);
|
const callbacks: RegisterCallback[] = [];
|
||||||
const userPassword = await passwordAuthProof.register(req.body.password);
|
|
||||||
await passwordAuthProof.authorize(req.body.password_confirmation, req.session);
|
|
||||||
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof, async (connection, userID) => {
|
|
||||||
const callbacks: (() => Promise<void>)[] = [];
|
|
||||||
|
|
||||||
// Password
|
// Password
|
||||||
await userPassword.setUser(userID);
|
await user.as(UserPasswordComponent).setPassword(req.body.password);
|
||||||
await userPassword.save(connection, c => callbacks.push(c));
|
|
||||||
|
|
||||||
// Username
|
// Username
|
||||||
await new Username({user_id: userID, username: req.body.username}).save(connection, c => callbacks.push(c));
|
user.as(UserNameComponent).name = req.body.username;
|
||||||
|
|
||||||
// Email
|
|
||||||
if (req.body.create_email && req.body.recovery_email) {
|
|
||||||
await new UserEmail({
|
|
||||||
user_id: userID,
|
|
||||||
email: req.body.recovery_email,
|
|
||||||
main: false,
|
|
||||||
}).save(connection, c => callbacks.push(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
return callbacks;
|
return callbacks;
|
||||||
});
|
});
|
||||||
|
} 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.getUser())!;
|
const user = (await passwordAuthProof.getResource())!;
|
||||||
|
|
||||||
req.flash('success', `Your account was successfully created! Welcome, ${user.name}.`);
|
req.flash('success', `Your account was successfully created! Welcome, ${user.as(UserNameComponent).name}.`);
|
||||||
res.redirect(Controller.route('home'));
|
res.redirect(Controller.route('home'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLogout(req: Request, res: Response): Promise<void> {
|
protected async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
await req.authGuard.logout(req.session!);
|
throw new ServerError('Not implemented.');
|
||||||
res.redirect(Controller.route('home'));
|
}
|
||||||
|
|
||||||
|
protected async postAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
throw new ServerError('Not implemented.');
|
||||||
}
|
}
|
||||||
}
|
}
|
3
src/controllers/MagicLinkActionType.ts
Normal file
3
src/controllers/MagicLinkActionType.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export enum MagicLinkActionType {
|
||||||
|
ADD_RECOVERY_EMAIL = 'Add a recovery email',
|
||||||
|
}
|
51
src/controllers/MagicLinkController.ts
Normal file
51
src/controllers/MagicLinkController.ts
Normal 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";
|
||||||
|
|
||||||
|
export default class MagicLinkController extends _MagicLinkController {
|
||||||
|
constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener) {
|
||||||
|
super(magicLinkWebSocketListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
|
||||||
|
switch (magicLink.getActionType()) {
|
||||||
|
case MagicLinkActionType.ADD_RECOVERY_EMAIL:
|
||||||
|
if (magicLink.getSessionID() !== req.sessionID!) throw new BadOwnerMagicLink();
|
||||||
|
await magicLink.delete();
|
||||||
|
|
||||||
|
const proof = await req.authGuard.isAuthenticated(req.session!);
|
||||||
|
const user = await proof?.getResource();
|
||||||
|
if (!user) break;
|
||||||
|
|
||||||
|
const email = await magicLink.getEmail();
|
||||||
|
|
||||||
|
// Existing 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'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
176
src/controllers/MailboxBackendController.ts
Normal file
176
src/controllers/MailboxBackendController.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import Controller from "wms-core/Controller";
|
||||||
|
import {REQUIRE_ADMIN_MIDDLEWARE, REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
|
||||||
|
import {Request, Response} from "express";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
import {WhereTest} from "wms-core/db/ModelQuery";
|
||||||
|
import UserNameComponent from "../models/UserNameComponent";
|
||||||
|
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
|
||||||
|
import {NotFoundHttpError} from "wms-core/HttpError";
|
||||||
|
import MailDomain from "../models/MailDomain";
|
||||||
|
import BackendController from "wms-core/helpers/BackendController";
|
||||||
|
import MailIdentity from "../models/MailIdentity";
|
||||||
|
|
||||||
|
export default class MailboxBackendController extends Controller {
|
||||||
|
public constructor() {
|
||||||
|
super();
|
||||||
|
BackendController.registerMenuElement({
|
||||||
|
getLink: async () => Controller.route('backend-mailboxes'),
|
||||||
|
getDisplayString: async () => 'Mailboxes',
|
||||||
|
getDisplayIcon: async () => 'mail',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoutesPrefix(): string {
|
||||||
|
return '/backend/mailboxes';
|
||||||
|
}
|
||||||
|
|
||||||
|
public routes(): void {
|
||||||
|
this.get('/', this.getMailboxesBackend, 'backend-mailboxes', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
|
||||||
|
this.get('/:id', this.getMailboxBackend, 'backend-mailbox', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
|
||||||
|
|
||||||
|
this.post('/add-domain', this.postAddDomain, 'backend-add-domain', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE)
|
||||||
|
this.post('/remove-domain', this.postRemoveDomain, 'backend-remove-domain', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE)
|
||||||
|
|
||||||
|
this.post('/:id/create-mail-identity', this.postCreateMailIdentity, 'backend-create-mail-identity', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
|
||||||
|
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'backend-delete-mail-identity', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getMailboxesBackend(req: Request, res: Response): Promise<void> {
|
||||||
|
const mailDomains = await MailDomain.select()
|
||||||
|
.with('owner')
|
||||||
|
.with('identities')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const users = await User.select()
|
||||||
|
.where('main_mail_identity_id', null, WhereTest.NE)
|
||||||
|
.with('mainMailIdentity')
|
||||||
|
.with('mailIdentities')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
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 => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.as(UserNameComponent).name,
|
||||||
|
name: await (await user.as(UserMailIdentityComponent).mainMailIdentity.get())?.toEmail(),
|
||||||
|
identity_count: (await user.as(UserMailIdentityComponent).mailIdentities.get()).length,
|
||||||
|
domain_count: (await user.as(UserMailIdentityComponent).mailDomains.get()).length,
|
||||||
|
}))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getMailboxBackend(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = await User.select()
|
||||||
|
.where('id', req.params.id)
|
||||||
|
.with('mailIdentities')
|
||||||
|
.first();
|
||||||
|
if (!user) throw new NotFoundHttpError('User', req.url);
|
||||||
|
|
||||||
|
res.render('backend/mailbox', {
|
||||||
|
mailbox: {
|
||||||
|
id: user.id,
|
||||||
|
name: await (await user.as(UserMailIdentityComponent).mainMailIdentity.get())?.toEmail() || 'Not created.',
|
||||||
|
},
|
||||||
|
domains: (await MailDomain.select().get()).map(d => ({
|
||||||
|
display: d.name,
|
||||||
|
value: d.id,
|
||||||
|
})),
|
||||||
|
identities: await Promise.all((await user.as(UserMailIdentityComponent).mailIdentities.get()).map(async i => ({
|
||||||
|
id: i.id,
|
||||||
|
email: await i.toEmail(),
|
||||||
|
}))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postAddDomain(req: Request, res: Response): Promise<void> {
|
||||||
|
const domain = MailDomain.create({
|
||||||
|
name: req.body.name,
|
||||||
|
user_id: req.body.user_id,
|
||||||
|
});
|
||||||
|
await domain.save();
|
||||||
|
req.flash('success', `Domain ${domain.name} successfully added with owner ${(await domain.owner.get())?.name}`);
|
||||||
|
res.redirectBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postRemoveDomain(req: Request, res: Response): Promise<void> {
|
||||||
|
const domain = await MailDomain.select()
|
||||||
|
.where('id', req.body.id)
|
||||||
|
.with('identities')
|
||||||
|
.first();
|
||||||
|
if (!domain) throw new NotFoundHttpError('Domain', req.url);
|
||||||
|
|
||||||
|
// Don't delete that domain if it still has identities
|
||||||
|
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).`);
|
||||||
|
res.redirectBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await domain.delete();
|
||||||
|
|
||||||
|
req.flash('success', `Domain ${domain.name} successfully deleted.`);
|
||||||
|
res.redirectBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = await User.select()
|
||||||
|
.where('id', req.params.id)
|
||||||
|
.first();
|
||||||
|
if (!user) throw new NotFoundHttpError('User', req.url);
|
||||||
|
|
||||||
|
const domain = await MailDomain.getById(req.body.mail_domain_id);
|
||||||
|
if (!domain) throw new NotFoundHttpError('domain', req.url);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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', 'Mailbox created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash('success', 'Mail identity ' + (await identity.toEmail()) + ' successfully created.')
|
||||||
|
res.redirectBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> {
|
||||||
|
const identity = await MailIdentity.select()
|
||||||
|
.where('id', req.body.id)
|
||||||
|
.with('user')
|
||||||
|
.first();
|
||||||
|
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
|
||||||
|
|
||||||
|
const user = await identity.user.get();
|
||||||
|
if (user?.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
|
||||||
|
req.flash('error', `Cannot delete this user's mailbox identity.`);
|
||||||
|
res.redirectBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await identity.delete();
|
||||||
|
req.flash('success', 'Identity ' + (await identity.toEmail()) + ' successfully deleted.');
|
||||||
|
res.redirectBack();
|
||||||
|
}
|
||||||
|
}
|
@ -1,50 +0,0 @@
|
|||||||
import Controller from "wms-core/Controller";
|
|
||||||
import {Request, RequestHandler, Response} from "express";
|
|
||||||
import {ForbiddenHttpError} from "wms-core/HttpError";
|
|
||||||
import Validator from "wms-core/db/Validator";
|
|
||||||
import argon2 from "argon2";
|
|
||||||
import config from "config";
|
|
||||||
|
|
||||||
export default class PreLaunchWall extends Controller {
|
|
||||||
public getGlobalHandlers(): RequestHandler[] {
|
|
||||||
return [
|
|
||||||
(req, res, next) => {
|
|
||||||
if (!req.session) throw new ForbiddenHttpError('page', req.url);
|
|
||||||
|
|
||||||
if (!req.session.authorized) {
|
|
||||||
const route = Controller.route('prelaunch-wall');
|
|
||||||
if (req.url !== route) {
|
|
||||||
res.redirect(route);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
routes(): void {
|
|
||||||
this.get('/prelaunch-wall', this.getWall, 'prelaunch-wall');
|
|
||||||
this.post('/prelaunch-wall', this.postWall, 'prelaunch-wall');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getWall(req: Request, res: Response) {
|
|
||||||
res.render('prelaunch-wall');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async postWall(req: Request, res: Response) {
|
|
||||||
await this.validate({
|
|
||||||
password: new Validator().defined(),
|
|
||||||
}, req.body);
|
|
||||||
|
|
||||||
if (await argon2.verify(config.get<string>('prelaunch-password'), req.body.password)) {
|
|
||||||
req.session!.authorized = true;
|
|
||||||
req.flash('success', 'Authentication success!');
|
|
||||||
res.redirect(Controller.route('home'));
|
|
||||||
}
|
|
||||||
|
|
||||||
req.flash('error', 'Invalid password.');
|
|
||||||
res.redirectBack();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,9 @@
|
|||||||
import Logger from "wms-core/Logger";
|
import Logger from "wms-core/Logger";
|
||||||
import Aldap from "./Aldap";
|
import App from "./App";
|
||||||
|
import config from "config";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const app = new Aldap(4899);
|
const app = new App(config.get<number>('port'));
|
||||||
await app.start();
|
await app.start();
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
Logger.error(err);
|
Logger.error(err);
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable";
|
|
||||||
import CreateLogsTable from "wms-core/migrations/CreateLogsTable";
|
|
||||||
import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable";
|
|
||||||
import CreateUserPasswordsTable from "./migrations/CreateUserPasswordsTable";
|
|
||||||
import CreateUsernamesTable from "./migrations/CreateUsernamesTable";
|
|
||||||
|
|
||||||
export const MIGRATIONS = [
|
|
||||||
CreateMigrationsTable,
|
|
||||||
CreateLogsTable,
|
|
||||||
CreateUsersAndUserEmailsTable,
|
|
||||||
CreateUserPasswordsTable,
|
|
||||||
CreateUsernamesTable,
|
|
||||||
];
|
|
20
src/migrations/AddNameToUsers.ts
Normal file
20
src/migrations/AddNameToUsers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
21
src/migrations/AddPasswordToUsers.ts
Normal file
21
src/migrations/AddPasswordToUsers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
49
src/migrations/CreateMailTables.ts
Normal file
49
src/migrations/CreateMailTables.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import Migration from "wms-core/db/Migration";
|
||||||
|
import {Connection} from "mysql";
|
||||||
|
import ModelFactory from "wms-core/db/ModelFactory";
|
||||||
|
import MailDomain from "../models/MailDomain";
|
||||||
|
import MailIdentity from "../models/MailIdentity";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
|
||||||
|
|
||||||
|
export default class CreateMailTables extends Migration {
|
||||||
|
public async install(connection: Connection): Promise<void> {
|
||||||
|
await this.query(`CREATE TABLE mail_domains
|
||||||
|
(
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(252) UNIQUE NOT NULL,
|
||||||
|
user_id INT,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
)`, connection);
|
||||||
|
await this.query(`CREATE TABLE mail_identities
|
||||||
|
(
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
mail_domain_id INT NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
redirects_to VARCHAR(254),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE (mail_domain_id, name),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (mail_domain_id) REFERENCES mail_domains (id) ON DELETE CASCADE
|
||||||
|
)`, connection);
|
||||||
|
await this.query(`ALTER TABLE users
|
||||||
|
ADD COLUMN main_mail_identity_id INT,
|
||||||
|
ADD FOREIGN KEY main_mail_identity_fk (main_mail_identity_id) REFERENCES mail_identities (id)`, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rollback(connection: Connection): Promise<void> {
|
||||||
|
await this.query(`ALTER TABLE users
|
||||||
|
DROP FOREIGN KEY main_mail_identity_fk,
|
||||||
|
DROP COLUMN main_mail_identity_id`, connection);
|
||||||
|
await this.query(`DROP TABLE IF EXISTS mail_identities, mail_domains`, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerModels(): void {
|
||||||
|
ModelFactory.register(MailDomain);
|
||||||
|
ModelFactory.register(MailIdentity);
|
||||||
|
ModelFactory.get(User).addComponent(UserMailIdentityComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,18 +0,0 @@
|
|||||||
import Migration from "wms-core/db/Migration";
|
|
||||||
import {query} from "wms-core/db/MysqlConnectionManager";
|
|
||||||
|
|
||||||
export default class CreateUserPasswordsTable extends Migration {
|
|
||||||
public async install(): Promise<void> {
|
|
||||||
await query('CREATE TABLE user_passwords(' +
|
|
||||||
'id INT NOT NULL AUTO_INCREMENT,' +
|
|
||||||
'user_id INT NOT NULL,' +
|
|
||||||
'password VARCHAR(256) NOT NULL,' +
|
|
||||||
'PRIMARY KEY(id),' +
|
|
||||||
'FOREIGN KEY user_pwd_fk (user_id) REFERENCES users (id) ON DELETE CASCADE' +
|
|
||||||
')');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async rollback(): Promise<void> {
|
|
||||||
await query('DROP TABLE user_passwords');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
import Migration from "wms-core/db/Migration";
|
|
||||||
import {query} from "wms-core/db/MysqlConnectionManager";
|
|
||||||
|
|
||||||
export default class CreateUserPasswordsTable extends Migration {
|
|
||||||
public async install(): Promise<void> {
|
|
||||||
await query('CREATE TABLE usernames(' +
|
|
||||||
'id INT NOT NULL AUTO_INCREMENT,' +
|
|
||||||
'user_id INT UNIQUE NOT NULL,' +
|
|
||||||
'username VARCHAR(64) UNIQUE NOT NULL,' +
|
|
||||||
'PRIMARY KEY(id),' +
|
|
||||||
'FOREIGN KEY user_name_fk (user_id) REFERENCES users (id) ON DELETE CASCADE' +
|
|
||||||
')');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async rollback(): Promise<void> {
|
|
||||||
await query('DROP TABLE usernames');
|
|
||||||
}
|
|
||||||
}
|
|
42
src/models/MailDomain.ts
Normal file
42
src/models/MailDomain.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Model from "wms-core/db/Model";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
import {ManyModelRelation, OneModelRelation} from "wms-core/db/ModelRelation";
|
||||||
|
import ModelFactory from "wms-core/db/ModelFactory";
|
||||||
|
import MailIdentity from "./MailIdentity";
|
||||||
|
|
||||||
|
export default class MailDomain extends Model {
|
||||||
|
public id?: number = undefined;
|
||||||
|
public name?: string = undefined;
|
||||||
|
public user_id?: number = undefined;
|
||||||
|
|
||||||
|
public readonly owner: OneModelRelation<MailDomain, User> = new OneModelRelation(this, ModelFactory.get(User), {
|
||||||
|
localKey: 'user_id',
|
||||||
|
foreignKey: 'id',
|
||||||
|
});
|
||||||
|
|
||||||
|
public readonly identities: ManyModelRelation<MailDomain, MailIdentity> = new ManyModelRelation(this, ModelFactory.get(MailIdentity), {
|
||||||
|
localKey: 'id',
|
||||||
|
foreignKey: 'mail_domain_id',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
public updateWithData(data: any) {
|
||||||
|
super.updateWithData(data);
|
||||||
|
if (typeof this.user_id !== 'undefined' && this.user_id <= 0) {
|
||||||
|
this.user_id = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected init(): void {
|
||||||
|
this.setValidation('name').defined().maxLength(252).regexp(/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/)
|
||||||
|
this.setValidation('user_id').acceptUndefined().exists(User, 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPublic(): boolean {
|
||||||
|
return !this.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public canCreateAddresses(user: User): boolean {
|
||||||
|
return this.user_id === user.id || this.isPublic();
|
||||||
|
}
|
||||||
|
}
|
38
src/models/MailIdentity.ts
Normal file
38
src/models/MailIdentity.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import Model, {EMAIL_REGEX} from "wms-core/db/Model";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
import MailDomain from "./MailDomain";
|
||||||
|
import {OneModelRelation} from "wms-core/db/ModelRelation";
|
||||||
|
import ModelFactory from "wms-core/db/ModelFactory";
|
||||||
|
|
||||||
|
export default class MailIdentity extends Model {
|
||||||
|
public static get table(): string {
|
||||||
|
return 'mail_identities';
|
||||||
|
}
|
||||||
|
|
||||||
|
public id?: number = undefined;
|
||||||
|
public user_id?: number = undefined;
|
||||||
|
public mail_domain_id?: number = undefined;
|
||||||
|
public name?: string = undefined;
|
||||||
|
public redirects_to?: string = undefined;
|
||||||
|
|
||||||
|
public readonly user: OneModelRelation<MailIdentity, User> = new OneModelRelation(this, ModelFactory.get(User), {
|
||||||
|
foreignKey: 'id',
|
||||||
|
localKey: 'user_id',
|
||||||
|
});
|
||||||
|
public readonly domain: OneModelRelation<MailIdentity, MailDomain> = new OneModelRelation(this, ModelFactory.get(MailDomain), {
|
||||||
|
foreignKey: 'id',
|
||||||
|
localKey: 'mail_domain_id',
|
||||||
|
});
|
||||||
|
|
||||||
|
protected init(): void {
|
||||||
|
this.setValidation('user_id').defined().exists(User, 'id');
|
||||||
|
this.setValidation('mail_domain_id').defined().exists(MailDomain, 'id');
|
||||||
|
this.setValidation('name').defined().maxLength(64).regexp(/^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+$/)
|
||||||
|
.unique(this, 'name', () => MailIdentity.select().where('mail_domain_id', this.mail_domain_id));
|
||||||
|
this.setValidation('redirects_to').acceptUndefined().maxLength(254).regexp(EMAIL_REGEX);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toEmail(): Promise<string> {
|
||||||
|
return this.name + '@' + (await this.domain.get())!.name;
|
||||||
|
}
|
||||||
|
}
|
38
src/models/UserMailIdentityComponent.ts
Normal file
38
src/models/UserMailIdentityComponent.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import ModelComponent from "wms-core/db/ModelComponent";
|
||||||
|
import User from "wms-core/auth/models/User";
|
||||||
|
import {ManyModelRelation, OneModelRelation} from "wms-core/db/ModelRelation";
|
||||||
|
import MailIdentity from "./MailIdentity";
|
||||||
|
import ModelFactory from "wms-core/db/ModelFactory";
|
||||||
|
import MailDomain from "./MailDomain";
|
||||||
|
|
||||||
|
export default class UserMailIdentityComponent extends ModelComponent<User> {
|
||||||
|
public main_mail_identity_id?: number = undefined;
|
||||||
|
|
||||||
|
public readonly mailIdentities: ManyModelRelation<User, MailIdentity> = new ManyModelRelation(this._model, ModelFactory.get(MailIdentity), {
|
||||||
|
localKey: 'id',
|
||||||
|
foreignKey: 'user_id',
|
||||||
|
});
|
||||||
|
public readonly publicMailIdentities: ManyModelRelation<User, MailIdentity> = this.mailIdentities.clone()
|
||||||
|
.filter(async model => (await model.domain.get())!.isPublic());
|
||||||
|
|
||||||
|
public readonly mailDomains: ManyModelRelation<User, MailDomain> = new ManyModelRelation(this._model, ModelFactory.get(MailDomain), {
|
||||||
|
localKey: 'id',
|
||||||
|
foreignKey: 'user_id',
|
||||||
|
});
|
||||||
|
|
||||||
|
public readonly mainMailIdentity: OneModelRelation<User, MailIdentity> = new OneModelRelation(this._model, ModelFactory.get(MailIdentity), {
|
||||||
|
foreignKey: 'id',
|
||||||
|
localKey: 'main_mail_identity_id',
|
||||||
|
});
|
||||||
|
|
||||||
|
protected init(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxPublicAddressesCount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPublicAddressesCount(): Promise<number> {
|
||||||
|
return (await this.publicMailIdentities.get()).length;
|
||||||
|
}
|
||||||
|
}
|
13
src/models/UserNameComponent.ts
Normal file
13
src/models/UserNameComponent.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,118 +0,0 @@
|
|||||||
import Model from "wms-core/db/Model";
|
|
||||||
import Validator from "wms-core/db/Validator";
|
|
||||||
import User from "wms-core/auth/models/User";
|
|
||||||
import argon2 from "argon2";
|
|
||||||
import AuthProof from "wms-core/auth/AuthProof";
|
|
||||||
import {UserAlreadyExistsAuthError} from "wms-core/auth/AuthGuard";
|
|
||||||
|
|
||||||
export default class UserPassword extends Model {
|
|
||||||
public static async getByEmail(email: string): Promise<UserPassword | null> {
|
|
||||||
const user = await User.fromEmail(email);
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
const result = await this.models<UserPassword>(this.select().where('user_id', user.id).first());
|
|
||||||
return result && result.length > 0 ? result[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private user_id?: number;
|
|
||||||
private password?: string;
|
|
||||||
|
|
||||||
protected defineProperties(): void {
|
|
||||||
this.defineProperty<number>('user_id', new Validator().defined().unique(this).exists(User, 'id'));
|
|
||||||
this.defineProperty<string>('password', new Validator().defined());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setUser(userID: number): Promise<void> {
|
|
||||||
if (this.user_id) throw new Error(`Cannot change this password's user.`);
|
|
||||||
this.user_id = userID;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setPassword(rawPassword: string): Promise<void> {
|
|
||||||
this.password = await argon2.hash(rawPassword, {
|
|
||||||
timeCost: 10,
|
|
||||||
memoryCost: 4096,
|
|
||||||
parallelism: 4,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async verifyPassword(passwordGuess: string): Promise<boolean> {
|
|
||||||
if (!this.password) return false;
|
|
||||||
|
|
||||||
return await argon2.verify(this.password, passwordGuess);
|
|
||||||
}
|
|
||||||
|
|
||||||
public isOwnedBy(userId: number): boolean {
|
|
||||||
return this.user_id === userId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PasswordAuthProof implements AuthProof {
|
|
||||||
public static getProofForSession(session: Express.Session): PasswordAuthProof | null {
|
|
||||||
const authPasswordProof = session.auth_password_proof;
|
|
||||||
if (!authPasswordProof) return null;
|
|
||||||
return new PasswordAuthProof(authPasswordProof.email, authPasswordProof.authorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly email?: string;
|
|
||||||
private authorized: boolean;
|
|
||||||
private userPassword?: UserPassword;
|
|
||||||
|
|
||||||
public constructor(email: string, authorized: boolean = false) {
|
|
||||||
this.email = email;
|
|
||||||
this.authorized = authorized;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getEmail(): Promise<string> {
|
|
||||||
return this.email!;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUser(): Promise<User | null> {
|
|
||||||
return await User.fromEmail(await this.getEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isAuthorized(): Promise<boolean> {
|
|
||||||
return this.authorized;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isOwnedBy(userId: number): Promise<boolean> {
|
|
||||||
const password = await this.getUserPassword();
|
|
||||||
return password !== null && password.isOwnedBy(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isValid(): Promise<boolean> {
|
|
||||||
return await this.getUserPassword() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async revoke(session: Express.Session): Promise<void> {
|
|
||||||
session.auth_password_proof = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUserPassword(): Promise<UserPassword | null> {
|
|
||||||
return this.userPassword ? this.userPassword : await UserPassword.getByEmail(await this.getEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async authorize(passwordGuess: string, session?: Express.Session): Promise<boolean> {
|
|
||||||
const password = await this.getUserPassword();
|
|
||||||
if (!password || !await password.verifyPassword(passwordGuess)) return false;
|
|
||||||
|
|
||||||
this.authorized = true;
|
|
||||||
if (session) {
|
|
||||||
this.save(session);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async register(password: string): Promise<UserPassword> {
|
|
||||||
if (await this.getUserPassword()) throw new UserAlreadyExistsAuthError(await this.getEmail());
|
|
||||||
this.userPassword = new UserPassword({});
|
|
||||||
await this.userPassword.setPassword(password);
|
|
||||||
return this.userPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
private save(session: Express.Session) {
|
|
||||||
session.auth_password_proof = {
|
|
||||||
email: this.email,
|
|
||||||
authorized: this.authorized,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
107
src/models/UserPasswordComponent.ts
Normal file
107
src/models/UserPasswordComponent.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
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 constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(): void {
|
||||||
|
this.setValidation('password').acceptUndefined().maxLength(128);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPassword(rawPassword: string): Promise<void> {
|
||||||
|
await new Validator<string>().defined().minLength(8).maxLength(512)
|
||||||
|
.execute('password', 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.save();
|
||||||
|
return proofForSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createProofForLogin(session: Express.Session): PasswordAuthProof {
|
||||||
|
return new PasswordAuthProof(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly session: Express.Session;
|
||||||
|
private userID: number | null;
|
||||||
|
private authorized: boolean;
|
||||||
|
private userPassword: UserPasswordComponent | null = null;
|
||||||
|
|
||||||
|
private constructor(session: Express.Session) {
|
||||||
|
this.session = session;
|
||||||
|
this.authorized = session.auth_password_proof?.authorized || false;
|
||||||
|
this.userID = session.auth_password_proof?.userID || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getResource(): Promise<User | null> {
|
||||||
|
return await User.getById(this.userID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setResource(user: User) {
|
||||||
|
this.userID = user.id!;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isAuthorized(): Promise<boolean> {
|
||||||
|
return this.authorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isValid(): Promise<boolean> {
|
||||||
|
return await this.isAuthorized() || typeof this.userID === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
userID: this.userID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,32 +0,0 @@
|
|||||||
import Model from "wms-core/db/Model";
|
|
||||||
import Validator from "wms-core/db/Validator";
|
|
||||||
import User from "wms-core/auth/models/User";
|
|
||||||
|
|
||||||
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
|
|
||||||
|
|
||||||
export default class Username extends Model {
|
|
||||||
public static async fromUser(userID: number): Promise<Username | null> {
|
|
||||||
const models = await this.models<Username>(this.select().where('user_id', userID));
|
|
||||||
return models && models.length > 0 ? models[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async getUserFromUsername(username: string): Promise<User | null> {
|
|
||||||
const models = await this.models<Username>(this.select().where('username', username.toLowerCase()));
|
|
||||||
if (!models || models.length === 0) return null;
|
|
||||||
return await User.getById<User>(models[0].user_id!);
|
|
||||||
}
|
|
||||||
|
|
||||||
private user_id?: number;
|
|
||||||
public username?: string;
|
|
||||||
|
|
||||||
|
|
||||||
constructor(data: any) {
|
|
||||||
super(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected defineProperties(): void {
|
|
||||||
this.defineProperty<number>('user_id', new Validator().defined().exists(User, 'id').unique(this));
|
|
||||||
this.defineProperty<number>('username', new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,14 +1,24 @@
|
|||||||
{% extends 'layouts/base.njk' %}
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
{% set title = 'Example App - About us' %}
|
{% set title = app.name + ' - About us' %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>Very interesting</h1>
|
<h1>ALDAP: an authentication system for many services</h1>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>This is us</h2>
|
<h2><i data-feather="help-circle"></i> What services use ALDAP?</h2>
|
||||||
<p class="center">And we like wms!</p>
|
<ul>
|
||||||
|
<li><a href="https://mail.toot.party" target="_blank">mail.toot.party</a> - a private mail service</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><i data-feather="settings"></i> Authentication mechanisms</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Internal (shared database)</li>
|
||||||
|
<li>LDAP</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
138
views/account.njk
Normal file
138
views/account.njk
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
|
{% set title = 'ALDAP - Welcome to the toot.party auth center!' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main class="container">
|
||||||
|
<div class="panel">
|
||||||
|
<i data-feather="user"></i>
|
||||||
|
<h1>My account</h1>
|
||||||
|
|
||||||
|
<p>Name: {{ user.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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> Set as main address
|
||||||
|
</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> Remove
|
||||||
|
</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> Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3"><i data-feather="shield-off"></i> No recovery email address.</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 %}
|
58
views/backend/mailbox.njk
Normal file
58
views/backend/mailbox.njk
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
|
{% set title = app.name + ' - Backend' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="container">
|
||||||
|
<main class="panel">
|
||||||
|
<i data-feather="mail"></i>
|
||||||
|
<h1>Mailbox</h1>
|
||||||
|
|
||||||
|
<p class="center">{{ mailbox.name }}</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('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> Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<form action="{{ route('backend-create-mail-identity', mailbox.id) }}" method="POST" class="sub-panel">
|
||||||
|
<h3>{% if mailboxIdentity == null %}Create a 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>
|
||||||
|
|
||||||
|
<button><i data-feather="plus"></i> Create</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
86
views/backend/mailboxes.njk
Normal file
86
views/backend/mailboxes.njk
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
|
{% set title = app.name + ' - Backend' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1>Mailbox manager</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>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">
|
||||||
|
<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> Remove
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Mailboxes</h2>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>User name</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Identities</th>
|
||||||
|
<th>Owned domains</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for box in mailboxes %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ box.id }}</td>
|
||||||
|
<td>{{ box.username }}</td>
|
||||||
|
<td><a href="{{ route('backend-mailbox', box.id) }}">{{ box.name }}</a></td>
|
||||||
|
<td>{{ box.identity_count }}</td>
|
||||||
|
<td>{{ box.domain_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,2 +0,0 @@
|
|||||||
{% extends './error.njk' %}
|
|
||||||
{% import 'macros.njk' as macros %}
|
|
@ -1,36 +0,0 @@
|
|||||||
{% extends '../layouts/barebone.njk' %}
|
|
||||||
|
|
||||||
{% set title = error_code + ' - ' + error_message %}
|
|
||||||
|
|
||||||
{% block _stylesheets %}
|
|
||||||
<link rel="stylesheet" href="/css/error.css">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block _body %}
|
|
||||||
<div class="logo"><a href="/">Example app</a></div>
|
|
||||||
|
|
||||||
<main class="{% block class %}{% endblock %}">
|
|
||||||
{% if flash %}
|
|
||||||
{{ macros.messages(flash) }}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="error-code">{{ error_code }}</div>
|
|
||||||
<div class="error-message">{{ error_message }}</div>
|
|
||||||
<div class="error-instructions">{{ error_instructions|safe }}</div>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
{% if session.previousUrl and session.previousUrl != '/' and session.previousUrl != url %}
|
|
||||||
<a href="{{ session.previousUrl }}" class="button"><i data-feather="arrow-left"></i> Go back</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<a href="/" class="button"><i data-feather="home"></i> Go to homepage</a>
|
|
||||||
</nav>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div class="contact">
|
|
||||||
Error ID: {{ error_id }}
|
|
||||||
<br>
|
|
||||||
If you think this isn't right, please contact us with the above error ID at
|
|
||||||
<a href="mailto:contact@example.net">contact@example.net</a>.
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -1,6 +1,6 @@
|
|||||||
{% extends 'layouts/base.njk' %}
|
{% extends 'layouts/base.njk' %}
|
||||||
|
|
||||||
{% set title = 'ALDAP - Welcome to the toot.party auth center!' %}
|
{% set title = app.name + ' - Welcome to the toot.party auth center!' %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{{ title }}</title>
|
|
||||||
|
|
||||||
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">
|
|
||||||
<link rel="shortcut icon" type="image/png" href="/img/logox128.png">
|
|
||||||
<link rel="shortcut icon" type="image/svg" href="/img/logo.svg">
|
|
||||||
|
|
||||||
{% if description %}
|
|
||||||
<meta name="description" content="{{ description }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if refresh_after %}
|
|
||||||
<meta http-equiv="refresh" content="{{ refresh_after }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% block _stylesheets %}{% endblock %}
|
|
||||||
{% block _scripts %}
|
|
||||||
<script src="/js/app.js" defer></script>
|
|
||||||
{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
{% block header %}{% endblock %}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% block _body %}{% endblock %}
|
|
||||||
|
|
||||||
<footer>{% block footer %}{% endblock %}</footer>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,4 +1,4 @@
|
|||||||
{% extends './barebone.njk' %}
|
{% extends 'layouts/barebone.njk' %}
|
||||||
{% import 'macros.njk' as macros %}
|
{% import 'macros.njk' as macros %}
|
||||||
|
|
||||||
{% block _stylesheets %}
|
{% block _stylesheets %}
|
||||||
@ -12,16 +12,28 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> ALDAP</a>
|
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> {{ app.name }}</a>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<button id="menu-button"><i data-feather="menu"></i></button>
|
||||||
<li><a href="{{ route('about') }}"><i data-feather="info"></i> About</a></li>
|
<ul id="main-menu">
|
||||||
|
<li><a href="{{ route('about') }}"><i data-feather="info"></i> <span class="tip">About</span></a></li>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<li><a href="{{ route('home') }}"><i data-feather="user"></i> {{ user.name }}</a></li>
|
{% if user.is_admin %}
|
||||||
<li><a href="{{ route('logout') }}"><i data-feather="log-out"></i> Logout</a></li>
|
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li><a href="{{ route('account') }}"><i data-feather="user"></i>
|
||||||
|
<span class="tip">{{ user.name }}</span></a></li>
|
||||||
|
<li>
|
||||||
|
<form action="{{ route('logout') }}" method="POST">
|
||||||
|
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
|
||||||
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="{{ route('login') }}"><i data-feather="log-in"></i> Login</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> Register</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>
|
||||||
@ -44,4 +56,4 @@
|
|||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block footer %}ALDAP v{{ app_version }} - all rights reserved.{% endblock %}
|
{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %}
|
@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="panel center">
|
<div class="panel center">
|
||||||
<form action="{{ route('login') }}" method="POST">
|
<form action="{{ route('auth') }}" method="POST">
|
||||||
{{ macros.field(_locals, 'email', 'email', null, 'Your email address', null, 'required') }}
|
{{ macros.field(_locals, 'text', 'username', null, 'Your username', null, 'required') }}
|
||||||
{{ macros.field(_locals, 'password', 'password', null, 'Your password', null, 'required') }}
|
{{ macros.field(_locals, 'password', 'password', null, 'Your password', null, 'required') }}
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
<button type="submit"><i data-feather="log-in"></i> Login</button>
|
||||||
|
|
||||||
{{ macros.csrf(getCSRFToken) }}
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
</form>
|
</form>
|
||||||
|
142
views/macros.njk
142
views/macros.njk
@ -1,142 +0,0 @@
|
|||||||
{% macro message(type, content, raw=false, discreet=false) %}
|
|
||||||
<div class="message{{ ' message-discreet' if discreet }}" data-type="{{ type }}">
|
|
||||||
<i class="icon"></i>
|
|
||||||
<span class="content">
|
|
||||||
{{ content|safe if raw else content }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro messages(flash) %}
|
|
||||||
{% set flashed = flash() %}
|
|
||||||
{% set display = 0 %}
|
|
||||||
|
|
||||||
{% for type, bag in flashed %}
|
|
||||||
{% if bag|length %}
|
|
||||||
{% set display = 1 %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if display %}
|
|
||||||
<div class="messages">
|
|
||||||
{% for type, bag in flashed %}
|
|
||||||
{% for content in bag %}
|
|
||||||
{{ message(type, content) }}
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro csrf(getCSRFToken) %}
|
|
||||||
<input type="hidden" name="csrf" value="{{ getCSRFToken() }}">
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro field(_locals, type, name, value, placeholder, hint, validation_attributes='', extraData='') %}
|
|
||||||
{% set validation = _locals.validation() %}
|
|
||||||
{% set validation = validation[name] if validation[name] or null %}
|
|
||||||
{% set previousFormData = _locals.previousFormData() %}
|
|
||||||
{% set value = previousFormData[name] or value or validation.value or '' %}
|
|
||||||
|
|
||||||
{% if type == 'hidden' %}
|
|
||||||
{% if validation %}
|
|
||||||
{{ message('error', validation.message) }}
|
|
||||||
{% endif %}
|
|
||||||
<input type="hidden" name="{{ name }}" value="{{ value }}">
|
|
||||||
{% else %}
|
|
||||||
<div class="form-field{{ ' inline' if type == 'checkbox' }}">
|
|
||||||
{% if type == 'duration' %}
|
|
||||||
<div class="input-group">
|
|
||||||
{% for f in extraData %}
|
|
||||||
<div class="time-input">
|
|
||||||
{% if previousFormData[name] %}
|
|
||||||
{% set v = value[f] %}
|
|
||||||
{% else %}
|
|
||||||
{% set v = (value % 60) if f == 's' else (((value - value % 60) / 60 % 60) if f == 'm' else ((value - value % 3600) / 3600 if f == 'h')) %}
|
|
||||||
{% endif %}
|
|
||||||
<input type="number" name="{{ name }}[{{ f }}]" id="field-{{ name }}-{{ f }}"
|
|
||||||
value="{{ v }}"
|
|
||||||
min="0" {{ 'max=60' if (f == 's' or f == 'm') }}
|
|
||||||
{{ validation_attributes }}>
|
|
||||||
<label for="field-{{ name }}-{{ f }}">{{ f }}</label>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% elseif type == 'select' %}
|
|
||||||
<select name="{{ name }}" id="field-{{ name }}" {{ validation_attributes|safe }}>
|
|
||||||
{% for option in extraData %}
|
|
||||||
<option value="{{ option }}" {{ 'selected' if value == option }}>{{ option }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<i data-feather="chevron-down"></i>
|
|
||||||
{% else %}
|
|
||||||
<input type="{{ type }}" name="{{ name }}" id="field-{{ name }}"
|
|
||||||
{% if type != 'checkbox' %} value="{{ value }}" {% endif %}
|
|
||||||
{{ 'checked' if (type == 'checkbox' and value == 'on') }}
|
|
||||||
{{ validation_attributes|safe }}>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<label for="field-{{ name }}{{ '-' + extraData[0] if type == 'duration' }}">{{ placeholder }}</label>
|
|
||||||
{{ fieldError(_locals, name) }}
|
|
||||||
{% if hint %}
|
|
||||||
<div class="hint"><i data-feather="info"></i> {{ hint }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro fieldError(_locals, name) %}
|
|
||||||
{% set validation = _locals.validation() %}
|
|
||||||
{% set validation = validation[name] if validation[name] or null %}
|
|
||||||
{% if validation %}
|
|
||||||
<div class="error"><i data-feather="x-circle"></i> {{ validation.message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro websocket(websocketUrl, listener, reconnectOnClose = 1, checkFunction = 0) %}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
{% if checkFunction %}
|
|
||||||
if (!{{ checkFunction }}()) return;
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
const websocket = new WebSocket('{{ websocketUrl }}');
|
|
||||||
websocket.onopen = (e) => {
|
|
||||||
console.debug('Websocket connected');
|
|
||||||
};
|
|
||||||
websocket.onmessage = (e) => {
|
|
||||||
{{ listener }}(websocket, e);
|
|
||||||
};
|
|
||||||
websocket.onerror = (e) => {
|
|
||||||
console.error('Websocket error', e);
|
|
||||||
};
|
|
||||||
websocket.onclose = (e) => {
|
|
||||||
console.debug('Websocket closed', e.code, e.reason);
|
|
||||||
|
|
||||||
{% if reconnectOnClose %}
|
|
||||||
setTimeout(run, 1000);
|
|
||||||
{% endif %}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro paginate(pagination, routeName) %}
|
|
||||||
{% if pagination.hasPrevious() or pagination.hasNext() %}
|
|
||||||
<div class="pagination">
|
|
||||||
{% if pagination.hasPrevious() %}
|
|
||||||
<a href="{{ route(routeName, {page: pagination.page - 1}) }}"><i data-feather="chevron-left"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<span>{{ pagination.page }}</span>
|
|
||||||
|
|
||||||
{% if pagination.hasNext() %}
|
|
||||||
<a href="{{ route(routeName, {page: pagination.page + 1}) }}"><i data-feather="chevron-right"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
27
views/mails/add_recovery_email.mjml.njk
Normal file
27
views/mails/add_recovery_email.mjml.njk
Normal 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 %}
|
@ -1,19 +0,0 @@
|
|||||||
{% extends 'layouts/base.njk' %}
|
|
||||||
|
|
||||||
{% set title = 'ALDAP - Early access' %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<div class="container">
|
|
||||||
<div class="panel">
|
|
||||||
<h1>{{ title }}</h1>
|
|
||||||
|
|
||||||
<form action="{{ route('prelaunch-wall') }}" method="POST">
|
|
||||||
{{ macros.field(_locals, 'password', 'password', null, 'Enter password') }}
|
|
||||||
|
|
||||||
<button type="submit">Authenticate</button>
|
|
||||||
|
|
||||||
{{ macros.csrf(getCSRFToken) }}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -17,23 +17,6 @@
|
|||||||
{{ macros.field(_locals, 'text', 'username', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
|
{{ macros.field(_locals, 'text', 'username', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="sub-panel">
|
|
||||||
<h2>Email</h2>
|
|
||||||
{{ macros.field(_locals, 'checkbox', 'create_email', null, 'Create an email address') }}
|
|
||||||
|
|
||||||
<div class="inline-fields">
|
|
||||||
<span id="email_username">@</span>
|
|
||||||
{{ macros.field(_locals, 'select', 'domain', null, 'Choose your domain', null, 'disabled', ['toot.party']) }}
|
|
||||||
</div>
|
|
||||||
{{ macros.fieldError(_locals, 'email') }}
|
|
||||||
<div class="hint"><i data-feather="info"></i> This cannot be changed later.</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sub-panel">
|
|
||||||
<h2>Recovery email</h2>
|
|
||||||
{{ macros.field(_locals, 'email', 'recovery_email', null, 'Your email address', 'Optional') }}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sub-panel">
|
<section class="sub-panel">
|
||||||
<h2>Password</h2>
|
<h2>Password</h2>
|
||||||
{{ macros.field(_locals, 'password', 'password', null, 'Choose a password', null, 'required') }}
|
{{ macros.field(_locals, 'password', 'password', null, 'Choose a password', null, 'required') }}
|
||||||
@ -42,7 +25,7 @@
|
|||||||
|
|
||||||
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the terms of services', null, 'required') }}
|
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the terms of services', null, 'required') }}
|
||||||
|
|
||||||
<button type="submit">Register</button>
|
<button type="submit"><i data-feather="user-plus"></i>Register</button>
|
||||||
|
|
||||||
{{ macros.csrf(getCSRFToken) }}
|
{{ macros.csrf(getCSRFToken) }}
|
||||||
</form>
|
</form>
|
||||||
|
Loading…
Reference in New Issue
Block a user