first commit

This commit is contained in:
lingdocs 2021-08-18 13:25:08 +04:00
commit 52b289310b
74 changed files with 63137 additions and 0 deletions

21
.github/workflows/deploy-account.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Deploy Account
on:
push:
branches:
- master
- dev
paths:
- 'account/**'
- '.github/workflows/deploy-account.yml'
workflow_dispatch:
jobs:
deploy-account:
runs-on: self-hosted
steps:
- uses: actions/checkout@v2
- run: |
cd account
npm install
- run: pm2 restart "account"

1
README.md Normal file
View File

@ -0,0 +1 @@
new monorepo

11
account/.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,11 @@
name: CI
on:
push:
branches: [ master ]
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v2
- run: npm install
- run: pm2 restart "auth.lingdocs.com"

2
account/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
sessions

4
account/README.md Normal file
View File

@ -0,0 +1,4 @@
# auth.lingdocs.com
Auth service for LingDocs (in progress, not usable yet)

1604
account/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
account/package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "passport-b",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --files src/index.ts",
"start": "NODE_ENV=production ts-node --transpile-only src/index.ts"
},
"author": "",
"license": "ISC",
"dependencies": {
"base64url": "^3.0.1",
"bcryptjs": "^2.4.3",
"connect-redis": "^6.0.0",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"ejs": "^3.1.6",
"express": "^4.17.1",
"express-session": "^1.17.2",
"nano": "^9.0.3",
"node-fetch": "^2.6.1",
"nodemailer": "^6.6.3",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-google-oauth": "^2.0.0",
"passport-local": "^1.0.0",
"passport-twitter": "^1.0.4",
"pug": "^3.0.2",
"redis": "^3.1.2",
"session-file-store": "^1.5.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/base64url": "^2.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/node": "^16.6.0",
"@types/node-fetch": "^2.5.12",
"@types/nodemailer": "^6.4.4",
"@types/passport-github2": "^1.2.5",
"@types/passport-google-oauth": "^1.0.42",
"@types/passport-local": "^1.0.34",
"@types/passport-twitter": "^1.0.37",
"@types/redis": "^2.8.31",
"@types/uuid": "^8.3.1",
"ts-node": "^10.1.0",
"typescript": "^4.3.5"
}
}

4997
account/public/css/bootstrap-grid.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4996
account/public/css/bootstrap-grid.rtl.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

427
account/public/css/bootstrap-reboot.css vendored Normal file
View File

@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,424 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr ;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */

File diff suppressed because one or more lines are too long

4866
account/public/css/bootstrap-utilities.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11221
account/public/css/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
account/public/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11197
account/public/css/bootstrap.rtl.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
html,
body {
height: 100%;
}
body {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
}
.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}
.form-signin .checkbox {
font-weight: 400;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

6780
account/public/js/bootstrap.bundle.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4977
account/public/js/bootstrap.esm.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

5026
account/public/js/bootstrap.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
account/public/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
account/src/extend-express.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace Express {
export interface Request {
// TODO: this will be brought in with an import
user?: LingdocsUser
}
}

30
account/src/index.ts Normal file
View File

@ -0,0 +1,30 @@
import express from "express";
import cors from "cors";
import passport from "passport";
import setupPassport from "./middleware/setup-passport";
import setupSession from "./middleware/setup-session";
import authRouter from "./routers/auth-router";
import apiRouter from "./routers/api-router";
import inProd from "./lib/inProd";
const app = express();
// MIDDLEWARE AND SETUP 🔧 //
app.set("view engine", "ejs");
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static("public"));
app.use(cors({ origin: inProd ? /\.lingdocs\.com$/ : "*", credentials: true }));
if (inProd) app.set('trust proxy', 1);
setupSession(app);
app.use(passport.initialize());
app.use(passport.session());
setupPassport(passport);
// Web Interface - returning html (mostly)
app.use("/", authRouter(passport));
// REST API - returning json
app.use("/api", apiRouter);
// START 💨 //
app.listen(4000, () => console.log("Server Has Started on 4000"));

View File

@ -0,0 +1,98 @@
import Nano from "nano";
import { DocumentInsertResponse } from "nano";
import { getTimestamp } from "./time-utils";
import env from "./env-vars";
const nano = Nano(env.couchDbURL);
const usersDb = nano.db.use("test-users");
export function updateLastActive(user: LingdocsUser): LingdocsUser {
return {
...user,
lastActive: getTimestamp(),
};
}
export function updateLastLogin(user: LingdocsUser): LingdocsUser {
return {
...user,
lastLogin: getTimestamp(),
};
}
function processAPIResponse(user: LingdocsUser, response: DocumentInsertResponse): LingdocsUser | undefined {
if (response.ok !== true) return undefined;
return {
...user,
_id: response.id,
_rev: response.rev,
};
}
export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "googleId" | "twitterId", value: string): Promise<undefined | LingdocsUser> {
const user = await usersDb.find({
selector: field === "githubId"
? { github: { id: value }}
: field === "googleId"
? { google: { id: value }}
: field === "twitterId"
? { twitter: { id: value }}
: { [field]: value },
});
if (!user.docs.length) {
return undefined;
}
return user.docs[0] as LingdocsUser;
}
export async function insertLingdocsUser(user: LingdocsUser): Promise<LingdocsUser> {
const res = await usersDb.insert(user);
const newUser = processAPIResponse(user, res);
if (!newUser) {
throw new Error("error inserting user");
}
return newUser;
}
export async function deleteLingdocsUser(uuid: UUID): Promise<void> {
const user = await getLingdocsUser("userId", uuid);
if (!user) return;
// TODO: cleanup userdbs etc
// TODO: Better type certainty here... obviously there is an _id and _rev here
await usersDb.destroy(user._id as string, user._rev as string);
}
// TODO: TO MAKE THIS SAFER, PASS IN JUST THE UPDATING FIELDS!!
// TODO: take out the updated object - do just an ID, and then use the toUpdate safe thing
export async function updateLingdocsUser(uuid: UUID, toUpdate:
// TODO: OR USE REDUCER??
{ name: string } |
{ name?: string, email: string, emailVerified: Hash } |
{ email: string, emailVerified: true } |
{ emailVerified: Hash } |
{ emailVerified: true } |
{ password: Hash } |
{ google: GoogleProfile | undefined } |
{ github: GitHubProfile | undefined } |
{ twitter: TwitterProfile | undefined } |
{
passwordReset: {
tokenHash: Hash,
requestedOn: TimeStamp,
},
}
): Promise<LingdocsUser> {
const user = await getLingdocsUser("userId", uuid);
if (!user) throw new Error("unable to update - user not found " + uuid);
if ("password" in toUpdate) {
const { passwordReset, ...u } = user;
return await insertLingdocsUser({
...u,
...toUpdate,
});
}
return await insertLingdocsUser({
...user,
...toUpdate,
});
}

