diff --git a/package-lock.json b/package-lock.json index bd5ef15..b993d74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,11 @@ "@nestjs/serve-static": "^2.2.2", "@nestjs/throttler": "^2.0.1", "bcrypt": "^5.0.1", + "body-parser": "^1.19.2", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", + "connect-redis": "^6.1.3", + "cookie-parser": "^1.4.6", "cropperjs": "^1.5.12", "dotenv": "^16.0.0", "express-session": "^1.17.2", @@ -30,6 +33,7 @@ "otplib": "^12.0.1", "pug": "^3.0.2", "qrcode": "^1.5.0", + "redis": "^3.1.2", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -44,6 +48,8 @@ "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", "@types/bcrypt": "^5.0.0", + "@types/connect-redis": "^0.0.18", + "@types/csurf": "^1.11.2", "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "27.4.1", @@ -3129,12 +3135,33 @@ "@types/node": "*" } }, + "node_modules/@types/connect-redis": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.18.tgz", + "integrity": "sha512-iGygGbXgPIr94DEAuoluWhzre3c2/ew5NPlbW9IWvwCTXMM1YCmc7M9wpXMkYqt6kB9aO1sjZnmDzyugUu+2vQ==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/express-session": "*", + "@types/ioredis": "*", + "@types/redis": "^2.8.0" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/csurf": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.2.tgz", + "integrity": "sha512-9bc98EnwmC1S0aSJiA8rWwXtgXtXHHOQOsGHptImxFgqm6CeH+mIOunHRg6+/eg2tlmDMX3tY7XrWxo2M/nUNQ==", + "dev": true, + "dependencies": { + "@types/express-serve-static-core": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -3202,6 +3229,15 @@ "@types/node": "*" } }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -3326,6 +3362,15 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, + "node_modules/@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/serve-static": { "version": "1.13.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", @@ -4986,6 +5031,14 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/connect-redis": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz", + "integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==", + "engines": { + "node": ">=12" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -5047,6 +5100,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -9982,6 +10055,56 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/redis/node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -14606,12 +14729,33 @@ "@types/node": "*" } }, + "@types/connect-redis": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@types/connect-redis/-/connect-redis-0.0.18.tgz", + "integrity": "sha512-iGygGbXgPIr94DEAuoluWhzre3c2/ew5NPlbW9IWvwCTXMM1YCmc7M9wpXMkYqt6kB9aO1sjZnmDzyugUu+2vQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/express-session": "*", + "@types/ioredis": "*", + "@types/redis": "^2.8.0" + } + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "@types/csurf": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.2.tgz", + "integrity": "sha512-9bc98EnwmC1S0aSJiA8rWwXtgXtXHHOQOsGHptImxFgqm6CeH+mIOunHRg6+/eg2tlmDMX3tY7XrWxo2M/nUNQ==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*" + } + }, "@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -14679,6 +14823,15 @@ "@types/node": "*" } }, + "@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -14803,6 +14956,15 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, + "@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/serve-static": { "version": "1.13.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", @@ -16077,6 +16239,11 @@ } } }, + "connect-redis": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz", + "integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==" + }, "consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -16131,6 +16298,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -19903,6 +20086,42 @@ "resolve": "^1.1.6" } }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "dependencies": { + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" + } + } + }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", diff --git a/package.json b/package.json index 1870ef5..ebe360c 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,11 @@ "@nestjs/serve-static": "^2.2.2", "@nestjs/throttler": "^2.0.1", "bcrypt": "^5.0.1", + "body-parser": "^1.19.2", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", + "connect-redis": "^6.1.3", + "cookie-parser": "^1.4.6", "cropperjs": "^1.5.12", "dotenv": "^16.0.0", "express-session": "^1.17.2", @@ -44,6 +47,7 @@ "otplib": "^12.0.1", "pug": "^3.0.2", "qrcode": "^1.5.0", + "redis": "^3.1.2", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -58,6 +62,8 @@ "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", "@types/bcrypt": "^5.0.0", + "@types/connect-redis": "^0.0.18", + "@types/csurf": "^1.11.2", "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "27.4.1", diff --git a/public/image/application.png b/public/image/application.png new file mode 100644 index 0000000..6ad5ce6 Binary files /dev/null and b/public/image/application.png differ diff --git a/src/fe/scss/_authorize.scss b/src/fe/scss/_authorize.scss index 3685d51..013d5fc 100644 --- a/src/fe/scss/_authorize.scss +++ b/src/fe/scss/_authorize.scss @@ -3,7 +3,7 @@ flex-direction: column; justify-content: space-between; align-items: center; - background-color: #005b74; + background-color: var(--main-darker); margin: 2rem -4rem; padding: 2rem 1rem; box-shadow: inset 0px 6px 62px -14px rgba(0, 0, 0, 0.45); @@ -22,7 +22,12 @@ width: 120px; height: 120px; flex-shrink: 0; - background-color: #b5b5b5; + background-color: var(--main-darker); + + img { + max-width: 100%; + max-height: 100%; + } } &-content { diff --git a/src/fe/scss/_block.scss b/src/fe/scss/_block.scss index 0f7a1c2..3767bbc 100644 --- a/src/fe/scss/_block.scss +++ b/src/fe/scss/_block.scss @@ -8,8 +8,8 @@ .center-box { max-width: 800px; - background-color: #2e6b81; - color: #fff; + background-color: var(--main); + color: var(--text-color); margin: 2rem auto; padding: 4rem; position: relative; @@ -22,8 +22,8 @@ } &-addon { - color: #fff; - background-color: #042b3a; + color: var(--text-color); + background-color: var(--main-dark); overflow: hidden; max-width: 600px; margin: 0 auto 2rem auto; diff --git a/src/fe/scss/_button.scss b/src/fe/scss/_button.scss index b227f66..7de3031 100644 --- a/src/fe/scss/_button.scss +++ b/src/fe/scss/_button.scss @@ -5,7 +5,7 @@ border: none; border-radius: 4px; cursor: pointer; - outline: 0px solid #00c0ff8a; + outline: 0px solid var(--focus-outline); background-color: var(--btn-background); color: var(--btn-color); @@ -14,18 +14,23 @@ &-link { font-size: 1rem; - color: #fff; + color: var(--text-color); + text-shadow: var(--text-shadow) 1px 1px 2px; padding: 12px; - } - text-shadow: none; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } &:hover { background-color: var(--btn-background-hover); } &:focus { - outline: 4px solid #00c0ff8a; + outline: 4px solid var(--focus-outline); } &-primary { diff --git a/src/fe/scss/_colors.scss b/src/fe/scss/_colors.scss new file mode 100644 index 0000000..675d996 --- /dev/null +++ b/src/fe/scss/_colors.scss @@ -0,0 +1,18 @@ +:root { + --black: #000; + --white: #fff; + + --text-color: var(--white); + --text-shadow: var(--black); + + --form-border: #707070; + --form-border-hover: #5c5c5c; + + --main-background: #314550; + --main: #2e6b81; + --main-light: #519eb9; + --main-darker: #005b74; + --main-dark: #042b3a; + + --focus-outline: #00c0ff8a; +} diff --git a/src/fe/scss/_form.scss b/src/fe/scss/_form.scss index e1f1c0f..e0a3df3 100644 --- a/src/fe/scss/_form.scss +++ b/src/fe/scss/_form.scss @@ -19,16 +19,16 @@ input.form-control { padding: 8px; font-size: 1rem; border-radius: 4px; - border: 1px solid #707070; + border: 1px solid var(--form-border); transition: outline 0.15s linear; &:focus { - outline: 4px solid #00c0ff8a; + outline: 4px solid var(--focus-outline); } &:hover, &:focus { - border: 1px solid #5c5c5c; + border: 1px solid var(--form-border-hover); } } diff --git a/src/fe/scss/_modal.scss b/src/fe/scss/_modal.scss index b014b72..16193d4 100644 --- a/src/fe/scss/_modal.scss +++ b/src/fe/scss/_modal.scss @@ -12,7 +12,7 @@ &__content { max-width: 800px; - background-color: #2e6b81; + background-color: var(--main); box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45); margin: 4rem auto 0 auto; } @@ -36,7 +36,7 @@ display: flex; flex-direction: row; justify-content: flex-end; - border-top: 1px solid #005b74; + border-top: 1px solid var(--main-darker); gap: 1rem; } @@ -46,7 +46,7 @@ padding: 1rem; align-items: center; justify-content: space-between; - border-bottom: 1px solid #005b74; + border-bottom: 1px solid var(--main-darker); &-button .btn { min-width: initial; diff --git a/src/fe/scss/_settings.scss b/src/fe/scss/_settings.scss index 59c0a68..dd26179 100644 --- a/src/fe/scss/_settings.scss +++ b/src/fe/scss/_settings.scss @@ -8,7 +8,7 @@ &__nav { padding: 2rem 0rem; - background-color: #005b74; + background-color: var(--main-darker); ul { display: flex; @@ -31,13 +31,13 @@ text-decoration: underline; } - &.active, - &:focus-visible { - border-right-color: #519eb9; + &.active { + border-right-color: var(--main-light); + font-weight: bold; } &:focus { - outline: 4px solid #00c0ff8a; + outline: 4px solid var(--focus-outline); } } } @@ -56,6 +56,7 @@ height: 120px; width: 120px; flex: 0 0 120px; + background-color: var(--main-darker); margin: auto; } } @@ -78,7 +79,64 @@ #crop-result { max-width: 100%; max-height: 50vh; - background-color: #005b74; + background-color: var(--main-darker); + } + } + + .alert { + margin-bottom: 1rem; + } + + .authorization { + &__client { + display: flex; + flex-direction: row; + min-height: 120px; + margin-bottom: 1rem; + + &-image { + width: 120px; + height: 120px; + flex-shrink: 0; + background-color: var(--main-darker); + + img { + max-width: 100%; + max-height: 100%; + } + } + + &-content { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 8px; + } + + &-title { + font-size: 1.5rem; + } + + &-urls { + margin-top: auto; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + } + + &-url { + margin-top: 2px; + } + + &-description { + margin-bottom: 4px; + } + + @include break-on(xs, down) { + flex-direction: column; + align-items: center; + gap: 1rem; + } } } } diff --git a/src/fe/scss/index.scss b/src/fe/scss/index.scss index 97bed28..638ab9e 100644 --- a/src/fe/scss/index.scss +++ b/src/fe/scss/index.scss @@ -1,4 +1,5 @@ @import 'breakpoint'; +@import 'colors'; @import 'block'; @import 'form'; @import 'button'; @@ -23,12 +24,12 @@ body { body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - background-color: #314550; - text-shadow: black 1px 1px 2px; + background-color: var(--main-background); + text-shadow: var(--text-shadow) 1px 1px 2px; } a { - color: #fff; + color: var(--text-color); &:hover { text-decoration: none; diff --git a/src/fe/ts/modal/avatar.ts b/src/fe/ts/modal/avatar.ts index 17b9eb7..4d90cbf 100644 --- a/src/fe/ts/modal/avatar.ts +++ b/src/fe/ts/modal/avatar.ts @@ -44,6 +44,10 @@ export class AvatarModal extends Modal { public initialize(): void { super.initialize(); + if (!this.modal) { + return; + } + this.csrf = (document.querySelector('#csrf') as HTMLInputElement).value; this.stages = this.modal?.querySelectorAll( '[data-upload-step]', @@ -131,7 +135,7 @@ export class AvatarModal extends Modal { this.uploadBtn.addEventListener('click', () => { const formData = new FormData(); formData.append('file', this.cropResultBlob); - formData.append('csrf', this.csrf); + formData.append('_csrf', this.csrf); // TODO: error fetch('/account/avatar', { diff --git a/src/fe/ts/modal/modal.ts b/src/fe/ts/modal/modal.ts index 67f926f..f245985 100644 --- a/src/fe/ts/modal/modal.ts +++ b/src/fe/ts/modal/modal.ts @@ -1,6 +1,11 @@ +const isVisible = (elem: HTMLElement) => + !!elem && + !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length); + export class Modal { public triggers?: NodeListOf; public modal?: HTMLElement; + public modalContent?: HTMLElement; protected focusLock: HTMLElement[] = []; protected trigger?: HTMLElement; @@ -13,6 +18,7 @@ export class Modal { this.modal.style.display = 'none'; this.removeFocusLock(); + this.removeClickListener(); } public open(): void { @@ -22,6 +28,10 @@ export class Modal { this.modal.style.display = 'block'; this.createFocusLock(); + + setTimeout(() => + document.addEventListener('click', this.outsideClickListener), + ); } public initialize(): void { @@ -44,12 +54,28 @@ export class Modal { if (this.modal) { const attrLabel = `modal_${this.name}_label`; const label = this.modal.querySelector('.modal__title'); + this.modalContent = this.modal.querySelector('.modal__content'); this.modal.setAttribute('aria-modal', 'true'); + this.modal.setAttribute('role', 'dialog'); this.modal.setAttribute('aria-labelledby', attrLabel); label.setAttribute('id', attrLabel); } } + private outsideClickListener = (event: Event) => { + if ( + !this.modalContent.contains(event.target as HTMLElement) && + isVisible(this.modalContent) + ) { + this.reset(); + this.removeClickListener(); + } + }; + + private removeClickListener = () => { + document.removeEventListener('click', this.outsideClickListener); + }; + private getFocusable(): HTMLElement[] { const focusable = Array.from( this.modal.querySelectorAll( diff --git a/src/fe/ts/modal/modals.ts b/src/fe/ts/modal/modals.ts index 2ae49ad..cf6fc37 100644 --- a/src/fe/ts/modal/modals.ts +++ b/src/fe/ts/modal/modals.ts @@ -14,6 +14,12 @@ export class ModalManager { this.close(); }); }); + + window.addEventListener('keyup', (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + this.close(); + } + }); } public register(item: T): T { diff --git a/src/main.ts b/src/main.ts index 50b1c51..2f907d3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as dotenv from 'dotenv'; import * as session from 'express-session'; +import * as connectRedis from 'connect-redis'; +import * as redis from 'redis'; import { join } from 'path'; import { NestExpressApplication } from '@nestjs/platform-express'; @@ -9,11 +11,22 @@ dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); + + const RedisStore = connectRedis(session); + const redisClient = redis.createClient({ + host: 'localhost', + port: 6379, + }); + + // app.use(express.urlencoded()); + // app.use(cookieParser()); + app.use( session({ secret: process.env.SESSION_SECRET, resave: true, saveUninitialized: false, + store: new RedisStore({ client: redisClient }), cookie: { sameSite: 'lax', secure: process.env.NODE_ENV === 'production', diff --git a/src/middleware/csrf.middleware.ts b/src/middleware/csrf.middleware.ts index f2d41e6..82407f3 100644 --- a/src/middleware/csrf.middleware.ts +++ b/src/middleware/csrf.middleware.ts @@ -7,6 +7,7 @@ export class CSRFMiddleware implements NestMiddleware { constructor(private readonly tokenService: TokenService) {} use(req: Request, res: Response, next: NextFunction) { + // TODO: do not store in session, keep the amount of pointless sessions down if (!req.session.csrf) { req.session.csrf = this.tokenService.generateString(64); } diff --git a/src/middleware/validate-csrf.middleware.ts b/src/middleware/validate-csrf.middleware.ts index 3067b70..4792932 100644 --- a/src/middleware/validate-csrf.middleware.ts +++ b/src/middleware/validate-csrf.middleware.ts @@ -9,7 +9,7 @@ export class ValidateCSRFMiddleware implements NestMiddleware { return next(); } - if (req.body.csrf !== req.session.csrf) { + if (req.body._csrf !== req.session.csrf) { return next(new Error('Invalid session')); } diff --git a/src/modules/features/login/login.controller.ts b/src/modules/features/login/login.controller.ts index 8d6297c..9a09b08 100644 --- a/src/modules/features/login/login.controller.ts +++ b/src/modules/features/login/login.controller.ts @@ -35,11 +35,8 @@ export class LoginController { @Get() @Render('login/login') - public loginView( - @Session() session: SessionData, - @Req() req: Request, - ): Record { - return this.formUtil.populateTemplate(req, session); + public loginView(@Req() req: Request): Record { + return this.formUtil.populateTemplate(req); } @Post() @@ -232,14 +229,14 @@ export class LoginController { } res.render('login/password', { - ...this.formUtil.populateTemplate(req, session), + ...this.formUtil.populateTemplate(req), token: true, }); return; } res.render('login/password', { - ...this.formUtil.populateTemplate(req, session), + ...this.formUtil.populateTemplate(req), token: false, }); } diff --git a/src/modules/features/oauth2/adapter/user.adapter.ts b/src/modules/features/oauth2/adapter/user.adapter.ts index ca5bfce..f540f8a 100644 --- a/src/modules/features/oauth2/adapter/user.adapter.ts +++ b/src/modules/features/oauth2/adapter/user.adapter.ts @@ -44,7 +44,11 @@ export class UserAdapter implements OAuth2UserAdapter { clientId: string, scope: string | string[], ): Promise { - return false; + return this._service.clientService.hasAuthorized( + userId, + clientId, + this._service.splitScope(scope), + ); } async consent( @@ -52,6 +56,13 @@ export class UserAdapter implements OAuth2UserAdapter { clientId: string, scope: string | string[], ): Promise { + const client = await this._service.clientService.getById(clientId); + const user = await this._service.userService.getById(userId); + await this._service.clientService.createAuthorization( + user, + client, + this._service.splitScope(scope), + ); return true; } } diff --git a/src/modules/features/oauth2/oauth2.service.ts b/src/modules/features/oauth2/oauth2.service.ts index 8a5e472..a90c5cd 100644 --- a/src/modules/features/oauth2/oauth2.service.ts +++ b/src/modules/features/oauth2/oauth2.service.ts @@ -64,11 +64,15 @@ export class OAuth2Service { } } - public splitScope(scope: string): string[] { + public splitScope(scope: string | string[]): string[] { if (!scope) { return []; } + if (Array.isArray(scope)) { + return scope; + } + return scope.includes(',') ? scope.split(',').map((item) => item.trim()) : scope.split(' '); diff --git a/src/modules/features/register/register.controller.ts b/src/modules/features/register/register.controller.ts index b8bbd7e..ff602e5 100644 --- a/src/modules/features/register/register.controller.ts +++ b/src/modules/features/register/register.controller.ts @@ -25,11 +25,8 @@ export class RegisterController { @Get() @Render('register') - public registerView( - @Session() session: SessionData, - @Req() req: Request, - ): Record { - return this.formUtil.populateTemplate(req, session); + public registerView(@Req() req: Request): Record { + return this.formUtil.populateTemplate(req); } @Post() diff --git a/src/modules/features/settings/settings.controller.ts b/src/modules/features/settings/settings.controller.ts index 67e24ab..ffbacdc 100644 --- a/src/modules/features/settings/settings.controller.ts +++ b/src/modules/features/settings/settings.controller.ts @@ -1,19 +1,25 @@ import { BadRequestException, + Body, Controller, + Delete, Get, + Param, Post, Redirect, Render, Req, + Res, Session, + UnauthorizedException, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { Request } from 'express'; +import { Request, Response } from 'express'; import { SessionData } from 'express-session'; import { unlink } from 'fs/promises'; +import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { UploadService } from 'src/modules/objects/upload/upload.service'; import { UserService } from 'src/modules/objects/user/user.service'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; @@ -26,6 +32,7 @@ export class SettingsController { private readonly _form: FormUtilityService, private readonly _upload: UploadService, private readonly _user: UserService, + private readonly _client: OAuth2ClientService, ) {} @Get() @@ -36,8 +43,42 @@ export class SettingsController { @Get('general') @Render('settings/general') - public general(@Req() req: Request, @Session() sess: SessionData) { - return this._form.populateTemplate(req, sess, { user: req.user }); + public general(@Req() req: Request) { + return this._form.populateTemplate(req, { user: req.user }); + } + + @Post('general') + public async updateDisplayName( + @Req() req: Request, + @Res() res: Response, + @Body() body: { display_name?: string }, + ) { + try { + const { display_name } = body; + if (!display_name) { + throw new Error('Display name is required.'); + } + + if (display_name.length < 3 || display_name.length > 32) { + throw new Error( + 'Display name must be between 3 and 32 characters long.', + ); + } + + req.user.display_name = display_name; + + await this._user.updateUser(req.user); + req.flash('message', { + error: false, + text: 'Display name has been changed!', + }); + } catch (e: any) { + req.flash('message', { + error: true, + text: e.message, + }); + } + res.redirect('/account/general'); } @Post('avatar') @@ -72,4 +113,55 @@ export class SettingsController { file: upload.file, }; } + + @Post('avatar/delete') + public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) { + this._user.deleteAvatar(req.user); + req.flash('message', { + error: false, + text: 'Avatar removed successfully.', + }); + res.redirect('/account/general'); + } + + @Get('oauth2') + @Render('settings/oauth2') + public async authorizations(@Req() req: Request) { + const authorizations = await this._client.getAuthorizations(req.user); + return this._form.populateTemplate(req, { authorizations }); + } + + @Post('oauth2/revoke/:id') + public async revokeAuthorization( + @Req() req: Request, + @Res() res: Response, + @Param('id') id: number, + ) { + const getAuth = await this._client.getAuthorization(req.user, id); + const jsreq = + req.header('content-type').startsWith('application/json') || + req.header('accept').startsWith('application/json'); + + if (!getAuth) { + if (jsreq) { + throw new UnauthorizedException( + 'Unauthorized or invalid revokation request', + ); + } + + req.flash('message', { + error: true, + text: 'Unauthorized revokation.', + }); + res.redirect('/account/oauth2'); + return; + } + + await this._client.revokeAuthorization(getAuth); + + if (jsreq) { + return res.json({ success: true }); + } + res.redirect('/account/oauth2'); + } } diff --git a/src/modules/features/settings/settings.module.ts b/src/modules/features/settings/settings.module.ts index 4a9dda8..59b2073 100644 --- a/src/modules/features/settings/settings.module.ts +++ b/src/modules/features/settings/settings.module.ts @@ -18,6 +18,7 @@ import { UserModule } from 'src/modules/objects/user/user.module'; import { OAuth2Module } from '../oauth2/oauth2.module'; import { SettingsController } from './settings.controller'; import { SettingsService } from './settings.service'; +import { OAuth2ClientModule } from 'src/modules/objects/oauth2-client/oauth2-client.module'; @Module({ controllers: [SettingsController], @@ -26,6 +27,7 @@ import { SettingsService } from './settings.service'; UploadModule, UserModule, OAuth2Module, + OAuth2ClientModule, MulterModule.registerAsync({ imports: [ConfigurationModule], useFactory: async (config: ConfigurationService) => { diff --git a/src/modules/features/two-factor/two-factor.controller.ts b/src/modules/features/two-factor/two-factor.controller.ts index 5ac38d1..5db1192 100644 --- a/src/modules/features/two-factor/two-factor.controller.ts +++ b/src/modules/features/two-factor/two-factor.controller.ts @@ -42,7 +42,7 @@ export class TwoFactorController { const qrcode = await this.qr.createQRDataURI(url); res.render('two-factor/activate', { - ...this.form.populateTemplate(req, session), + ...this.form.populateTemplate(req), qrcode, }); diff --git a/src/modules/objects/oauth2-client/oauth2-client.service.ts b/src/modules/objects/oauth2-client/oauth2-client.service.ts index bc911eb..c7ae24d 100644 --- a/src/modules/objects/oauth2-client/oauth2-client.service.ts +++ b/src/modules/objects/oauth2-client/oauth2-client.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { DeleteResult, Repository } from 'typeorm'; +import { User } from '../user/user.entity'; import { OAuth2ClientAuthorization } from './oauth2-client-authorization.entity'; import { OAuth2ClientURL, @@ -18,6 +19,98 @@ export class OAuth2ClientService { private clientAuthRepository: Repository, ) {} + public async hasAuthorized( + userId: number, + clientId: string, + scope: string[], + ): Promise { + const authorization = await this.clientAuthRepository.findOne({ + where: { + user: { + id: userId, + }, + client: { + client_id: clientId, + }, + }, + relations: ['user', 'client'], + }); + + if (!authorization) { + return false; + } + + // Scopes must have been allowed + const splitScope = authorization.scope.split(' '); + if (scope.every((item) => splitScope.includes(item))) { + return true; + } + + return false; + } + + public async createAuthorization( + user: User, + client: OAuth2Client, + scope: string[], + ): Promise { + const existing = await this.clientAuthRepository.findOne({ + where: { + user, + client, + }, + relations: ['user', 'client'], + }); + + if (existing) { + const splitScope = existing.scope.split(' '); + scope.forEach((item) => { + if (!splitScope.includes(item)) { + splitScope.push(item); + } + }); + existing.scope = splitScope.join(' '); + await this.clientAuthRepository.save(existing); + return existing; + } + + const authorization = new OAuth2ClientAuthorization(); + authorization.user = user; + authorization.client = client; + authorization.scope = scope.join(' '); + await this.clientAuthRepository.insert(authorization); + return authorization; + } + + public async getAuthorizations( + user: User, + ): Promise { + return this.clientAuthRepository.find({ + relations: ['user', 'client', 'client.urls'], + where: { user }, + }); + } + + public async revokeAuthorization( + auth: OAuth2ClientAuthorization, + ): Promise { + console.log(auth); + return this.clientAuthRepository.remove(auth); + } + + public async getAuthorization( + user: User, + authId: number, + ): Promise { + return this.clientAuthRepository.findOne({ + where: { + user, + id: authId, + }, + relations: ['user'], + }); + } + public async getById(id: string | number): Promise { let client: OAuth2Client; diff --git a/src/modules/objects/user/user.service.ts b/src/modules/objects/user/user.service.ts index c5b64c1..0b45aa4 100644 --- a/src/modules/objects/user/user.service.ts +++ b/src/modules/objects/user/user.service.ts @@ -77,6 +77,12 @@ export class UserService { return user; } + public async deleteAvatar(user: User): Promise { + if (user.picture) { + await this.upload.delete(user.picture); + } + } + public async comparePasswords( hash: string, password: string, diff --git a/src/modules/utility/services/form-utility.service.ts b/src/modules/utility/services/form-utility.service.ts index 2e132bc..4387ac3 100644 --- a/src/modules/utility/services/form-utility.service.ts +++ b/src/modules/utility/services/form-utility.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { Request } from 'express'; -import { SessionData } from 'express-session'; @Injectable() export class FormUtilityService { @@ -25,7 +24,6 @@ export class FormUtilityService { */ public populateTemplate( req: Request, - session: SessionData, additional: Record = {}, ): Record { const message = req.flash('message')[0] || {}; @@ -35,7 +33,7 @@ export class FormUtilityService { return { path: req.originalUrl, - csrf: session.csrf, + csrf: req.session.csrf, message, form, ...additional, diff --git a/views/authorize.pug b/views/authorize.pug index 0469a93..3450067 100644 --- a/views/authorize.pug +++ b/views/authorize.pug @@ -12,12 +12,20 @@ block body .authorize .authorize__user .authorize__user-image + if user.picture + img(src='/uploads/' + user.picture.file, alt=user.username) + else + img(src='/public/image/avatar.png', alt='No picture') .authorize__user-content span.authorize__user-title #{user.display_name} span.authorize__user-user @#{user.username} .authorize__center .authorize__client .authorize__client-image + if client.picture + img(src='/uploads/' + client.picture.file, alt=client.title) + else + img(src='/public/image/application.png', alt='No picture') .authorize__client-content span.authorize__client-title #{client.title} span.authorize__client-description #{client.description} @@ -45,12 +53,12 @@ block body form(method="POST", action="") div.form-container - input(type="hidden", name="csrf", value=csrf) + input(type="hidden", name="_csrf", value=csrf) input(type="hidden", name="decision", value="1") button.btn.btn-primary(type="submit") Authorize form(method="POST", action="") div.form-container - input(type="hidden", name="csrf", value=csrf) + input(type="hidden", name="_csrf", value=csrf) input(type="hidden", name="decision", value="0") button.btn.btn-link(type="submit") Reject diff --git a/views/login/login.pug b/views/login/login.pug index 846dcc3..5f11c19 100644 --- a/views/login/login.pug +++ b/views/login/login.pug @@ -18,7 +18,7 @@ block body form(method="post") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="username") Username input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username) label.form-label(for="password") Password diff --git a/views/login/password.pug b/views/login/password.pug index 493db27..193758a 100644 --- a/views/login/password.pug +++ b/views/login/password.pug @@ -19,7 +19,7 @@ block body form(method="post") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="password") New password input.form-control#password(type="password", name="password", autofocus, placeholder="Password") small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number. @@ -31,7 +31,7 @@ block body p If you have forgotten your password, please enter your accounts email address and we will send you a link to recover it. form(method="post") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="email") Email address input.form-control#email(type="email", name="email", autofocus, placeholder="Email addres") button.btn.btn-primary(type="submit") Send recovery email diff --git a/views/login/totp-verify.pug b/views/login/totp-verify.pug index 24440e9..6ac58d1 100644 --- a/views/login/totp-verify.pug +++ b/views/login/totp-verify.pug @@ -18,7 +18,7 @@ block body form(method="post") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="totp") Code input.form-control#totp(type="text", name="totp", autofocus, placeholder="xxxxxx") button.btn.btn-primary(type="submit") Log in diff --git a/views/register.pug b/views/register.pug index b57f4b5..2bd9a55 100644 --- a/views/register.pug +++ b/views/register.pug @@ -18,7 +18,7 @@ block body form(method="post") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="username") Username input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username) diff --git a/views/settings/general.pug b/views/settings/general.pug index 89a7ada..4c93f34 100644 --- a/views/settings/general.pug +++ b/views/settings/general.pug @@ -16,7 +16,7 @@ block settings .col form(method="post") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="username") Username input.form-control#username(type="text", name="username", placeholder="Username", disabled, value=user.username) label.form-label(for="display_name") Display Name @@ -33,16 +33,18 @@ block settings .flex-column(data-script="flex") button.btn.btn-primary(data-modal-trigger="avatar") Change avatar if user.picture - button.btn.btn-link#remove-avatar Remove avatar + form(method="post", action="/account/avatar/delete") + input(type="hidden", name="_csrf", value=csrf) + button.btn.btn-link#remove-avatar(type="submit") Remove avatar form(method="post", data-noscript, action="/account/avatar", enctype="multipart/form-data") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input(type="hidden", name="_csrf", value=csrf) label.form-label(for="image") Image input.form-control#image(type="file", name="file") small.form-hint Must be less than 10 MB and 1:1 aspect ratio. Enable JavaScript to custom crop your image. button.btn.btn-primary(type="submit") Change - .modal#avatar-modal(data-modal="avatar", aria-live="polite", role="modal", style="display: none") + .modal#avatar-modal(data-modal="avatar", style="display: none") .modal__content .modal__title |Change avatar diff --git a/views/settings/layout.pug b/views/settings/layout.pug index c3fbe39..570a29b 100644 --- a/views/settings/layout.pug +++ b/views/settings/layout.pug @@ -7,10 +7,10 @@ block body nav.sidebar.settings__nav ul li - a(href="/account/general") General + a(href="/account/general", class=path === '/account/general' ? 'active' : '') General li - a(href="/account/oauth2") Authorizations + a(href="/account/oauth2", class=path === '/account/oauth2' ? 'active' : '') Authorizations li - a(href="/account/security") Security + a(href="/account/security", class=path === '/account/security' ? 'active' : '') Security section.content.settings__content block settings diff --git a/views/settings/oauth2.pug b/views/settings/oauth2.pug new file mode 100644 index 0000000..8ddc80f --- /dev/null +++ b/views/settings/oauth2.pug @@ -0,0 +1,44 @@ +extends ./layout.pug + +block title + |Authorizations - Account settings | Icy Network + +block settings + h1 Authorizations + if message.text + if message.error + .alert.alert-danger + span #{message.text} + else + .alert.alert-success + span #{message.text} + p These applications are authorized automatically when requested, provided you have already consented to the information they require. + p By revoking the authorization, you may be prompted to authorize the application again in the future. This does NOT ensure that your information + |is deleted by any third-party applications in question. Please contact each application's owner individually if you wish to remove your information + |from their servers. + hr + .authorization-wrapper + each auth in authorizations + .authorization__client + .authorization__client-image + if auth.client.picture + img(src='/uploads/' + auth.client.picture.file, alt=auth.client.title) + else + img(src='/public/image/application.png', alt='No picture') + .authorization__client-content + span.authorization__client-title #{auth.client.title} + span.authorization__client-description #{auth.client.description} + .authorization__client-urls + each url in auth.client.urls + if url.type == 'website' + a.authorization__client-url(href=url.url, target="_blank", rel="nofollow") Visit website + if url.type == 'privacy' + a.authorization__client-url(href=url.url, target="_blank", rel="nofollow") Privacy Policy + if url.type == 'terms' + a.authorization__client-url(href=url.url, target="_blank", rel="nofollow") Terms of Service + .authorization__client-actions + form(method="post",action="/account/oauth2/revoke/" + auth.id) + input(type="hidden", name="_csrf", value=csrf) + button.btn.btn-primary(type="submit") Revoke + if !authorizations.length + p.text-center You have not authorized any applications. diff --git a/views/two-factor/activate.pug b/views/two-factor/activate.pug index 1630f51..143c9bb 100644 --- a/views/two-factor/activate.pug +++ b/views/two-factor/activate.pug @@ -21,7 +21,7 @@ block body form(method="post",autocomplete="off") div.form-container - input#csrf(type="hidden", name="csrf", value=csrf) + input#csrf(type="hidden", name="_csrf", value=csrf) label.form-label(for="code") Code from authenticator app input.form-control#code(type="text", name="code", autofocus, placeholder="xxxxxx") button.btn.btn-primary(type="submit") Activate