Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening

This commit is contained in:
nmemmert
2026-05-04 13:56:04 -04:00
parent 7f56060d6b
commit 1551599305
16 changed files with 1597 additions and 682 deletions
-1
View File
@@ -1 +0,0 @@
ADMIN_PASSWORD=TestAdmin123!
+6
View File
@@ -21,6 +21,12 @@ data/backups/
data/hit-stats.json
data/visitor-stats.json
data/contact-submissions.json
data/totp-secret.json
data/uploads-meta.json
# Local environment secrets — never commit
.env
.env.*
# Editor directories and files
.vscode/*
+4 -11
View File
@@ -23,22 +23,15 @@
"seriesLabel": "Now Playing",
"seriesTitle": "Study of Titus: Sound Doctrine",
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
"seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png",
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "",
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
"shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"customLinks": [
{
"id": "molso9ow",
"label": "test",
"url": "/uploads/discussion_questions_post-1777565348551.png",
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
"placement": "resources"
}
],
"customLinks": [],
"customBlocks": [],
"archivedSeries": [],
"redirects": [
@@ -108,5 +101,5 @@
]
}
},
"updatedAt": "2026-04-30T18:06:00.013Z"
"updatedAt": "2026-05-04T17:42:41.930Z"
}
+4 -11
View File
@@ -23,22 +23,15 @@
"seriesLabel": "Now Playing",
"seriesTitle": "Study of Titus: Sound Doctrine",
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
"seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png",
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "",
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
"shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"customLinks": [
{
"id": "molso9ow",
"label": "test",
"url": "/uploads/discussion_questions_post-1777565348551.png",
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
"placement": "resources"
}
],
"customLinks": [],
"customBlocks": [],
"archivedSeries": [],
"redirects": [
@@ -108,5 +101,5 @@
]
}
},
"updatedAt": "2026-04-30T18:06:00.017Z"
"updatedAt": "2026-05-04T17:42:41.949Z"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+316 -11
View File
@@ -9,6 +9,9 @@
"version": "0.0.0",
"dependencies": {
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"otplib": "^13.4.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
@@ -582,6 +585,74 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@otplib/core": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz",
"integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==",
"license": "MIT"
},
"node_modules/@otplib/hotp": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz",
"integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.4.0",
"@otplib/uri": "13.4.0"
}
},
"node_modules/@otplib/plugin-base32-scure": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz",
"integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.4.0",
"@scure/base": "^2.0.0"
}
},
"node_modules/@otplib/plugin-crypto-noble": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz",
"integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1",
"@otplib/core": "13.4.0"
}
},
"node_modules/@otplib/totp": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz",
"integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.4.0",
"@otplib/hotp": "13.4.0",
"@otplib/uri": "13.4.0"
}
},
"node_modules/@otplib/uri": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz",
"integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.4.0"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
@@ -864,6 +935,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
@@ -1355,7 +1435,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1365,7 +1444,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -1531,6 +1609,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
@@ -1638,7 +1725,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -1651,7 +1737,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
@@ -1801,6 +1886,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -1862,6 +1956,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1893,7 +1993,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -2210,6 +2309,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -2398,7 +2515,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -2665,6 +2781,15 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2722,7 +2847,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4193,6 +4317,20 @@
"node": ">= 0.8.0"
}
},
"node_modules/otplib": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz",
"integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.4.0",
"@otplib/hotp": "13.4.0",
"@otplib/plugin-base32-scure": "13.4.0",
"@otplib/plugin-crypto-noble": "13.4.0",
"@otplib/totp": "13.4.0",
"@otplib/uri": "13.4.0"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4225,6 +4363,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4276,7 +4423,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4322,6 +4468,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
@@ -4400,6 +4555,141 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -4595,12 +4885,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resend": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz",
@@ -4766,6 +5061,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -4929,7 +5230,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -4958,7 +5258,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -5451,6 +5750,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4 -2
View File
@@ -10,11 +10,13 @@
"build": "tsc -b && vite build",
"start": "node --env-file=.env server.js",
"lint": "eslint .",
"preview": "vite preview",
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
"preview": "vite preview"
},
"dependencies": {
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"otplib": "^13.4.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
+180 -97
View File
@@ -1,9 +1,11 @@
import express from 'express'
import rateLimit from 'express-rate-limit'
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Resend } from 'resend'
import qrcode from 'qrcode'
import {
sanitizeSiteContent,
escapeHtml,
@@ -28,6 +30,17 @@ import {
deleteAdminSession,
validateAdminPasswordSetup,
isAdminPasswordValid,
isTotpEnabled,
loadTotpState,
saveTotpState,
generateTotpSecret,
getTotpUri,
verifyTotpCode,
generateRecoveryCodes,
hashRecoveryCode,
consumeRecoveryCode,
createPendingSession,
consumePendingSession,
} from './server/auth.js'
const __filename = fileURLToPath(import.meta.url)
@@ -40,7 +53,6 @@ const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -227,7 +239,7 @@ async function writeUploadsMetadata(metadata) {
async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort()
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
const metadata = await readUploadsMetadata()
const withStats = await Promise.all(imageFiles.map(async filename => {
@@ -1090,7 +1102,7 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
return
}
@@ -1193,93 +1205,6 @@ app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
res.json({ ok: true, message: result.message })
})
// ── Chatbot knowledge base ──────────────────────────────────────────────────
const MAX_CHATBOT_ENTRIES = 500
let chatbotEntries = []
let chatbotWritePromise = Promise.resolve()
let chatbotFileMtimeMs = 0
function queueChatbotWrite() {
chatbotWritePromise = chatbotWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CHATBOT_FILE,
JSON.stringify(chatbotEntries, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[chatbot] failed to write chatbot content:', err)
})
}
async function loadChatbotFromDisk() {
try {
const [fileStats, raw] = await Promise.all([
stat(CHATBOT_FILE),
readFile(CHATBOT_FILE, 'utf8'),
])
const parsed = JSON.parse(raw)
chatbotEntries = Array.isArray(parsed) ? parsed.slice(0, MAX_CHATBOT_ENTRIES) : []
chatbotFileMtimeMs = fileStats.mtimeMs
} catch {
chatbotEntries = []
chatbotFileMtimeMs = 0
}
}
async function refreshChatbotFromDiskIfChanged() {
try {
const fileStats = await stat(CHATBOT_FILE)
if (fileStats.mtimeMs <= chatbotFileMtimeMs) return
await loadChatbotFromDisk()
} catch {
if (chatbotFileMtimeMs === 0) return
chatbotEntries = []
chatbotFileMtimeMs = 0
}
}
// Public: return all chatbot entries for client-side matching
app.get('/api/chatbot-content', async (req, res) => {
await refreshChatbotFromDiskIfChanged()
res.json(chatbotEntries)
})
// Admin: get all entries
app.get('/api/admin/chatbot-content', async (req, res) => {
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
await refreshChatbotFromDiskIfChanged()
res.json(chatbotEntries)
})
// Admin: save full list (replace all)
app.post('/api/admin/chatbot-content', (req, res) => {
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
const body = req.body
if (!Array.isArray(body)) { res.status(400).json({ message: 'Expected array.' }); return }
const sanitized = body
.filter(e => e && typeof e.title === 'string' && typeof e.content === 'string')
.slice(0, MAX_CHATBOT_ENTRIES)
.map(e => ({
id: typeof e.id === 'string' && e.id ? e.id : randomUUID(),
type: ['qa', 'topic', 'episode'].includes(e.type) ? e.type : 'qa',
title: String(e.title).trim().slice(0, 500),
content: String(e.content).trim().slice(0, 4000),
sourceLabel: typeof e.sourceLabel === 'string' ? e.sourceLabel.trim().slice(0, 160) : '',
priority: e.priority === true,
keywords: Array.isArray(e.keywords)
? e.keywords.filter(k => typeof k === 'string').map(k => k.trim().toLowerCase()).slice(0, 20)
: [],
createdAt: typeof e.createdAt === 'string' ? e.createdAt : new Date().toISOString(),
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : new Date().toISOString(),
}))
chatbotEntries = sanitized
queueChatbotWrite()
res.json({ ok: true, count: chatbotEntries.length })
})
function queueQuestionsWrite() {
questionsWritePromise = questionsWritePromise
.then(async () => {
@@ -1331,14 +1256,27 @@ function loadQuestionsFromDisk() {
questions = []
})
}
app.get('/api/admin-auth/status', (req, res) => {
// Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
app.get('/api/admin-auth/status', async (req, res) => {
res.json({
authenticated: isValidAdminSession(req),
configured: isAdminPasswordConfigured(),
totpEnabled: await isTotpEnabled(),
})
})
app.post('/api/admin-auth/login', (req, res) => {
// Step 1: verify password. If TOTP is enabled, returns a short-lived pending token.
// If TOTP is not yet configured, logs straight in (backwards compatible).
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!isAdminPasswordConfigured()) {
@@ -1351,11 +1289,110 @@ app.post('/api/admin-auth/login', (req, res) => {
return
}
const totpOn = await isTotpEnabled()
if (totpOn) {
const pendingToken = createPendingSession()
res.json({ totpRequired: true, pendingToken })
return
}
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
})
// Step 2a: verify TOTP code (or recovery code) after password was accepted
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
const { pendingToken, code } = req.body ?? {}
if (!consumePendingSession(pendingToken)) {
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
return
}
const state = await loadTotpState()
if (!state?.secret || !state?.verified) {
res.status(400).json({ message: 'TOTP is not configured.' })
return
}
const codeStr = typeof code === 'string' ? code.trim() : ''
// Try TOTP first
if (verifyTotpCode(state.secret, codeStr)) {
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
return
}
// Try recovery code
if (consumeRecoveryCode(state, codeStr)) {
await saveTotpState(state)
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length })
return
}
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
})
// TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password)
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
const secret = generateTotpSecret()
const uri = getTotpUri(secret)
const qrDataUrl = await qrcode.toDataURL(uri)
// Store unverified secret temporarily
const existing = await loadTotpState()
await saveTotpState({ ...existing, secret, verified: false })
res.json({ qrDataUrl, secret })
})
// TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
const { code } = req.body ?? {}
const state = await loadTotpState()
if (!state?.secret) {
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
return
}
if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) {
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({
secret: state.secret,
verified: true,
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
enabledAt: new Date().toISOString(),
})
res.json({ ok: true, recoveryCodes })
})
// Disable TOTP (requires active admin session)
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
res.json({ ok: true })
})
// Regenerate recovery codes (requires active admin session)
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
const state = await loadTotpState()
if (!state?.secret || !state?.verified) {
res.status(400).json({ message: 'TOTP is not enabled.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
res.json({ ok: true, recoveryCodes })
})
app.post('/api/admin-auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
@@ -1592,12 +1629,17 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
return
}
const published = await loadSiteContentFile(DATA_FILE)
const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)
if (!configuredDownloadUrl) {
try {
await stat(TITUS_STUDY_FILE)
} catch {
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' })
return
}
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
@@ -1617,6 +1659,11 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
await syncContactToResend(trimmedName, trimmedEmail)
}
if (configuredDownloadUrl) {
res.json({ ok: true, downloadUrl: configuredDownloadUrl })
return
}
const token = createTitusDownloadToken(trimmedEmail)
res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
} catch (err) {
@@ -1640,9 +1687,46 @@ app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
}
const published = await loadSiteContentFile(DATA_FILE)
const resource = Array.isArray(published?.siteContent?.customLinks)
? published.siteContent.customLinks.find(link => link.id === resourceId && link.placement === 'resources')
: undefined
const siteContent = published?.siteContent
function resolveResourceFromId(id) {
if (!siteContent || typeof siteContent !== 'object') return null
const customResources = Array.isArray(siteContent.customLinks)
? siteContent.customLinks.filter(link => link?.placement === 'resources')
: []
if (id.startsWith('custom:')) {
const customId = id.slice('custom:'.length)
const match = customResources.find(link => link.id === customId)
return match ? { label: match.label, url: match.url } : null
}
if (id.startsWith('archived:')) {
const [, seriesId, ...linkIdParts] = id.split(':')
const linkId = linkIdParts.join(':')
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
const series = archivedSeries.find(item => item.id === seriesId)
const link = Array.isArray(series?.resourceLinks)
? series.resourceLinks.find(item => item.id === linkId)
: null
return link ? { label: link.label || series?.title, url: link.url } : null
}
const customMatch = customResources.find(link => link.id === id)
if (customMatch) return { label: customMatch.label, url: customMatch.url }
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
for (const series of archivedSeries) {
if (!Array.isArray(series?.resourceLinks)) continue
const link = series.resourceLinks.find(item => item.id === id)
if (link) return { label: link.label || series?.title, url: link.url }
}
return null
}
const resource = resolveResourceFromId(resourceId)
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
res.status(400).json({ message: 'Resource not found.' })
@@ -2061,7 +2145,6 @@ Promise.all([
loadContactSubmissionsFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadChatbotFromDisk(),
refreshContentCaches(),
])
.catch(err => {
+185 -3
View File
@@ -1,6 +1,18 @@
import { createHash, randomUUID } from 'node:crypto'
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseCookies } from './helpers.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const TOTP_SECRET_FILE = path.join(__dirname, '..', 'data', 'totp-secret.json')
// Pending sessions: password verified, waiting for TOTP code
// Map<pendingToken, { expiresAt }>
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
const totpPendingSessions = new Map()
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
@@ -22,7 +34,6 @@ export function validateAdminPasswordSetup() {
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_PASSWORD is required in production.')
}
if (!isAdminPasswordConfigured()) {
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
}
@@ -30,9 +41,180 @@ export function validateAdminPasswordSetup() {
export function isAdminPasswordValid(password) {
if (!isAdminPasswordConfigured()) return false
return sha256(password) === sha256(ADMIN_PASSWORD)
const a = Buffer.from(sha256(password), 'utf8')
const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
// ── TOTP (RFC 6238) — implemented with Node built-in crypto ─────────────────
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
function base32Decode(str) {
const s = str.toUpperCase().replace(/=+$/, '')
let bits = 0
let value = 0
const output = []
for (const char of s) {
const idx = BASE32_CHARS.indexOf(char)
if (idx === -1) continue
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 0xff)
bits -= 8
}
}
return Buffer.from(output)
}
function base32Encode(buf) {
let bits = 0
let value = 0
let output = ''
for (const byte of buf) {
value = (value << 8) | byte
bits += 8
while (bits >= 5) {
output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f]
bits -= 5
}
}
if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f]
return output
}
function totpToken(secret, counter) {
const key = base32Decode(secret)
const msg = Buffer.alloc(8)
// Write 64-bit big-endian counter
const hi = Math.floor(counter / 0x100000000)
const lo = counter >>> 0
msg.writeUInt32BE(hi, 0)
msg.writeUInt32BE(lo, 4)
const hmac = createHmac('sha1', key).update(msg).digest()
const offset = hmac[hmac.length - 1] & 0x0f
const code = ((hmac[offset] & 0x7f) << 24)
| (hmac[offset + 1] << 16)
| (hmac[offset + 2] << 8)
| hmac[offset + 3]
return String(code % 1000000).padStart(6, '0')
}
export function generateTotpSecret() {
const buf = Buffer.allocUnsafe(20)
randomFillSync(buf)
return base32Encode(buf)
}
export async function loadTotpState() {
try {
const raw = await readFile(TOTP_SECRET_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return null
}
}
export async function saveTotpState(state) {
await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8')
}
export async function isTotpEnabled() {
const state = await loadTotpState()
return Boolean(state?.secret && state?.verified)
}
function randomBytesForRecovery(n) {
const buf = Buffer.allocUnsafe(n)
randomFillSync(buf)
return buf
}
export function getTotpUri(secret, label = 'Siteforge Admin') {
const issuer = 'Siteforge'
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
}
export function verifyTotpCode(secret, code) {
try {
const token = String(code).replace(/\s/g, '')
const step = Math.floor(Date.now() / 1000 / 30)
// Accept current step and one step either side (±30 seconds clock skew)
for (const offset of [-1, 0, 1]) {
if (totpToken(secret, step + offset) === token) return true
}
return false
} catch {
return false
}
}
// ── Recovery Codes ──────────────────────────────────────────────────────────
const RECOVERY_CODE_COUNT = 8
function generateRecoveryCode() {
// Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars)
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const randBytes = randomBytesForRecovery(12)
let code = ''
for (let i = 0; i < 12; i++) {
if (i > 0 && i % 4 === 0) code += '-'
code += chars[randBytes[i] % chars.length]
}
return code
}
export function generateRecoveryCodes() {
const codes = []
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
codes.push(generateRecoveryCode())
}
return codes
}
export function hashRecoveryCode(code) {
return sha256(code.replace(/-/g, '').toUpperCase())
}
// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes.
export function consumeRecoveryCode(state, inputCode) {
if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false
const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase()
const inputHash = sha256(normalized)
const idx = state.hashedRecoveryCodes.findIndex(h => {
const a = Buffer.from(h, 'utf8')
const b = Buffer.from(inputHash, 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
})
if (idx === -1) return false
state.hashedRecoveryCodes.splice(idx, 1)
return true
}
// ── Pending (password-ok, awaiting TOTP) sessions ───────────────────────────
export function createPendingSession() {
const token = randomUUID()
totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS })
return token
}
export function consumePendingSession(token) {
if (!token) return false
const entry = totpPendingSessions.get(token)
if (!entry || entry.expiresAt <= Date.now()) {
totpPendingSessions.delete(token)
return false
}
totpPendingSessions.delete(token)
return true
}
// ── Admin Sessions ───────────────────────────────────────────────────────────
export function createAdminSession() {
const token = randomUUID()
adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS)
+5
View File
@@ -171,6 +171,7 @@ function sanitizeCustomLinks(value) {
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
url: sanitizeUrl(item.url),
imageUrl: sanitizeUrl(item.imageUrl),
description: typeof item.description === 'string' ? item.description.trim().slice(0, 400) : '',
placement,
}
})
@@ -196,6 +197,7 @@ function sanitizeArchivedSeriesResourceLinks(value) {
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 400) : '',
url: sanitizeUrl(item.url),
}))
.filter(item => item.label && item.url)
@@ -334,6 +336,9 @@ export function inferImageExtensionFromDataUrl(dataUrl) {
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
if (dataUrl.startsWith('data:application/pdf;base64,')) return '.pdf'
if (dataUrl.startsWith('data:application/msword;base64,')) return '.doc'
if (dataUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,')) return '.docx'
return null
}
+396 -15
View File
@@ -213,6 +213,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
// TOTP management state
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
const [totpSetupQr, setTotpSetupQr] = useState<string | null>(null)
const [totpSetupSecret, setTotpSetupSecret] = useState<string | null>(null)
const [totpConfirmCode, setTotpConfirmCode] = useState('')
const [totpMsg, setTotpMsg] = useState('')
const [totpRecoveryCodes, setTotpRecoveryCodes] = useState<string[] | null>(null)
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
const [assets, setAssets] = useState<AdminAsset[]>([])
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
@@ -243,6 +251,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => setTotpEnabled(!!(data as { totpEnabled?: boolean }).totpEnabled))
.catch(() => {})
}, [])
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -458,6 +473,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}))
}
async function handleTotpSetupInit() {
setTotpMsg('')
setTotpRecoveryCodes(null)
const res = await fetch('/api/admin-auth/totp-setup-init', { method: 'POST' })
const data = await res.json().catch(() => ({})) as { qrDataUrl?: string; secret?: string; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Setup failed.'); return }
setTotpSetupQr(data.qrDataUrl ?? null)
setTotpSetupSecret(data.secret ?? null)
setTotpConfirmCode('')
}
async function handleTotpSetupConfirm(e: React.FormEvent) {
e.preventDefault()
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-setup-confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: totpConfirmCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Confirmation failed.'); return }
setTotpEnabled(true)
setTotpSetupQr(null)
setTotpSetupSecret(null)
setTotpConfirmCode('')
setTotpRecoveryCodes(data.recoveryCodes ?? null)
setTotpMsg('Two-factor authentication enabled.')
}
async function handleTotpDisable() {
if (!confirm('Disable two-factor authentication? This will make your admin less secure.')) return
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-disable', { method: 'POST' })
if (res.ok) { setTotpEnabled(false); setTotpRecoveryCodes(null); setTotpMsg('Two-factor authentication disabled.') }
else { const d = await res.json().catch(() => ({})) as { message?: string }; setTotpMsg(d.message ?? 'Failed to disable TOTP.') }
}
async function handleTotpRegenRecovery() {
if (!confirm('Regenerate recovery codes? Your old codes will stop working immediately.')) return
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-regen-recovery', { method: 'POST' })
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Failed.'); return }
setTotpRecoveryCodes(data.recoveryCodes ?? null)
setTotpMsg('New recovery codes generated. Save these now.')
}
function addRedirectRule() {
setForm(f => ({
...f,
@@ -760,6 +822,35 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)
}
function renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
const currentValue = value?.trim() ?? ''
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
assetOptions.unshift({ label: `Current file (${currentValue})`, value: currentValue })
}
if (assetOptions.length === 0) return null
return (
<div className="admin-field-asset-picker">
<label htmlFor={fieldId}>Choose an existing file</label>
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
<option value="">Select file</option>
{assetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
function isImageAsset(filename: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
@@ -792,7 +883,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'resources' as const },
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const },
],
}))
}
@@ -808,6 +899,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
}
function moveLinkToResources(id: string) {
setForm(f => ({
...f,
customLinks: (f.customLinks ?? []).map(link => (
link.id === id ? { ...link, placement: 'resources' as const } : link
)),
}))
}
function addBlock() {
setForm(f => ({
...f,
@@ -870,7 +970,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
...series,
resourceLinks: [
...(series.resourceLinks ?? []),
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', url: '' },
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '' },
],
}
: series),
@@ -923,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{
id: `${seriesId}-${Date.now().toString(36)}`,
label: source.label,
description: source.description ?? '',
url: source.url,
},
],
@@ -949,6 +1050,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
.map(link => ({
id: `${seriesId}-${Date.now().toString(36)}-${link.id}`,
label: link.label,
description: link.description ?? '',
url: link.url,
}))
@@ -1114,6 +1216,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
alert('Failed to delete question')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
return count + (series.resourceLinks ?? []).length
}, 0)
return (
<div className="admin-page">
<div className="admin-header">
@@ -1301,7 +1409,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<p className="admin-stats-note">No episode highlights yet. Add one below.</p>
)}
{(form.podcastFeaturedLinks ?? []).map(item => (
<div key={item.id} className="admin-array-row">
<details key={item.id} className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>{item.title || 'Untitled episode highlight'}</strong>
<p>{item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
@@ -1334,6 +1451,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div>
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
</div>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
@@ -1459,6 +1578,75 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
{status === 'saved' && <p className="admin-status admin-status--ok"> Changes saved.</p>}
{status === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Two-Factor Authentication</h2>
<p>Require a time-based one-time code from an authenticator app on every login.</p>
</div>
{totpEnabled === null && <p className="admin-stats-note">Loading</p>}
{totpEnabled === false && !totpSetupQr && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note">2FA is currently <strong>off</strong>. Enable it to require an authenticator app (Google Authenticator, Authy, etc.) at every login.</p>
<button type="button" className="btn-primary" onClick={handleTotpSetupInit}>Enable 2FA</button>
</div>
</div>
)}
{totpSetupQr && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note">Scan this QR code with your authenticator app, then enter the 6-digit code below to confirm.</p>
<img src={totpSetupQr} alt="TOTP QR code" style={{ width: 200, height: 200, display: 'block', margin: '0.5rem 0' }} />
{totpSetupSecret && <p className="admin-stats-note" style={{ wordBreak: 'break-all' }}>Manual entry key: <code>{totpSetupSecret}</code></p>}
<form onSubmit={handleTotpSetupConfirm} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="totp-confirm-code">Confirmation Code</label>
<input
id="totp-confirm-code"
type="text"
inputMode="numeric"
value={totpConfirmCode}
onChange={e => setTotpConfirmCode(e.target.value)}
placeholder="000000"
autoFocus
required
/>
</div>
<button type="submit" className="btn-primary">Confirm &amp; Enable</button>
<button type="button" className="btn-secondary" onClick={() => { setTotpSetupQr(null); setTotpSetupSecret(null) }}>Cancel</button>
</form>
</div>
</div>
)}
{totpEnabled === true && !totpSetupQr && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note">2FA is currently <strong>on</strong>. A code from your authenticator app is required at every login.</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-reset" onClick={handleTotpRegenRecovery}>Regenerate Recovery Codes</button>
<button type="button" className="btn-admin-remove" onClick={handleTotpDisable}>Disable 2FA</button>
</div>
</div>
</div>
)}
{totpRecoveryCodes && (
<div className="admin-array-row">
<div className="admin-array-fields">
<p className="admin-stats-note"><strong>Save these recovery codes somewhere safe.</strong> Each can be used once instead of the 6-digit code if you lose access to your authenticator app. They will not be shown again.</p>
<ul style={{ fontFamily: 'monospace', lineHeight: 2 }}>
{totpRecoveryCodes.map(c => <li key={c}>{c}</li>)}
</ul>
<button type="button" className="btn-secondary" onClick={() => setTotpRecoveryCodes(null)}>I've saved these</button>
</div>
</div>
)}
{totpMsg && <p className="admin-stats-note">{totpMsg}</p>}
</section>
)}
@@ -1471,8 +1659,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-actions admin-actions--maintenance">
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
{assetUploadPending ? 'Uploading...' : 'Upload File'}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif,application/pdf,.pdf,application/msword,.doc,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.docx" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
</label>
</div>
@@ -1494,7 +1682,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<tbody>
{assets.map(asset => (
<tr key={asset.filename}>
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
<td>
{isImageAsset(asset.filename)
? <img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} />
: <a href={asset.url} target="_blank" rel="noreferrer">{asset.filename}</a>}
</td>
<td>{asset.url}</td>
<td>
<input
@@ -1835,7 +2027,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
className={`admin-tab ${contentTab === 'resources' ? 'admin-tab--active' : ''}`}
onClick={() => handleContentTabChange('resources')}
>
Resources
Downloads
</button>
<button
type="button"
@@ -1908,20 +2100,104 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<>
<div className="admin-content-summary">
<div className="admin-summary-card">
<h3>Resource Links</h3>
<p>{(form.customLinks ?? []).filter(link => link.placement === 'resources').length}</p>
<h3>Companion Study Guide</h3>
<p>Included</p>
</div>
<div className="admin-summary-card">
<h3>Current Downloads</h3>
<p>{resourceLinks.length}</p>
</div>
<div className="admin-summary-card">
<h3>Previous Study Downloads</h3>
<p>{archivedResourceCount}</p>
</div>
<div className="admin-summary-card">
<h3>Total Library Items</h3>
<p>{resourceLinks.length + archivedResourceCount}</p>
</div>
</div>
<div className="admin-section-header">
<h3>More Resources</h3>
<p>Manage additional resources shown on the More Resources page.</p>
<h3>Companion Study Guide</h3>
<p>This is the featured primary download at the top of the Downloads page.</p>
</div>
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="resources-study-guide-title">Title</label>
<input
id="resources-study-guide-title"
type="text"
value={form.studyGuideTitle}
placeholder="Companion Study Guide"
onChange={e => handleChange('studyGuideTitle', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="resources-study-guide-description">Description</label>
<textarea
id="resources-study-guide-description"
rows={3}
value={form.studyGuideDescription}
placeholder="Describe the study guide download."
onChange={e => handleChange('studyGuideDescription', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="resources-series-image-url">Cover Image URL</label>
<input
id="resources-series-image-url"
type="text"
value={form.seriesImageUrl}
placeholder="/uploads/study-guide-cover.png"
onChange={e => handleChange('seriesImageUrl', e.target.value)}
/>
{renderImageAssetSelector(form.seriesImageUrl, value => handleChange('seriesImageUrl', value), 'resources-series-image-url-asset')}
</div>
<div className="admin-field">
<label htmlFor="resources-study-guide-download-url">Primary Download URL</label>
<input
id="resources-study-guide-download-url"
type="url"
value={form.studyGuideDownloadUrl}
placeholder="/uploads/new-guide.pdf or https://..."
onChange={e => handleChange('studyGuideDownloadUrl', e.target.value)}
/>
{renderFileAssetSelector(form.studyGuideDownloadUrl, value => handleChange('studyGuideDownloadUrl', value), 'resources-study-guide-download-url-asset')}
<p className="admin-stats-note">This is the file URL the main guide download form will deliver after submission.</p>
</div>
<div className="admin-field">
<label htmlFor="resources-study-guide-url">Printed Copy URL (Amazon button)</label>
<input
id="resources-study-guide-url"
type="url"
value={form.studyGuideUrl}
placeholder="https://..."
onChange={e => handleChange('studyGuideUrl', e.target.value)}
/>
</div>
</div>
</div>
<div className="admin-section-header">
<h3>Download Library</h3>
<p>Manage the current downloads shown below the featured guide.</p>
</div>
{resourceLinks.length === 0 && (
<p className="admin-stats-note">No resources yet.</p>
)}
{(form.customLinks ?? []).filter(link => link.placement === 'resources').map(link => (
<div key={link.id} className="admin-array-row">
{resourceLinks.map(link => (
<details key={link.id} className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>{link.label || 'Untitled download'}</strong>
<p>{link.description || 'Current download item'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<p className="admin-stats-note">Source: Custom link</p>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resource-label-${link.id}`}>Label</label>
@@ -1943,6 +2219,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
onChange={e => updateLink(link.id, 'url', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resource-description-${link.id}`}>Description</label>
<textarea
id={`resource-description-${link.id}`}
rows={3}
value={link.description ?? ''}
placeholder="Short description shown on the download page"
onChange={e => updateLink(link.id, 'description', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resource-image-${link.id}`}>Image URL</label>
<input
@@ -1969,10 +2255,95 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
Remove
</button>
</div>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addResource}>
+ Add Resource
+ Add Download
</button>
<div className="admin-section-header">
<h3>Previous Study Downloads</h3>
<p>These downloads appear in the Previous Studies area of the download library.</p>
</div>
{(form.archivedSeries ?? []).length === 0 && (
<p className="admin-stats-note">No archived series yet. Add one in Custom Content, then manage its downloads here.</p>
)}
{(form.archivedSeries ?? []).map(series => (
<details key={series.id} className="admin-collapsible-card admin-collapsible-card--group">
<summary className="admin-collapsible-summary">
<div>
<strong>{series.title || 'Untitled archived series'}</strong>
<p>{(series.resourceLinks ?? []).length} download{(series.resourceLinks ?? []).length === 1 ? '' : 's'}</p>
</div>
<span className="admin-collapsible-hint">Expand to manage</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>{series.title || 'Untitled archived series'}</h5>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>
+ Add Link
</button>
</div>
{(series.resourceLinks ?? []).length === 0 && (
<p className="admin-stats-note">No archived resource links yet.</p>
)}
{(series.resourceLinks ?? []).map(link => (
<details key={link.id} className="admin-collapsible-card admin-collapsible-card--nested">
<summary className="admin-collapsible-summary">
<div>
<strong>{link.label || 'Untitled archived download'}</strong>
<p>{link.description || 'Previous study download'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row admin-array-row--nested">
<p className="admin-stats-note">Source: Archived series</p>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resources-archive-link-label-${link.id}`}>Label</label>
<input
id={`resources-archive-link-label-${link.id}`}
type="text"
value={link.label}
placeholder="Episode guide"
onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-url-${link.id}`}>URL</label>
<input
id={`resources-archive-link-url-${link.id}`}
type="url"
value={link.url}
placeholder="https://..."
onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-description-${link.id}`}>Description</label>
<textarea
id={`resources-archive-link-description-${link.id}`}
rows={3}
value={link.description ?? ''}
placeholder="Short description shown on the download page"
onChange={e => updateArchivedSeriesLink(series.id, link.id, 'description', e.target.value)}
/>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>
Remove
</button>
</div>
</div>
</details>
))}
</div>
</div>
</details>
))}
</>
)}
@@ -2056,6 +2427,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<option value="resources">More Resources Section</option>
</select>
</div>
<div className="admin-field">
<label>Quick Action</label>
<button
type="button"
className="btn-admin-apply"
onClick={() => moveLinkToResources(link.id)}
>
Move to Resources
</button>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
Remove
+135 -393
View File
@@ -919,93 +919,6 @@
margin-bottom: 0.75rem;
}
/* ── Contact ── */
.section-chatbot-feature {
padding: 5rem 0;
background:
radial-gradient(circle at top left, rgba(201, 168, 76, 0.14), transparent 38%),
linear-gradient(180deg, rgba(22, 18, 10, 0.96), rgba(11, 11, 11, 0.98));
border-top: 1px solid rgba(201, 168, 76, 0.18);
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
}
.chatbot-feature-inner {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
gap: 2rem;
align-items: stretch;
}
.chatbot-feature-copy,
.chatbot-feature-card {
background: rgba(18, 18, 18, 0.84);
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 18px;
padding: 1.7rem;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
}
.chatbot-feature-copy .section-heading {
text-align: left;
margin-bottom: 1rem;
}
.chatbot-feature-lead,
.chatbot-feature-sub,
.chatbot-feature-kicker {
font-family: var(--brand-font-body);
margin: 0;
color: #d9c9a0;
}
.chatbot-feature-lead {
font-size: clamp(1.2rem, 2vw, 1.55rem);
line-height: 1.45;
color: var(--brand-warm-white);
text-wrap: balance;
}
.chatbot-feature-sub {
margin-top: 0.85rem;
font-size: 1.02rem;
line-height: 1.65;
color: #ab9568;
}
.chatbot-feature-actions {
margin-top: 1.35rem;
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.chatbot-feature-card {
display: flex;
flex-direction: column;
justify-content: center;
}
.chatbot-feature-kicker {
font-size: 0.82rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--brand-gold);
margin-bottom: 1rem;
}
.chatbot-feature-prompts {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.chatbot-prompt-btn--feature {
font-size: 0.86rem;
padding: 0.55rem 0.9rem;
text-align: left;
color: #efd8a1;
}
.section-contact {
background: #090909;
border-top: 1px solid rgba(201, 168, 76, 0.18);
@@ -1539,6 +1452,65 @@
letter-spacing: 0.03em;
}
.section-download-library {
background:
radial-gradient(circle at top center, rgba(201, 168, 76, 0.1), transparent 26%),
var(--brand-black);
}
.download-library-head {
max-width: 760px;
margin: 0 auto 2rem;
text-align: center;
}
.download-library-copy {
font-family: var(--brand-font-body);
font-size: 1.05rem;
line-height: 1.7;
color: var(--brand-muted);
margin: 0.85rem auto 0;
}
.download-library-group {
background: rgba(17, 17, 17, 0.82);
border: 1px solid rgba(201, 168, 76, 0.14);
border-radius: 24px;
padding: 1.5rem;
}
.download-library-group + .download-library-group {
margin-top: 1.5rem;
}
.download-library-group-head {
margin-bottom: 1rem;
}
.download-library-group-head h3,
.download-library-series-head h4 {
margin: 0;
font-family: var(--brand-font-heading);
color: var(--brand-warm-white);
font-size: clamp(1.25rem, 2vw, 1.7rem);
}
.download-library-group-head p,
.download-library-series-head p {
margin: 0.45rem 0 0;
font-family: var(--brand-font-body);
color: var(--brand-muted);
line-height: 1.65;
}
.download-library-series-head {
margin: 1.35rem 0 0.9rem;
}
.download-library-series-head:first-child {
margin-top: 0;
}
/* ── Custom content blocks ── */
.section-custom-block {
background: #0d0d0d;
@@ -1600,6 +1572,17 @@
font-weight: 700;
}
.guide-page-title {
font-family: var(--brand-font-heading);
font-size: clamp(2rem, 3.6vw, 3rem);
color: var(--brand-warm-white);
margin: 0.1rem 0 0.85rem;
}
.guide-page-intro {
max-width: 52ch;
}
.guide-text p {
font-family: var(--brand-font-body);
font-weight: 300;
@@ -2749,6 +2732,71 @@
margin-bottom: 0.75rem;
}
.admin-collapsible-card {
margin-bottom: 0.85rem;
border: 1px solid rgba(201, 168, 76, 0.15);
border-radius: 10px;
background: #0d0d0d;
overflow: hidden;
}
.admin-collapsible-card[open] {
border-color: rgba(201, 168, 76, 0.28);
}
.admin-collapsible-card--group {
margin-top: 0.75rem;
}
.admin-collapsible-card--nested {
margin-bottom: 0.75rem;
}
.admin-collapsible-summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 1rem;
cursor: pointer;
}
.admin-collapsible-summary::-webkit-details-marker {
display: none;
}
.admin-collapsible-summary strong {
display: block;
font-family: var(--brand-font-heading);
color: var(--brand-warm-white);
font-size: 1.05rem;
}
.admin-collapsible-summary p {
margin: 0.2rem 0 0;
font-family: var(--brand-font-body);
color: var(--brand-muted);
font-size: 0.92rem;
}
.admin-collapsible-hint {
flex-shrink: 0;
font-family: var(--brand-font-body);
color: var(--brand-gold);
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.admin-collapsible-body {
padding: 0 1rem 1rem;
}
.admin-collapsible-body .admin-array-row {
margin-bottom: 0;
}
.back-to-top {
position: fixed;
right: 1.5rem;
@@ -2928,19 +2976,6 @@
gap: 1.5rem;
}
.chatbot-feature-inner {
grid-template-columns: 1fr;
}
.chatbot-feature-copy .section-heading {
text-align: center;
}
.chatbot-feature-actions,
.chatbot-feature-prompts {
justify-content: center;
}
.contact-card {
max-width: 100%;
padding: 1.45rem;
@@ -2981,7 +3016,6 @@
.section-series,
.section-guide,
.section-home-jump,
.section-chatbot-feature,
.section-contact {
padding: 3.5rem 0;
}
@@ -3380,298 +3414,6 @@
cursor: pointer;
transition: all 0.2s ease;
}
/* ══════════════════════════════════════════════════════════
FLOATING CHATBOT
══════════════════════════════════════════════════════════ */
/* Bubble trigger */
.chatbot-bubble {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--brand-gold);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
transition: background 0.2s, transform 0.2s;
}
.chatbot-bubble:hover {
background: #e8a91a;
transform: scale(1.08);
}
.chatbot-bubble--open {
background: #555;
}
/* Panel */
.chatbot-panel {
position: fixed;
bottom: 5.5rem;
right: 1.5rem;
z-index: 999;
width: min(360px, calc(100vw - 2rem));
max-height: min(520px, calc(100vh - 8rem));
display: flex;
flex-direction: column;
background: #1a1a1a;
border: 1px solid rgba(201, 168, 76, 0.35);
border-radius: 0.75rem;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
overflow: hidden;
animation: chatSlideUp 0.22s ease;
}
.chatbot-page {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(201, 168, 76, 0.14), transparent 40%),
var(--brand-black);
padding: 1.25rem;
}
.chatbot-panel--standalone {
position: relative;
inset: auto;
right: auto;
bottom: auto;
width: min(780px, 100%);
max-height: calc(100vh - 2.5rem);
min-height: calc(100vh - 2.5rem);
margin: 0 auto;
}
.chatbot-panel--standalone .chatbot-messages {
padding: 1rem 1.1rem;
}
.chatbot-panel--standalone .chatbot-msg {
max-width: 92%;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Header */
.chatbot-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
background: rgba(201, 168, 76, 0.12);
border-bottom: 1px solid rgba(201, 168, 76, 0.25);
font-size: 0.9rem;
font-weight: 600;
color: #e8c87a;
}
.chatbot-header-actions {
display: flex;
align-items: center;
gap: 0.55rem;
}
.chatbot-header-btn {
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.28);
color: #d9bc7a;
border-radius: 999px;
padding: 0.28rem 0.72rem;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chatbot-header-btn:hover {
background: rgba(201, 168, 76, 0.22);
border-color: rgba(201, 168, 76, 0.5);
color: #f4ddb0;
}
.chatbot-header-btn--link {
display: inline-flex;
align-items: center;
}
.chatbot-close {
background: none;
border: none;
color: var(--brand-muted);
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0 0.25rem;
transition: color 0.15s;
}
.chatbot-close:hover { color: #e8c87a; }
/* Message list */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.chatbot-msg {
max-width: 85%;
padding: 0.6rem 0.85rem;
border-radius: 0.65rem;
font-size: 0.88rem;
line-height: 1.5;
}
.chatbot-msg-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.85rem;
}
.chatbot-msg p {
margin: 0;
}
.chatbot-msg p + p {
margin-top: 0.4rem;
}
.chatbot-msg--bot {
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.2);
color: #e8d8b0;
align-self: flex-start;
border-bottom-left-radius: 0.15rem;
}
.chatbot-msg--user {
background: rgba(80, 80, 80, 0.45);
color: #ddd;
align-self: flex-end;
border-bottom-right-radius: 0.15rem;
}
/* Typing dots */
.chatbot-msg--typing {
display: flex;
gap: 0.3rem;
align-items: center;
padding: 0.75rem 1rem;
}
.chatbot-msg--typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-gold);
animation: typingDot 1.2s infinite;
}
.chatbot-msg--typing span:nth-child(2) { animation-delay: 0.2s; }
.chatbot-msg--typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingDot {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1.1); }
}
/* Suggested prompts */
.chatbot-prompts {
padding: 0 0.75rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chatbot-prompt-btn {
background: rgba(201, 168, 76, 0.1);
border: 1px solid rgba(201, 168, 76, 0.3);
color: var(--brand-gold);
border-radius: 1rem;
padding: 0.3rem 0.75rem;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.chatbot-prompt-btn:hover {
background: rgba(201, 168, 76, 0.22);
border-color: var(--brand-gold);
}
/* Input row */
.chatbot-form {
display: flex;
align-items: center;
border-top: 1px solid rgba(201, 168, 76, 0.2);
padding: 0.6rem 0.75rem;
gap: 0.5rem;
}
.chatbot-style-select {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(201, 168, 76, 0.25);
color: #d9cba8;
border-radius: 0.4rem;
padding: 0.45rem 0.5rem;
font-size: 0.78rem;
outline: none;
max-width: 6.8rem;
}
.chatbot-style-select:focus {
border-color: var(--brand-gold);
}
.chatbot-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 0.4rem;
color: #e8d8b0;
padding: 0.45rem 0.75rem;
font-size: 0.88rem;
outline: none;
transition: border-color 0.2s;
}
.chatbot-input::placeholder { color: #7a6a50; }
.chatbot-input:focus { border-color: var(--brand-gold); }
.chatbot-send {
background: var(--brand-gold);
border: none;
border-radius: 0.4rem;
color: #fff;
width: 2.2rem;
height: 2.2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.chatbot-send:hover:not(:disabled) { background: #e8a91a; }
.chatbot-send:disabled { opacity: 0.35; cursor: default; }
/* New here page cards */
.start-grid {
display: grid;
+267 -47
View File
@@ -18,13 +18,14 @@ const AMAZON_MUSIC_URL = '/amazon'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function StudyDownloadForm() {
function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
@@ -35,6 +36,7 @@ function StudyDownloadForm() {
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/study-downloads/titus', {
@@ -52,6 +54,7 @@ function StudyDownloadForm() {
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
@@ -94,8 +97,13 @@ function StudyDownloadForm() {
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
{status === 'success' && downloadUrl && (
<p className="study-download-success">
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : 'Download Titus Study'}
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
</form>
)
@@ -108,6 +116,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
@@ -118,6 +127,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/resource-download', {
@@ -135,6 +145,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
@@ -177,6 +188,11 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
{status === 'success' && downloadUrl && (
<p className="study-download-success">
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
@@ -184,6 +200,71 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
)
}
function buildCustomResourceDownloadId(id: string) {
return `custom:${id}`
}
function buildArchivedResourceDownloadId(seriesId: string, linkId: string) {
return `archived:${seriesId}:${linkId}`
}
function buildCustomDownloadPageId(id: string) {
return `custom--${id}`
}
function buildArchivedDownloadPageId(seriesId: string, linkId: string) {
return `archived--${seriesId}--${linkId}`
}
interface DownloadPageResource {
id: string
label: string
imageUrl?: string
summary: string
tags: string[]
buttonText: string
resourceId: string
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
if (!resource) return null
return {
id: pageId,
label: resource.label,
imageUrl: resource.imageUrl,
summary: resource.description || 'Complete the short form below and your download will start right away.',
tags: resource.tags ?? [],
buttonText: `Download ${resource.label}`,
resourceId: buildCustomResourceDownloadId(resource.id),
}
}
if (pageId.startsWith('archived--')) {
const [, seriesId, linkId] = pageId.split('--')
const series = (content.archivedSeries ?? []).find(item => item.id === seriesId)
const link = (series?.resourceLinks ?? []).find(item => item.id === linkId)
if (!series || !link) return null
return {
id: pageId,
label: link.label || series.title || 'Download Resource',
imageUrl: series.imageUrl,
summary: link.description || series.description || 'Fill out the form below to access this download from a previous study.',
tags: [],
buttonText: `Download ${link.label || series.title || 'Resource'}`,
resourceId: buildArchivedResourceDownloadId(series.id, link.id),
}
}
return null
}
function AnalyticsConsentBanner() {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
@@ -341,7 +422,7 @@ function SiteHeader() {
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
<NavLink to="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Home</NavLink>
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</NavLink>
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
<a
@@ -473,10 +554,13 @@ function StudyGuideSection({ content }: { content: SiteContent }) {
/>
</div>
<div className="guide-text">
<p className="eyebrow">Downloads</p>
<h1 className="guide-page-title">Study Guides and Downloads</h1>
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
<p className="eyebrow">Free Download</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<StudyDownloadForm />
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
{content.studyGuideUrl && (
<div className="guide-actions">
<a
@@ -590,40 +674,132 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
)
}
function CustomResourcesSection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(l => l.placement === 'resources')
if (resources.length === 0) return null
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
if (resources.length === 0 && archivedWithResources.length === 0) return null
return (
<section className="section-resources" aria-label="More resources">
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> More Resources{' '}
<span className="ornament"></span>
</h2>
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2>
<p className="download-library-copy">Keep the main guide featured at the top, and use this library for every other download you want available on the page.</p>
</div>
{resources.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Current Downloads</h3>
<p>Extra files you want people to grab right now.</p>
</div>
<div className="resources-list">
{resources.map(resource => (
<article key={resource.id} className="resource-download-card">
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{resource.description && <p className="resource-link-description">{resource.description}</p>}
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<span className="resource-link-action">Open download page </span>
</div>
</div>
<ResourceDownloadForm resourceId={resource.id} buttonText={`Download ${resource.label}`} />
</article>
</Link>
))}
</div>
</div>
)}
{archivedWithResources.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Previous Studies</h3>
<p>Downloads from earlier series that you still want available.</p>
</div>
{archivedWithResources.map(series => (
<div key={series.id} className="archive-series-resources">
<div className="download-library-series-head">
<h4>{series.title || 'Archived Study'}</h4>
{series.description && <p>{series.description}</p>}
</div>
<div className="resources-list">
{(series.resourceLinks ?? []).map(link => (
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{series.imageUrl && (
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
{link.description && <p className="resource-link-description">{link.description}</p>}
<span className="resource-link-action">Open download page </span>
</div>
</div>
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
</section>
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
if (!resource) {
return (
<main className="thanks-page" aria-label="Download not found">
<div className="thanks-card">
<p className="eyebrow">Downloads</p>
<h1>Download not found</h1>
<p>The download you requested is not available right now.</p>
<Link to="/resources" className="btn-primary">Back to Downloads</Link>
</div>
</main>
)
}
return (
<main className="thanks-page download-detail-page" aria-label={resource.label}>
<div className="thanks-card download-detail-card">
<p className="eyebrow">Downloads</p>
<h1>{resource.label}</h1>
<p>{resource.summary}</p>
{resource.imageUrl && (
<div className="download-detail-art">
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
</div>
)}
{resource.tags.length > 0 && (
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
)}
<div className="download-detail-form-wrap">
<ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />
</div>
<div className="download-detail-actions">
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div>
</div>
</main>
)
}
function CustomBlocksSection({ content }: { content: SiteContent }) {
return (
<>
@@ -703,7 +879,7 @@ function LandingPage({ content }: { content: SiteContent }) {
<SpotifyIcon />
Listen to Series
</a>
<Link to="/resources" className="btn-secondary">View Study Resources</Link>
<Link to="/resources" className="btn-secondary">View Downloads</Link>
</div>
</article>
</div>
@@ -722,8 +898,8 @@ function LandingPage({ content }: { content: SiteContent }) {
<p>Listen to latest episodes and platform links.</p>
</Link>
<Link to="/resources" className="home-jump-card">
<h3>Resources</h3>
<p>Study guide, links, and archived study resources.</p>
<h3>Downloads</h3>
<p>Main guide, extra downloads, and past study files.</p>
</Link>
<Link to="/questions" className="home-jump-card">
<h3>Q&amp;A</h3>
@@ -851,36 +1027,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
}
function ResourcesPage({ content }: { content: SiteContent }) {
const archivedSeries = content.archivedSeries ?? []
return (
<div className="site">
<SiteHeader />
<StudyGuideSection content={content} />
<CustomResourcesSection content={content} />
{archivedSeries.length > 0 && (
<section className="section-resources" aria-label="Archived studies">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Archived Studies{' '}
<span className="ornament">✦</span>
</h2>
<div className="resources-list">
{archivedSeries.map(series => (
<a
key={series.id}
href={series.listenUrl || SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="resource-link"
>
{series.title} {series.description ? `— ${series.description}` : ''}
</a>
))}
</div>
</div>
</section>
)}
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
@@ -1125,6 +1276,10 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
// TOTP two-step state
const [totpRequired, setTotpRequired] = useState(false)
const [pendingToken, setPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
useEffect(() => {
fetch('/api/admin-auth/status')
@@ -1152,9 +1307,16 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Login failed.')
setErrorMsg(data.message ?? 'Login failed.')
setSubmitting(false)
return
}
if (data.totpRequired && data.pendingToken) {
setPendingToken(data.pendingToken)
setTotpRequired(true)
setPassword('')
setSubmitting(false)
return
}
@@ -1167,11 +1329,45 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
}
}
async function handleTotpVerify(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken, code: totpCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; usedRecoveryCode?: boolean; remainingRecoveryCodes?: number; message?: string }
if (!res.ok) {
setErrorMsg(data.message ?? 'Invalid code.')
setSubmitting(false)
return
}
setStatus('authenticated')
setTotpCode('')
} catch {
setErrorMsg('Verification failed.')
} finally {
setSubmitting(false)
}
}
function handleBackToPassword() {
setTotpRequired(false)
setPendingToken('')
setTotpCode('')
setErrorMsg('')
}
async function handleLogout() {
try {
await fetch('/api/admin-auth/logout', { method: 'POST' })
} finally {
setStatus('unauthenticated')
setTotpRequired(false)
setPendingToken('')
}
}
@@ -1188,7 +1384,7 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
{status === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
)}
{status === 'unauthenticated' && (
{status === 'unauthenticated' && !totpRequired && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>
Password
@@ -1207,6 +1403,29 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
{status === 'unauthenticated' && totpRequired && (
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
<label>
Code
<input
type="text"
inputMode="numeric"
value={totpCode}
onChange={e => setTotpCode(e.target.value)}
autoComplete="one-time-code"
placeholder="000000 or XXXX-XXXX-XXXX"
autoFocus
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Verifying' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={handleBackToPassword}>Back</button>
</form>
)}
</div>
</main>
)
@@ -1350,6 +1569,7 @@ export default function App() {
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} />
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage />} />
+4
View File
@@ -4,6 +4,7 @@ export interface CustomLink {
url: string
placement: 'platforms' | 'footer' | 'resources'
imageUrl?: string
description?: string
tags?: string[]
}
@@ -16,6 +17,7 @@ export interface CustomBlock {
export interface ArchivedSeriesResourceLink {
id: string
label: string
description?: string
url: string
}
@@ -103,6 +105,7 @@ export interface SiteContent {
seriesListenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideDownloadUrl: string
studyGuideUrl: string
shareHeading: string
shareP: string
@@ -152,6 +155,7 @@ export const DEFAULTS: SiteContent = {
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
studyGuideDownloadUrl: '',
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',