View File

@ -0,0 +1,34 @@
const names = [
"LINGDOCS_EMAIL_HOST",
"LINGDOCS_EMAIL_USER",
"LINGDOCS_EMAIL_PASS",
"LINGDOCS_COUCHDB",
"LINGDOCS_ACCOUNT_COOKIE_SECRET",
"LINGDOCS_ACCOUNT_GOOGLE_CLIENT_SECRET",
"LINGDOCS_ACCOUNT_TWITTER_CLIENT_SECRET",
"LINGDOCS_ACCOUNT_GITHUB_CLIENT_SECRET",
"LINGDOCS_ACCOUNT_RECAPTCHA_SECRET",
];
const values = names.map((name) => ({
name,
value: process.env[name] || "",
}));
const missing = values.filter((v) => !v.value);
if (missing.length) {
console.error("Missing evironment variable(s):", missing.map((m) => m.name).join(", "));
process.exit(1);
}
export default {
emailHost: values[0].value,
emailUser: values[1].value,
emailPass: values[2].value,
couchDbURL: values[3].value,
cookieSecret: values[4].value,
googleClientSecret: values[5].value,
twitterClientSecret: values[6].value,
githubClientSecret: values[7].value,
recaptchaSecret: values[8].value,
};

View File

@ -0,0 +1 @@
export default process.env.NODE_ENV === "production";

View File

@ -0,0 +1,60 @@
import nodemailer from "nodemailer";
import inProd from "./inProd";
import env from "./env-vars";
type Address = string | { name: string, address: string };
const from: Address = {
name: "LingDocs Admin",
address: "admin@lingdocs.com",
};
function getAddress(user: LingdocsUser): Address {
// TODO: Guard against ""
if (!user.name) return user.email || "";
return {
name: user.name,
address: user.email || "",
};
}
const transporter = nodemailer.createTransport({
host: env.emailHost,
port: 465,
secure: true,
auth: {
user: env.emailUser,
pass: env.emailPass,
},
});
async function sendEmail(to: Address, subject: string, text: string) {
await transporter.sendMail({
from,
to,
subject,
text,
});
}
// TODO: MAKE THIS
const baseURL = inProd ? "https://account.lingdocs.com" : "http://localhost:4000";
export async function sendVerificationEmail(user: LingdocsUser, token: URLToken) {
const content = `Hello ${user.name},
Please verify your email by visiting this link: ${baseURL}/email-verification/${user.userId}/${token}
LingDocs Admin`;
await sendEmail(getAddress(user), "Please Verify Your E-mail", content);
}
export async function sendPasswordResetEmail(user: LingdocsUser, token: URLToken) {
const content = `Hello ${user.name},
Please visit this link to reset your password: ${baseURL}/password-reset/${user.userId}/${token}
LingDocs Admin`;
await sendEmail(getAddress(user), "Reset Your Password", content);
}

View File

@ -0,0 +1,23 @@
import { hash, compare } from "bcryptjs";
import { randomBytes } from "crypto";
import base64url from "base64url";
const tokenSize = 24;
export async function getHash(p: string): Promise<Hash> {
return await hash(p, 10) as Hash;
}
export async function getEmailTokenAndHash(): Promise<{ token: URLToken, hash: Hash }> {
const token = getURLToken();
const h = await getHash(token);
return { token, hash: h };
}
export function getURLToken(): URLToken {
return base64url(randomBytes(tokenSize)) as URLToken;
}
export function compareToHash(s: string, hash: Hash): Promise<boolean> {
return compare(s, hash);
}

View File

@ -0,0 +1,11 @@
import env from "./env-vars";
import fetch from "node-fetch";
const secret = env.recaptchaSecret;
export async function validateReCaptcha(response: string): Promise<boolean> {
const initial = await fetch(
encodeURI(`https://www.google.com/recaptcha/api/siteverify?secret=${secret}&response=${response}`)
);
const answer = await initial.json();
return !!answer.success;
}

View File

@ -0,0 +1,3 @@
export function getTimestamp(): TimeStamp {
return Date.now() as TimeStamp;
}

View File

