Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening
This commit is contained in:
@@ -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/*
|
||||
|
||||
@@ -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
@@ -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 |
Generated
+316
-11
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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
@@ -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&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,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.',
|
||||
|
||||
Reference in New Issue
Block a user