Security & hardening pass: scrypt password hashing, persisted admin sessions, upvote/analytics rate limits, dependency fixes, healthcheck, lint cleanup; v2.10.0
Publish Container / build (push) Successful in 17m3s
Publish Container / build (push) Successful in 17m3s
- Study passwords: salted scrypt; legacy SHA-256 hashes verified and upgraded on login - Admin sessions persisted (hashed tokens) so deploys no longer log the admin out - Upvotes: rate limit + one per client per question, approved questions only; rate limit on analytics play/event - npm audit: 46 -> 4 vulnerabilities (tiptap bumped to 3.31.4) - Untrack per-user data (study users/notes/progress/etc.) from git - Dockerfile HEALTHCHECK; node --test suite for password hashing - Fix email template cards remounting (lost input focus) on each keystroke - Lint: 34 errors -> 0 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2150e1cd7
commit
8a796964b9
27 files changed
+880
-1034
No files matched your search
+81
-70
@@ -23,7 +23,7 @@ function highlightLaneOf(link: PodcastFeaturedLink): HighlightLane {
|
||||
// Splits highlights into the ones belonging to a series (moved onto its Finished Book) and the rest (kept).
|
||||
function splitHighlightsForLane(links: PodcastFeaturedLink[] | undefined, lane: HighlightLane) {
|
||||
const all = links ?? []
|
||||
const ending = all.filter(link => highlightLaneOf(link) === lane).map(({ lane: _lane, ...link }) => link)
|
||||
const ending = all.filter(link => highlightLaneOf(link) === lane).map(link => { const copy = { ...link }; delete copy.lane; return copy })
|
||||
const kept = all.filter(link => highlightLaneOf(link) !== lane)
|
||||
return { ending, kept }
|
||||
}
|
||||
@@ -397,6 +397,72 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Module-level (not nested in EmailTemplatesPanel) so its identity is stable across
|
||||
// renders — otherwise every keystroke remounts the card and the focused input loses focus.
|
||||
function EmailTemplateCard({
|
||||
type, icon, title, trigger, autoVars, fieldKeys, openKey,
|
||||
form, handleChange, renderImageAssetSelector, renderImagePreview, expandedEmail, setExpandedEmail,
|
||||
}: {
|
||||
form: SiteContent
|
||||
handleChange: (key: StringField, value: string) => void
|
||||
renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode
|
||||
renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode
|
||||
expandedEmail: string | null
|
||||
setExpandedEmail: (key: string | null) => void
|
||||
type: 'newsletter' | 'study' | 'transactional'
|
||||
icon: string
|
||||
title: string
|
||||
trigger: string
|
||||
autoVars?: string
|
||||
fieldKeys: string[]
|
||||
openKey: string
|
||||
}) {
|
||||
const isOpen = expandedEmail === openKey
|
||||
return (
|
||||
<div className={`admin-et-card admin-et-card--${type}${isOpen ? ' admin-et-card--open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-et-card-header"
|
||||
onClick={() => setExpandedEmail(isOpen ? null : openKey)}
|
||||
>
|
||||
<span className="admin-et-card-icon" aria-hidden="true">{icon}</span>
|
||||
<div className="admin-et-card-meta">
|
||||
<div className="admin-et-card-title-row">
|
||||
<span className={`admin-et-badge admin-et-badge--${type}`}>{type}</span>
|
||||
<strong className="admin-et-card-title">{title}</strong>
|
||||
</div>
|
||||
<p className="admin-et-card-trigger">⚡ {trigger}</p>
|
||||
</div>
|
||||
<span className="admin-et-card-chevron">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="admin-et-card-body">
|
||||
{autoVars && (
|
||||
<p className="admin-et-card-vars">
|
||||
<span className="admin-et-card-vars-label">Auto-inserted:</span> {autoVars}
|
||||
</p>
|
||||
)}
|
||||
{fieldKeys.map(key => {
|
||||
const field = FIELDS.find(f => f.key === key && f.section === 'email-templates')
|
||||
if (!field) return null
|
||||
return (
|
||||
<div className="admin-field" key={key}>
|
||||
<label htmlFor={`field-et-${key}`}>{field.label}</label>
|
||||
{field.multiline
|
||||
? <textarea id={`field-et-${key}`} rows={3} value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||||
: <input id={`field-et-${key}`} type="text" value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||||
}
|
||||
{key.includes('ImageUrl') && renderImageAssetSelector(form[key as StringField] as string, value => handleChange(key as StringField, value), `field-et-${key}-asset`)}
|
||||
{key.includes('ImageUrl') && renderImagePreview(form[key as StringField] as string, `${field.label} preview`)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailTemplatesPanel({
|
||||
form,
|
||||
handleChange,
|
||||
@@ -412,62 +478,6 @@ function EmailTemplatesPanel({
|
||||
}) {
|
||||
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
|
||||
|
||||
function EmailTemplateCard({
|
||||
type, icon, title, trigger, autoVars, fieldKeys, openKey,
|
||||
}: {
|
||||
type: 'newsletter' | 'study' | 'transactional'
|
||||
icon: string
|
||||
title: string
|
||||
trigger: string
|
||||
autoVars?: string
|
||||
fieldKeys: string[]
|
||||
openKey: string
|
||||
}) {
|
||||
const isOpen = expandedEmail === openKey
|
||||
return (
|
||||
<div className={`admin-et-card admin-et-card--${type}${isOpen ? ' admin-et-card--open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-et-card-header"
|
||||
onClick={() => setExpandedEmail(isOpen ? null : openKey)}
|
||||
>
|
||||
<span className="admin-et-card-icon" aria-hidden="true">{icon}</span>
|
||||
<div className="admin-et-card-meta">
|
||||
<div className="admin-et-card-title-row">
|
||||
<span className={`admin-et-badge admin-et-badge--${type}`}>{type}</span>
|
||||
<strong className="admin-et-card-title">{title}</strong>
|
||||
</div>
|
||||
<p className="admin-et-card-trigger">⚡ {trigger}</p>
|
||||
</div>
|
||||
<span className="admin-et-card-chevron">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="admin-et-card-body">
|
||||
{autoVars && (
|
||||
<p className="admin-et-card-vars">
|
||||
<span className="admin-et-card-vars-label">Auto-inserted:</span> {autoVars}
|
||||
</p>
|
||||
)}
|
||||
{fieldKeys.map(key => {
|
||||
const field = FIELDS.find(f => f.key === key && f.section === 'email-templates')
|
||||
if (!field) return null
|
||||
return (
|
||||
<div className="admin-field" key={key}>
|
||||
<label htmlFor={`field-et-${key}`}>{field.label}</label>
|
||||
{field.multiline
|
||||
? <textarea id={`field-et-${key}`} rows={3} value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||||
: <input id={`field-et-${key}`} type="text" value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||||
}
|
||||
{key.includes('ImageUrl') && renderImageAssetSelector(form[key as StringField] as string, value => handleChange(key as StringField, value), `field-et-${key}-asset`)}
|
||||
{key.includes('ImageUrl') && renderImagePreview(form[key as StringField] as string, `${field.label} preview`)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Email Templates">
|
||||
@@ -476,79 +486,79 @@ function EmailTemplatesPanel({
|
||||
<p>Every automated email the site sends — edit subject lines, body copy, and sign-offs. Publish to apply changes.</p>
|
||||
</div>
|
||||
|
||||
<EmailTemplateCard type="newsletter" icon="📬" title="Newsletter Welcome"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="newsletter" icon="📬" title="Newsletter Welcome"
|
||||
trigger="Someone subscribes via the homepage form or contact page"
|
||||
autoVars="Subscriber's first name, Spotify / Apple / Amazon links"
|
||||
fieldKeys={['welcomeEmailSubject','welcomeEmailImageUrl','welcomeEmailGreetingPrefix','welcomeEmailIntro','welcomeEmailCurrentSeries','welcomeEmailStartHereTitle','welcomeEmailStartHereSummary','welcomeEmailStartHereUrl','welcomeEmailStartHereLinkLabel','welcomeEmailWhatToExpect1','welcomeEmailWhatToExpect2','welcomeEmailWhatToExpect3','welcomeEmailScripture','welcomeEmailScriptureRef','welcomeEmailSignoff','welcomeEmailSpotifyUrl','welcomeEmailSpotifyBtnLabel','welcomeEmailAppleUrl','welcomeEmailAppleBtnLabel','welcomeEmailAmazonUrl']}
|
||||
openKey="newsletter-welcome" />
|
||||
|
||||
<EmailTemplateCard type="study" icon="📖" title="Study Account — New Account"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="📖" title="Study Account — New Account"
|
||||
trigger="A student creates a study account"
|
||||
autoVars="Student's display name, link to study hub, link to account page"
|
||||
fieldKeys={['studyWelcomeEmailSubject','studyWelcomeEmailBody','studyWelcomeEmailCtaLabel','studyWelcomeEmailCtaPath','studyWelcomeEmailSignoff']}
|
||||
openKey="study-welcome" />
|
||||
|
||||
<EmailTemplateCard type="study" icon="🗑️" title="Study Account — Account Deleted"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="🗑️" title="Study Account — Account Deleted"
|
||||
trigger="A student (or admin) deletes a study account"
|
||||
autoVars="Student's display name, link to create a new account"
|
||||
fieldKeys={['studyDeletedEmailSubject','studyDeletedEmailBody','studyDeletedEmailCtaLabel','studyDeletedEmailCtaPath','studyDeletedEmailSignoff']}
|
||||
openKey="study-deleted" />
|
||||
|
||||
<EmailTemplateCard type="study" icon="🔔" title="Study Account — New Lesson Unlocked"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="🔔" title="Study Account — New Lesson Unlocked"
|
||||
trigger="A lesson releases for a student who has reminders turned on"
|
||||
autoVars="Student's name, lesson title, scripture reference, study track name — button links directly to the lesson (auto-generated, not editable)"
|
||||
fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']}
|
||||
openKey="study-reminder" />
|
||||
|
||||
<EmailTemplateCard type="study" icon="💤" title="Study Account — Re-engagement Reminders"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="💤" title="Study Account — Re-engagement Reminders"
|
||||
trigger="A student with reminders enabled hasn't logged in for the configured number of days (fires up to 3 tiers, one per check)"
|
||||
autoVars="Student's name, study title, study URL, and a one-click unsubscribe link are auto-inserted"
|
||||
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
|
||||
openKey="study-reengagement" />
|
||||
|
||||
<EmailTemplateCard type="study" icon="🏅" title="Study Account — Completion Certificate"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="🏅" title="Study Account — Completion Certificate"
|
||||
trigger="A student earns their certificate by completing all lessons in a study"
|
||||
autoVars="Student's name, study title, and a link to the public certificate are auto-inserted"
|
||||
fieldKeys={['studyCertificateEmailSubject','studyCertificateEmailBody','studyCertificateEmailCtaLabel','studyCertificateEmailSignoff']}
|
||||
openKey="study-certificate" />
|
||||
|
||||
<EmailTemplateCard type="study" icon="📅" title="Study Account — Weekly Lesson Digest"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="📅" title="Study Account — Weekly Lesson Digest"
|
||||
trigger="Students who choose 'Weekly Digest' mode receive one summary email per week listing all newly released lessons"
|
||||
autoVars="Student's name, lesson list (title, scripture reference, link), and a one-click unsubscribe link"
|
||||
fieldKeys={['studyWeeklyDigestEmailSubject','studyWeeklyDigestEmailBody','studyWeeklyDigestEmailSignoff']}
|
||||
openKey="study-weekly-digest" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
|
||||
trigger="A student requests to change their account email address"
|
||||
autoVars="Button links to a one-time verification URL (auto-generated per request, not editable)"
|
||||
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
||||
openKey="email-change" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="✅" title="Account Security — Verify Email Address"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="✅" title="Account Security — Verify Email Address"
|
||||
trigger="A student creates a study account (sent alongside the welcome email)"
|
||||
autoVars="Student's display name, button links to a one-time verification URL (auto-generated, expires in 7 days, not editable)"
|
||||
fieldKeys={['emailVerifySubject','emailVerifyBody','emailVerifyCtaLabel']}
|
||||
openKey="email-verify" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="🔑" title="Account Security — Password Reset"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🔑" title="Account Security — Password Reset"
|
||||
trigger="A student requests a password reset from the sign-in page"
|
||||
autoVars="Student's display name, button links to a one-time reset URL (auto-generated, expires in 1 hour, not editable)"
|
||||
fieldKeys={['passwordResetSubject','passwordResetBody','passwordResetCtaLabel']}
|
||||
openKey="password-reset" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="🛡️" title="Account Security — Password Changed"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🛡️" title="Account Security — Password Changed"
|
||||
trigger="A student's password is changed (self-service reset, in-account change, or admin reset) — confirms the change and that other sessions were signed out"
|
||||
autoVars="Student's display name, link to account settings"
|
||||
fieldKeys={['passwordChangedSubject','passwordChangedBody']}
|
||||
openKey="password-changed" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
||||
trigger="A student with email-based 2FA signs in, or requests a resend during login"
|
||||
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
|
||||
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
|
||||
openKey="2fa-otp" />
|
||||
|
||||
<EmailTemplateCard type="transactional" icon="💬" title="Q&A — Question Answered Notification"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="💬" title="Q&A — Question Answered Notification"
|
||||
trigger={'An admin approves a Q&A question submitted with "Email me when answered" checked'}
|
||||
autoVars={'Submitter\'s first name, their original question text, the answer (truncated to 600 chars), a direct link to the answer'}
|
||||
fieldKeys={['qaAnsweredEmailSubject','qaAnsweredEmailBody','qaAnsweredEmailCtaLabel','qaAnsweredEmailSignoff']}
|
||||
@@ -1971,6 +1981,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
||||
.catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-fetch is driven explicitly when the range changes
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user