@ -0,0 +1,132 @@
import { v4 as uuidv4 } from "uuid";
import { insertLingdocsUser } from "../lib/couch-db";
import {
getHash,
getEmailTokenAndHash,
} from "../lib/password-utils";
import { getTimestamp } from "../lib/time-utils";
import { sendVerificationEmail } from "../lib/mail-utils";
import { outsideProviders } from "../middleware/setup-passport";
function getUUID(): UUID {
return uuidv4() as UUID;
}
export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean {
if (user.email && user.password) {
return true;
}
const providersPresent = outsideProviders.filter((provider) => !!user[provider]);
return providersPresent.length > 1;
}
export function getVerifiedEmail({ emails }: ProviderProfile): string | false {
return (
emails
&& emails.length
// @ts-ignore
&& emails[0].verified
) ? emails[0].value : false;
}
function getEmailFromGoogleProfile(profile: GoogleProfile): { email: string | undefined, verified: boolean } {
if (!profile.emails || profile.emails.length === 0) {
return { email: undefined, verified: false };
}
const em = profile.emails[0];
// @ts-ignore // but the verified value *is* there - if not it's still safe
const verified = !!em.verified
return {
email: em.value,
verified,
};
}
export async function createNewUser(input: {
strategy: "local",
email: string,
name: string,
passwordPlainText: string,
} | {
strategy: "github",
profile: GitHubProfile,
} | {
strategy: "google",
profile: GoogleProfile,
} | {
strategy: "twitter",
profile: TwitterProfile,
}): Promise<LingdocsUser> {
const userId = getUUID();
const now = getTimestamp();
if (input.strategy === "local") {
const email = await getEmailTokenAndHash();
const password = await getHash(input.passwordPlainText);
const newUser: LingdocsUser = {
_id: userId,
userId,
email: input.email,
emailVerified: email.hash,
name: input.name,
password,
level: "basic",
tests: [],
lastLogin: now,
lastActive: now,
};
const user = await insertLingdocsUser(newUser);
sendVerificationEmail(user, email.token).catch(console.error);
return user;
}
// GitHub || Twitter
if (input.strategy === "github" || input.strategy === "twitter") {
const newUser: LingdocsUser = {
_id: userId,
userId,
emailVerified: false,
name: input.profile.displayName,
[input.strategy]: input.profile,
level: "basic",
tests: [],
lastLogin: now,
lastActive: now,
};
const user = await insertLingdocsUser(newUser);
return user;
}
// Google
// TODO: Add e-mail in here
const { email, verified } = getEmailFromGoogleProfile(input.profile);
if (email && !verified) {
const em = await getEmailTokenAndHash();
const newUser: LingdocsUser = {
_id: userId,
userId,
email,
emailVerified: em.hash,
name: input.profile.displayName,
google: input.profile,
lastLogin: now,
tests: [],
lastActive: now,
level: "basic",
}
const user = await insertLingdocsUser(newUser);
sendVerificationEmail(user, em.token);
return user;
}
const newUser: LingdocsUser = {
_id: userId,
userId,
email,
emailVerified: verified,
name: input.profile.displayName,
google: input.profile,
lastLogin: now,
tests: [],
lastActive: now,
level: "basic",
}
const user = await insertLingdocsUser(newUser);
return user;
}

View File

@ -0,0 +1,160 @@
import { compare } from "bcryptjs";
import { PassportStatic } from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { OAuth2Strategy as GoogleStrategy } from "passport-google-oauth";
import { Strategy as GitHubStrategy } from "passport-github2";
import { Strategy as TwitterStrategy } from "passport-twitter";
import {
getLingdocsUser,
insertLingdocsUser,
updateLastActive,
updateLastLogin,
updateLingdocsUser,
} from "../lib/couch-db";
import {
createNewUser,
getVerifiedEmail,
} from "../lib/user-utils";
import env from "../lib/env-vars";
export const outsideProviders: ("github" | "google" | "twitter")[] = ["github", "google", "twitter"];
function setupPassport(passport: PassportStatic) {
passport.use(new LocalStrategy({
usernameField: "email",
},
async function(username, password, done) {
try {
const user = await getLingdocsUser("email", username);
if (!user) return done(null, false, { message: "email not found" });
if (!user.password) return done(null, false, { message: "user doesn't have password" });
compare(password, user.password, (err, result) => {
if (err) return done(err);
if (result === true) {
const u = updateLastLogin(user);
insertLingdocsUser(u).then((usr) => {
return done(null, usr);
}).catch(console.error);
} else {
return done(null, false, { message: "incorrect password" });
}
});
} catch (e) {
// error looking up user from database
done(e);
}
}
));
passport.use(new GoogleStrategy({
clientID: "1059009861653-ndh517ctpnats30qlgmihsrol4pdcj12.apps.googleusercontent.com",
clientSecret: env.googleClientSecret,
callbackURL: "https://account.lingdocs.com/google/callback",
passReqToCallback: true,
},
async function(req, accessToken, refreshToken, profileRaw, done) {
const { _json, _raw, ...profile } = profileRaw;
const gProfile = { ...profile, accessToken, refreshToken };
try {
if (req.isAuthenticated()) {
if (!req.user) done(new Error("user lost"));
const otherAccountWSameGoogle = await getLingdocsUser("googleId", profile.id);
if (otherAccountWSameGoogle) {
return done(null, otherAccountWSameGoogle);
}
const u = await updateLingdocsUser(req.user.userId, { google: gProfile });
if (!u.email) {
// if the user is adding a google account and doesn't have a previous email, add the google email
const email = getVerifiedEmail(gProfile)
if (email) {
const emailAdded = await updateLingdocsUser(req.user.userId, { email, emailVerified: true });
return done(null, emailAdded);
}
}
return done(null, u);
}
const user = await getLingdocsUser("googleId", profile.id);
if (user) return done (null, user);
const u = await createNewUser({ strategy: "google", profile: gProfile });
return done(null, u);
} catch (e) {
done(e);
}
}
));
passport.use(new TwitterStrategy({
consumerKey: "Y6fwSL0BUx7PO8edFgiZMqcLf",
consumerSecret: env.twitterClientSecret,
callbackURL: "https://account.lingdocs.com/twitter/callback",
passReqToCallback: true,
}, async function(req, token, tokenSecret, profileRaw, done) {
const { _json, _raw, ...profile } = profileRaw;
const twitterProfile = { ...profile, token, tokenSecret };
try {
if (req.isAuthenticated()) {
if (!req.user) done(new Error("user lost"));
const otherAccountWSameTwitter = await getLingdocsUser("twitterId", twitterProfile.id);
if (otherAccountWSameTwitter) {
return done(null, otherAccountWSameTwitter);
}
const u = await updateLingdocsUser(req.user.userId, { twitter: twitterProfile });
return done(null, u);
}
const user = await getLingdocsUser("twitterId", profile.id);
if (user) return done (null, user);
const u = await createNewUser({ strategy: "twitter", profile: twitterProfile });
return done(null, u);
} catch (e) {
done(e);
}
}));
passport.use(new GitHubStrategy({
clientID: "37abff09e9baf39aff0a",
clientSecret: env.githubClientSecret,
callbackURL: "https://account.lingdocs.com/github/callback",
passReqToCallback: true,
},
async function(req: any, accessToken: any, refreshToken: any, profileRaw: any, done: any) {
// not getting refresh token
const { _json, _raw, ...profile } = profileRaw;
const ghProfile: GitHubProfile = { ...profile, accessToken };
try {
if (req.isAuthenticated()) {
if (!req.user) done(new Error("user lost"));
const otherAccountWSameGithub = await getLingdocsUser("githubId", ghProfile.id);
if (otherAccountWSameGithub) {
return done(null, otherAccountWSameGithub);
}
const u = await updateLingdocsUser(req.user.userId, { github: ghProfile });
return done(null, u);
}
const user = await getLingdocsUser("githubId", ghProfile.id);
if (user) return done (null, user);
const u = await createNewUser({ strategy: "github", profile: ghProfile });
return done(null, u);
} catch (e) {
done(e);
}
}));
// @ts-ignore
passport.serializeUser((user: LingdocsUser, cb) => {
// @ts-ignore
cb(null, user.userId);
});
passport.deserializeUser(async (userId: UUID, cb) => {
try {
const user = await getLingdocsUser("userId", userId);
if (!user) {
cb(null, false);
return;
}
const newUser = await insertLingdocsUser(updateLastActive(user));
cb(null, newUser);
} catch (err) {
cb(err, null);
}
});
}
export default setupPassport;

View File

@ -0,0 +1,33 @@
import session from "express-session";
import { Express } from "express";
import redis from "redis";
import inProd from "../lib/inProd";
import env from "../lib/env-vars";
const FileStore = !inProd ? require("session-file-store")(session) : undefined;
const RedisStore = require("connect-redis")(session);
function setupSession(app: Express) {
app.use(
session({
secret: env.cookieSecret,
name: "__session",
resave: true,
saveUninitialized: false,
proxy: inProd,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7 * 30 * 6,
secure: inProd,
domain: inProd ? "lingdocs.com" : undefined,
// TODO: TRY TO SET TO TRUE
httpOnly: false,
},
store: inProd
? new RedisStore({ client: redis.createClient() })
: new FileStore({}),
})
);
}
export default setupSession;

View File

@ -0,0 +1,111 @@
import express, { Response } from "express";
import {
deleteLingdocsUser,
updateLingdocsUser,
} from "../lib/couch-db";
import {
getHash,
getURLToken,
compareToHash,
getEmailTokenAndHash,
} from "../lib/password-utils";
import {
sendVerificationEmail,
} from "../lib/mail-utils";
function sendResponse(res: Response, payload: APIResponse) {
return res.send(payload);
}
const apiRouter = express.Router();
// Guard all api with authentication
apiRouter.use((req, res, next) => {
if (req.isAuthenticated()) {
return next();
}
const r: APIResponse = { ok: false, error: "401 Unauthorized" };
return res.status(401).send(r);
});
/**
* gets the LingdocsUser object for the user signed in
*/
apiRouter.get("/user", (req, res, next) => {
if (!req.user) return next("user not found");
sendResponse(res, { ok: true, user: req.user });
});
/**
* receives a request to change or add a user's own password
*/
apiRouter.post("/password", async (req, res, next) => {
if (!req.user) return next("user not found");
const { oldPassword, password, passwordConfirmed } = req.body;
const addingFirstPassword = !req.user.password;
if (!oldPassword && !addingFirstPassword) {
return sendResponse(res, { ok: false, error: "Please enter your old password" });
}
if (!password) {
return sendResponse(res, { ok: false, error: "Please enter a new password" });
}
if (!req.user.email) {
return sendResponse(res, { ok: false, error: "You need to add an e-mail address first" });
}
if (req.user.password) {
const matchedOld = await compareToHash(oldPassword, req.user.password) || !req.user.password;
if (!matchedOld) {
return sendResponse(res, { ok: false, error: "Incorrect old password" });
}
}
if (password !== passwordConfirmed) {
return sendResponse(res, { ok: false, error: "New passwords do not match" });
}
if (password.length < 6) {
return sendResponse(res, {ok: false, error: "New password too short" });
}
const hash = await getHash(password);
await updateLingdocsUser(req.user.userId, { password: hash });
sendResponse(res, { ok: true, message: addingFirstPassword ? "Password added" : "Password changed" });
});
/**
* receives a request to generate a new e-mail verification token and send e-mail
*/
apiRouter.put("/email-verification", async (req, res, next) => {
try {
if (!req.user) throw new Error("user not found");
const { token, hash } = await getEmailTokenAndHash();
const u = await updateLingdocsUser(req.user.userId, { emailVerified: hash });
sendVerificationEmail(u, token).then(() => {
sendResponse(res, { ok: true, message: "e-mail verification sent" });
}).catch((err) => {
sendResponse(res, { ok: false, error: err });
});
} catch (e) {
next(e);
}
});
/**
* deletes a users own account
*/
apiRouter.delete("/user", async (req, res, next) => {
try {
if (!req.user) throw new Error("user not found");
await deleteLingdocsUser(req.user.userId);
sendResponse(res, { ok: true, message: "user delted" });
} catch (e) {
next(e);
}
})
/**
* signs out the user signed in
*/
apiRouter.post("/sign-out" , (req, res) => {
req.logOut();
sendResponse(res, { ok: true, message: "signed out" });
});
export default apiRouter;

View File

@ -0,0 +1,249 @@
import { Router } from "express";
import { PassportStatic } from "passport";
import {
getLingdocsUser,
updateLingdocsUser,
} from "../lib/couch-db";
import { createNewUser, canRemoveOneOutsideProvider } from "../lib/user-utils";
import {
getHash,
getURLToken,
compareToHash,
getEmailTokenAndHash,
} from "../lib/password-utils";
import { validateReCaptcha } from "../lib/recaptcha";
import {
getTimestamp,
} from "../lib/time-utils";
import {
sendPasswordResetEmail,
sendVerificationEmail,
} from "../lib/mail-utils";
import { outsideProviders } from "../middleware/setup-passport";
import inProd from "../lib/inProd";
const authRouter = (passport: PassportStatic) => {
const router = Router();
router.get("/", (req, res) => {
if (req.isAuthenticated()) {
return res.redirect("/user");
}
res.render("login", { recaptcha: "", inProd });
});
router.get("/user", (req, res) => {
if (!req.isAuthenticated()) {
return res.redirect("/");
}
res.render("user", { user: req.user, error: null, removeProviderOption: canRemoveOneOutsideProvider(req.user) });
});
router.post("/user", async (req, res, next) => {
const page = "user";
if (!req.user) return next("user not found");
const name = req.body.name as string;
const email = req.body.email as string;
if (email !== req.user.email) {
if (name !== req.user.name) await updateLingdocsUser(req.user.userId, { name });
const withSameEmail = (email !== "") && await getLingdocsUser("email", email);
if (withSameEmail) {
return res.render(page, { user: { ...req.user, email }, error: "email taken", removeProviderOption: canRemoveOneOutsideProvider(req.user) });
}
// TODO: ABSTRACT THE PROCESS OF GETTING A NEW EMAIL TOKEN AND MAILING!
const { token, hash } = await getEmailTokenAndHash();
const updated = await updateLingdocsUser(req.user.userId, {
name,
email,
emailVerified: hash,
});
sendVerificationEmail(updated, token).catch(console.error);
return res.render(page, { user: updated, error: null, removeProviderOption: canRemoveOneOutsideProvider(req.user) });
}
const updated = await updateLingdocsUser(req.user.userId, { name });
// need to do this because sometimes the update seems slow?
return res.render(page, { user: updated, error: null, removeProviderOption: canRemoveOneOutsideProvider(req.user) });
});
router.post("/login", async (req, res, next) => {
if (inProd) {
const success = await validateReCaptcha(req.body.token);
if (!success) {
return res.render("login", { recaptcha: "fail", inProd });
}
}
passport.authenticate("local", (err, user: LingdocsUser | undefined, info) => {
if (err) throw err;
if (!user && info.message === "email not found") {
return res.send({ ok: false, newSignup: true });
}
if (!user) res.send({
ok: false,
message: "Incorrect password",
});
else {
req.logIn(user, (err) => {
if (err) return next(err);
res.send({ ok: true, user });
});
}
})(req, res, next);
});
router.get(
"/google",
passport.authenticate("google", {
// @ts-ignore - needed for getting refreshToken]
accessType: "offline",
scope: ["openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"],
})
);
router.get('/github', passport.authenticate("github", {
scope: ["read:user", "user:email"],
}));
router.get('/twitter', passport.authenticate("twitter"));
// all callback and remove routes/functions are the same for each provider
outsideProviders.forEach((provider) => {
router.get(
`/${provider}/callback`,
passport.authenticate(provider, { successRedirect: '/user', failureRedirect: '/' }),
);
router.post(`/${provider}/remove`, async (req, res, next) => {
try {
if (!req.user) return next("user not found");
if (!canRemoveOneOutsideProvider(req.user)) return res.redirect("/user");
await updateLingdocsUser(
req.user.userId,
// @ts-ignore - shouldn't need this
{ [provider]: undefined }
);
return res.redirect("/user");
} catch(e) {
next(e);
}
});
});
router.post("/register", async (req, res, next) => {
try {
const { email, password, name } = req.body;
const existingUser = await getLingdocsUser("email", email);
if (existingUser) return res.send("Tser Already Exists");
const user = await createNewUser({ strategy: "local", email, passwordPlainText: password, name });
req.logIn(user, (err) => {
if (err) return next(err);
return res.send({ ok: true, user });
});
} catch(e) {
return next(e);
}
});
router.get("/email-verification/:uuid/:token", async (req, res, next) => {
const page = "email-verification";
const { uuid, token } = req.params;
try {
const user = await getLingdocsUser("userId", uuid);
if (!user) {
return res.render(page, { ok: false, message: "not found" });
}
if (user.emailVerified === true) {
return res.render(page, { ok: true, message: "already verified" });
}
if (user.emailVerified === false) {
return res.render(page, { ok: false, message: "invalid token" });
}
const result = await compareToHash(token, user.emailVerified);
if (result === true) {
await updateLingdocsUser(user.userId, { emailVerified: true });
return res.render(page, { ok: true, message: "verified" });
} else {
res.render(page, { ok: false, message: "invalid token" });
}
} catch (e) {
return res.render(page, { ok: false, message: "error verifying e-mail" });
}
});
router.get("/password-reset", (req, res) => {
const email = req.query.email || ""
res.render("password-reset-request", { email, done: false });
});
router.post("/password-reset", async(req, res, next) => {
const page = "password-reset-request";
const email = req.body.email || "";
try {
const user = await getLingdocsUser("email", email);
if (!user) {
console.log("password reset attempt on non-existant e-mail");
return res.render(page, { email, done: false });
}
if (user.emailVerified !== true) {
console.log("password reset attempt on an unverified e-mail");
return res.render(page, { email, done: false });
}
// TODO: SHOULD THIS BE NOT ALLOWED?
// TODO: PROPER ERROR MESSAGING IN ALL THIS!!
if (!user.password) {
console.log("password reset attempt on an account without a password");
return res.render(page, { email, done: false });
}
const token = getURLToken();
const tokenHash = await getHash(token);
const u = await updateLingdocsUser(
user.userId,
{ passwordReset: { tokenHash, requestedOn: getTimestamp() }},
);
await sendPasswordResetEmail(u, token);
return res.render(page, { email, done: true });
} catch (e) {
next(e);
}
});
router.get("/password-reset/:uuid/:token", async (req, res, next) => {
const page = "password-reset";
const { uuid, token } = req.params;
const user = await getLingdocsUser("userId", uuid);
if (!user || !user.passwordReset) {
return res.render(page, { ok: false, user: null, message: "not found" });
}
// TODO: ALSO CHECK IF THE RESET IS FRESH ENOUGH
const result = await compareToHash(token, user.passwordReset.tokenHash);
if (result === true) {
return res.render(page, { ok: true, user, token, message: "" });
} else {
res.render(page, { ok: false, user: null, message: "invalid token" });
}
});
router.post("/password-reset/:uuid/:token", async (req, res, next) => {
const page = "password-reset";
const { uuid, token } = req.params;
const { password, passwordConfirmed } = req.body;
const user = await getLingdocsUser("userId", uuid);
if (!user || !user.passwordReset) {
return res.render(page, { ok: false, message: "not found" });
}
const result = await compareToHash(token, user.passwordReset.tokenHash);
if (!result) return res.render(page, { ok: false, user: null, message: "invalid token" });
const passwordsMatch = password === passwordConfirmed;
if (passwordsMatch) {
const hash = await getHash(password);
await updateLingdocsUser(user.userId, { password: hash });
return res.render(page, { ok: true, user, message: "password reset" });
} else {
return res.render(page, { ok: false, user, message: "passwords don't match" });
}
});
router.post("/sign-out", (req, res) => {
req.logOut();
res.redirect("/");
});
return router;
}
export default authRouter;

37
account/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
type Hash = string & { __brand: "Hashed String" };
type UUID = string & { __brand: "Random Unique UID" };
type TimeStamp = number & { __brand: "UNIX Timestamp in milliseconds" };
type UserDbPassword = string & { __brand: "password for an individual user couchdb" };
type URLToken = string & { __brand: "Base 64 URL Token" };
type EmailVerified = true | Hash | false;
type ActionComplete = { ok: true, message: string };
type ActionError = { ok: false, error: string };
type APIResponse = ActionComplete | ActionError | { ok: true, user: LingdocsUser };
type WoutRJ<T> = Omit<T, "_raw"|"_json">;
type GoogleProfile = WoutRJ<import("passport-google-oauth").Profile> & { refreshToken: string, accessToken: string };
type GitHubProfile = WoutRJ<import("passport-github2").Profile> & { accessToken: string };
type TwitterProfile = WoutRJ<import("passport-twitter").Profile> & { token: string, tokenSecret: string };
type ProviderProfile = GoogleProfile | GitHubProfile | TwitterProfile;
type UserLevel = "basic" | "student" | "editor";
// TODO: TYPE GUARDING SO WE NEVER HAVE A USER WITH NO Id or Password
type LingdocsUser = {
userId: UUID,
password?: Hash,
name: string,
email?: string,
emailVerified: EmailVerified,
github?: GitHubProfile,
google?: GoogleProfile,
twitter?: TwitterProfile,
passwordReset?: {
tokenHash: Hash,
requestedOn: TimeStamp,
},
tests: [],
lastLogin: TimeStamp,
lastActive: TimeStamp,
} & ({ level: "basic"} | { level: "student" | "editor", userDbPassword: UserDbPassword })
& import("nano").MaybeDocument;

72
account/tsconfig.json Normal file
View File

@ -0,0 +1,72 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>E-mail Verification · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="text-center">
<div class="container">
<h2 class="mt-4 mb-4">LingDocs Email Verification</h2>
<% if (message === "not found") { %>
<p>Invalid Verification Link 👎</p>
<% } %>
<% if (message === "already verified") { %>
<p>Your e-mail has already been verified. 👍</p>
<% } %>
<% if (message === "verified") { %>
<p>Thank you. Your e-mail has been verified. 👍</p>
<% } %>
<!-- TODO: DYNAMIC DOMAIN HERE! -->
<% if (message === "invalid token") { %>
<p>That's an older or expired verification code. Please check for the most recent verification email or request another from the <a href="https://account.lingdocs.com/user">account page</a>.</p>
<% } %>
<% if (message === "error verifying e-mail") { %>
<p>Sorry! There was an error verifying your e-mail. Please try again later.</p>
<% } %>
</div>
</body>
</html>

131
account/views/login.ejs Normal file
View File

@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>Signin · LingDocs</title>
<link rel="canonical" href="https://account.lingdocs.com">
<link href="/css/bootstrap.min.css" rel="stylesheet">
<style>
.bd-placeholder-img {
border-radius: 30px;
}
</style>
<!-- Custom styles for this template -->
<link href="/css/signin.css" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
<% if (inProd) { %>
<script src="https://www.google.com/recaptcha/api.js?render=6LcVjAUcAAAAAD0jviyYjUjuvjTMgdwx4H6kNoHH" async defer></script>
<% } %>
</head>
<body class="text-center">
<main class="form-signin">
<form id="signin-form">
<img class="mb-4" src="/img/lingdocs-logo.png" alt="" height="60" width="60">
<h1 class="h3 mb-4 fw-normal">Sign in to LingDocs</h1>
<p class="small mb-2">New? Enter an e-mail and password to sign up.</p>
<div class="form-floating mt-3">
<input type="email" required class="form-control" id="emailInput" placeholder="name@example.com">
<label for="floatingInput">Email address</label>
</div>
<div class="form-floating" id="name-form" style="display:none">
<input type="text" class="form-control" id="nameInput">
<label for="floatingPassword">Name</label>
</div>
<div class="form-floating">
<input type="password" required minlength="6" class="form-control" id="passwordInput" placeholder="Password">
<label for="floatingPassword">Password</label>
</div>
<div class="small text-left">
<a href="/password-reset" tabindex="-1">Forgot Password?</a>
</div>
<div id="response" style="display: none;" class="alert alert-success" role="alert">
</div>
<button class="g-recaptcha mt-3 w-100 btn btn-lg btn-primary" type="submit" id="sign-in-button">Sign In</button>
<button style="display: none;" class="mt-3 w-100 btn btn-lg btn-secondary" type="button" id="cancel-sign-up-button">Cancel Sign Up</button>
<% if (inProd) { %>
<div
class="g-recaptcha"
id="recaptcha-container"
data-sitekey="6LcVjAUcAAAAAD0jviyYjUjuvjTMgdwx4H6kNoHH"
data-callback="captchaCallback"
data-size="invisible">
</div>
<% } %>
</form>
<p class="mt-3">or</p>
<a href="/google" class="mt-1 w-100 btn btn-lg btn-secondary" role="button"><i class="fab fa-google mr-2"></i> Sign In With Google</a>
<a href="/twitter" class="mt-3 w-100 btn btn-lg btn-secondary" role="button"><i class="fab fa-twitter mr-2"></i> Sign In With Twitter</a>
<a href="/github" class="mt-3 w-100 btn btn-lg btn-secondary" role="button"><i class="fab fa-github mr-2"></i> Sign In With GitHub</a>
<p class="mt-5 text-muted">&copy; 2021 <a href="https://www.lingdocs.com/">LingDocs.com</a></p>
</main>
</body>
<% if (recaptcha === "fail") { %>
<script>
alert("reCaptcha failed");
</script>
<% } %>
<script>
const form = document.getElementById("signin-form");
const signInButton = document.getElementById("sign-in-button");
const cancelSignUpButton = document.getElementById("cancel-sign-up-button");
const nameForm = document.getElementById("name-form");
const response = document.getElementById("response");
cancelSignUpButton.addEventListener("click", (e) => {
e.preventDefault();
nameForm.style = "display: none;";
nameForm.innerHTML = "";
response.innerHTML = "";
response.style = "display: none;";
cancelSignUpButton.style = "display: none;";
signInButton.innerHTML = "Sign In";
});
form.addEventListener("submit", handleSubmit, true);
function captchaCallback(token) {
const email = document.getElementById("emailInput").value.trim();
const password = document.getElementById("passwordInput").value.trim();
const name = document.getElementById("nameInput").value.trim();
fetch(name ? "/register" : "/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name, token }),
}).then((res) => res.json())
.then((res) => {
if (res.ok) {
location.reload();
}
if (!res.ok) {
if (res.newSignup) {
nameForm.style = "";
response.className = "alert alert-info mt-3";
response.innerText = "Enter your name to finish Signup";
response.style = "";
signInButton.innerHTML = "Sign Up";
cancelSignUpButton.style = "";
} else {
response.className = "alert alert-warning mt-3";
response.innerText = res.message;
response.style = "";
}
}
});
}
<% if (inProd) { %>
function handleSubmit(e) {
e.preventDefault();
grecaptcha.execute();
}
<% } else { %>
function handleSubmit(e) {
e.preventDefault();
captchaCallback("");
}
<% } %>
</script>
</html>

View File

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>Password Reset · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<h2 class="mt-4 mb-4 text-center">Reset your LingDocs Password</h2>
<% if (!done) { %>
<form method="POST" class="mb-4" style="max-width: 500px; margin: 0 auto;">
<div>
<label for="email" class="form-label">Email:</label>
<input required name="email" type="email" class="form-control" id="email" value="<%= email %>">
</div>
<button type="submit" class="btn btn-primary mt-4">Reset Password</button>
</form>
<% } else { %>
<p>If <strong><%= email %></strong> is a <em>verified</em> e-mail in our system, we sent a password reset email.</p>
<% } %>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>Password Reset · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<h2 class="mt-4 mb-4 text-center">Reset your LingDocs Password</h2>
<% if (user && message === "password reset") { %>
<p>Ok, <%= user.name %>, the password for your account with <strong><%= user.email %></strong> has been reset. 👍</p>
<p><a href="/">Login</a></p>
<% } else if (user) { %>
<form method="POST" class="mb-4" style="max-width: 500px; margin: 0 auto;">
<div>
<label for="password" class="form-label">New Password:</label>
<input required minlength="6" name="password" type="password" class="form-control" id="password" value="">
</div>
<div class="mt-2">
<label for="passwordConfirmed" class="form-label">Confirm New Password:</label>
<input required minlength="6" name="passwordConfirmed" type="password" class="form-control" id="passwordConfirmed" value="">
</div>
<% if (message === "passwords don't match") { %>
<div class="alert alert-warning mt-3" role="alert">
Passwords don't match
</div>
<% } %>
<button type="submit" class="btn btn-primary mt-4">Reset Password</button>
</form>
<% } else { %>
<p>Invalid Password Reset Link</p>
<% } %>
</div>
</div>
</body>
</html>

245
account/views/user.ejs Normal file
View File

@ -0,0 +1,245 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>Account · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
</head>
<body>
<div class="container" style="max-width: 400px;">
<h2 class="mt-4 mb-4 text-center">LingDocs Account</h2>
<h4>Profile:</h4>
<form method="POST" class="mb-4">
<div>
<label for="email" class="form-label">Email:</label>
<% if (user.email) { %>
<input required name="email" type="email" class="form-control" id="email" value="<%= user.email %>" />
<% } else { %>
<input name="email" type="email" class="form-control" id="email" placeholder="add an e-mail here" />
<% } %>
</div>
<div>
<% if (error === "email taken") { %>
<div class="alert alert-danger mt-3" role="alert">
Sorry, that e-mail is already taken 🙄
</div>
<% } else if (typeof user.emailVerified === "string") { %>
<div class="alert alert-info mt-3" role="alert">
<div class="d-flex flex-row justify-content-between align-items-center">
<div>Check your e-mail to verify</div>
<div id="resend-button-container">
<button type="button" class="btn btn-light btn-sm" id="resend-button">Resend</button>
</div>
</div>
</div>
<% } %>
</div>
<div class="mb-4 mt-3">
<label for="name" class="form-label">Name:</label>
<input required name="name" type="text" class="form-control" id="name" value="<%= user.name %>" />
</div>
<div>
<button type="submit" class="btn btn-primary">Update Profile</button>
</div>
<% if (user.email) { %>
<h4 class="mt-3">Password:</h4>
<% } %>
<div id="password-change-form" style="display: none;">
<% if (user.password) { %>
<div id="old-password-form">
<% } else { %>
<div id="old-password-form" style="display: none;">
<% } %>
<div class="mb-3 mt-3">
<label for="oldPassword" class="form-label">Old Password:</label>
<input type="password" class="form-control" id="oldPassword">
</div>
<div class="small text-left" id="forgot-password">
<a href="" tabindex="-1">Forgot Old Password?</a>
</div>
</div>
<div class="mb-3 mt-3">
<label for="password" class="form-label">New Password:</label>
<input type="password" class="form-control" id="password" />
</div>
<div class="mb-4 mt-3">
<label for="confirmPassword" class="form-label">Confirm New Password:</label>
<input type="password" class="form-control" id="confirmPassword">
</div>
</div>
<div id="password-change-result" style="display: none;" class="alert alert-info mt-3 mb-4" role="alert">
</div>
<% if (user.email) { %>
<div class="d-flex flex-row justify-content-between mt-4 mb-3">
<button type="button" id="password-change-button" class="btn btn-secondary">
<% if (user.password) { %>
Change
<% } else { %>
Add
<% } %>
Password
</button>
<button type="button" style="display: none;" id="cancel-password-change-button" class="btn btn-light">Cancel</button>
</div>
<% } %>
</form>
<h4 class="mb-2">Linked Accounts:</h4>
<div class="mb-4">
<% if (user.google) { %>
<!-- TODO: MAKE THIS EMAIL THING SAFER! -->
<div class="my-2 w-100 btn btn-secondary"><i class="fab fa-google mr-2"></i> Linked to Google · <%= user.google.emails[0].value %></div>
<form action="/google/remove" method="POST">
<% if (removeProviderOption) { %>
<button type="submit" class="btn btn-sm">Unlink from Google</button>
<% } %>
</form>
<% } %>
<% if (user.twitter) { %>
<div class="my-2 w-100 btn btn-secondary"><i class="fab fa-twitter mr-2"></i> Linked to Twitter · @<%= user.twitter.username %></div>
<form action="/twitter/remove" method="POST">
<% if (removeProviderOption) { %>
<button type="submit" class="btn btn-sm">Unlink from twitter</button>
<% } %>
</form>
<% } %>
<% if (user.github) { %>
<div class="my-2 w-100 btn btn-secondary"><i class="fab fa-github mr-2"></i> Linked to GitHub · <%= user.github.username %></div>
<form action="/github/remove" method="POST">
<% if (removeProviderOption) { %>
<button type="submit" class="btn btn-sm">Unlink from GitHub</button>
<% } %>
</form>
<% } %>
<% if (!user.google) { %>
<a href="/google" class="my-2 w-100 btn btn-outline-secondary" role="button"><i class="fab fa-google mr-2"></i> Link to Google</a>
<% } %>
<% if (!user.twitter) { %>
<a href="/twitter" class="my-2 w-100 btn btn-outline-secondary" role="button"><i class="fab fa-twitter mr-2"></i> Link to Twitter</a>
<% } %>
<% if (!user.github) { %>
<a href="/github" class="my-2 w-100 btn btn-outline-secondary" role="button"><i class="fab fa-github mr-2"></i> Link to GitHub</a>
<% } %>
</div>
<hr />
<p class="text-muted small">Last Login: <%= new Date(user.lastLogin).toUTCString() %></p>
<form action="/sign-out" method="POST">
<button type="submit" class="btn btn-outline-secondary"><i class="fas fa-sign-out-alt mr-2"></i> Sign Out of LingDocs</button>
</form>
<hr />
<div class="mb-4">
<button onclick="handleDelete()" type="button" class="btn btn-outline-danger my-4"><i class="fas fa-trash-alt mr-2"></i> Delete Account</button>
</div>
<p class="text-muted text-center"><a href="https://www.lingdocs.com/">LingDocs.com</a></p>
</div>
</body>
<script>
function clearPasswordForm() {
document.getElementById("oldPassword").value = "";
document.getElementById("password").value = "";
document.getElementById("confirmPassword").value = "";
}
function handleDelete() {
const answer = confirm("Are you sure you want to delete your account?");
if (answer) {
fetch("/api/user", { method: "DELETE" }).then((res) => res.json()).then((res) => {
if (res.ok) {
window.location = "/";
}
}).catch((err) => {
alert("Error deleting account - check your connection");
console.error(err);
});
}
}
window.addEventListener('keydown', (e) => {
// prevent an enter from submitting the form
if (e.keyCode === 13) {
e.preventDefault();
}
});
const passwordChangeForm = document.getElementById("password-change-form");
const passwordChangeButton = document.getElementById("password-change-button");
const passwordChangeResult = document.getElementById("password-change-result");
const forgotPassword = document.getElementById("forgot-password");
if (forgotPassword) {
forgotPassword.addEventListener("click", (e) => {
event.preventDefault();
const email = document.getElementById("email").value;
window.location = encodeURI("/password-reset?email=" + email);
});
}
const cancelPasswordChangeButton = document.getElementById("cancel-password-change-button");
if (passwordChangeButton) {
passwordChangeButton.addEventListener("click", (e) => {
e.preventDefault();
const formClosed = window.getComputedStyle(passwordChangeForm).getPropertyValue("display") === "none";
if (formClosed) {
clearPasswordForm();
passwordChangeResult.innerHTML = "";
passwordChangeResult.style = "display: none;";
passwordChangeForm.style = "";
cancelPasswordChangeButton.style = "";
} else {
const oldPassword = document.getElementById("oldPassword").value;
const password = document.getElementById("password").value;
const passwordConfirmed = document.getElementById("confirmPassword").value;
fetch("/api/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oldPassword, password, passwordConfirmed }),
}).then((res) => res.json()).then((res) => {
if (res.ok) {
passwordChangeResult.innerHTML = res.message;
passwordChangeResult.style = "";
passwordChangeForm.style = "display: none;";
cancelPasswordChangeButton.style = "display: none;";
if (res.message === "Password added") {
document.getElementById("old-password-form").style = "";
passwordChangeButton.innerHTML = "Change Password";
}
} else {
passwordChangeResult.innerHTML = res.error;
passwordChangeResult.style = "";
}
}).catch(console.error);
}
});
}
if (cancelPasswordChangeButton) {
cancelPasswordChangeButton.addEventListener("click", (e) => {
e.preventDefault();
passwordChangeForm.style="display: none;";
cancelPasswordChangeButton.style="display: none;";
passwordChangeResult.innerHTML = "";
passwordChangeResult.style = "display: none;";
clearPasswordForm();
});
}
const resendButton = document.getElementById("resend-button");
if (resendButton) {
resendButton.addEventListener("click", handleResendVerification, true);
}
function handleResendVerification(e) {
e.preventDefault();
const bc = document.getElementById("resend-button-container");
bc.innerHTML = "Sending …";
fetch("/api/email-verification", { method: "PUT" }).then((res) => res.json())
.then((res) => {
console.log(res);
if (res.ok) {
bc.innerHTML = "Sent ✔";
} else {
bc.innerHTML = "Send Failed 😔";
}
}).catch((error) => {
console.error(error);
bs.innerHTML = "Send Failed 😔";
});
}
</script>
</html>