Add host welcome/reset emails, email field, and /host URL; v2.0.7
- Host accounts now have an optional email field - Creating a host with an email sends a welcome email with the portal URL, username, and password via Resend - Resetting a password also emails the new credentials if the host has an email on file; admin sees a confirmation either way - Admin panel shows email column and confirmation messages - Portal URL changed from /host-upload to /host Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e396644a9
commit
a12a471c41
5 files changed
+90
-10
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.6",
|
"version": "2.0.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -764,3 +764,44 @@ export async function sendNuggetsUploadNotification({ to, source, hostName, epis
|
|||||||
console.error('[nuggets-notify] send exception:', err)
|
console.error('[nuggets-notify] send exception:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendNuggetsHostWelcomeEmail({ to, name, username, password, portalUrl }) {
|
||||||
|
if (!process.env.RESEND_API_KEY || !to) return
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||||
|
|
||||||
|
const bodyHtml = `
|
||||||
|
<p style="margin:0 0 14px;font-family:Arial,sans-serif;font-size:15px;color:#2c2417;">Hi ${escapeHtml(name)},</p>
|
||||||
|
<p style="margin:0 0 20px;font-family:Arial,sans-serif;font-size:15px;color:#2c2417;">
|
||||||
|
You've been set up as a host on the <strong>Nuggets</strong> episode upload portal.
|
||||||
|
Use the details below to sign in and submit your episodes.
|
||||||
|
</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;margin-bottom:20px;">
|
||||||
|
<tr><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;border-radius:4px;color:#7a7060;width:110px;">Portal URL</td><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;"><a href="${escapeHtml(portalUrl)}" style="color:#c8a96e;">${escapeHtml(portalUrl)}</a></td></tr>
|
||||||
|
<tr><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;color:#7a7060;">Username</td><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;font-family:monospace;font-weight:700;">${escapeHtml(username)}</td></tr>
|
||||||
|
<tr><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;color:#7a7060;">Password</td><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;font-family:monospace;font-weight:700;">${escapeHtml(password)}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;">When you're ready to submit an episode, fill in the episode name, the Bible passage, a short description, and attach your audio file. Everything uploads in one step.</p>
|
||||||
|
<p style="margin:0;font-family:Arial,sans-serif;font-size:14px;color:#7a7060;">If you have any trouble signing in, reply to this email and we'll get you sorted.</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from,
|
||||||
|
to: [to],
|
||||||
|
subject: 'Your Nuggets Host Upload Portal Access',
|
||||||
|
text: `Hi ${name},\n\nYou've been set up as a host on the Nuggets episode upload portal.\n\nPortal URL: ${portalUrl}\nUsername: ${username}\nPassword: ${password}\n\nWhen submitting an episode, fill in the episode name, Bible passage, a short description, and attach your audio file.\n\nIf you have any trouble, reply to this email.`,
|
||||||
|
html: buildBrandedEmailHtml({
|
||||||
|
title: 'Upload Portal Access',
|
||||||
|
eyebrow: 'Nuggets',
|
||||||
|
bodyHtml,
|
||||||
|
ctaLabel: 'Open Upload Portal',
|
||||||
|
ctaUrl: portalUrl,
|
||||||
|
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because an admin created an upload account for you on the Nuggets podcast portal.</p>',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (error) console.error('[nuggets-welcome] send error:', error)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nuggets-welcome] send exception:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import rateLimit from 'express-rate-limit'
|
|||||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
import { sendNuggetsUploadNotification } from '../email.js'
|
import { sendNuggetsUploadNotification, sendNuggetsHostWelcomeEmail } from '../email.js'
|
||||||
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||||
import {
|
import {
|
||||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||||
@@ -281,6 +281,7 @@ export function register(app) {
|
|||||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
||||||
const username = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : ''
|
const username = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : ''
|
||||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||||
|
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||||
|
|
||||||
if (!name || !username || !password) {
|
if (!name || !username || !password) {
|
||||||
res.status(400).json({ message: 'Name, username, and password are required.' })
|
res.status(400).json({ message: 'Name, username, and password are required.' })
|
||||||
@@ -301,6 +302,7 @@ export function register(app) {
|
|||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
name,
|
name,
|
||||||
username,
|
username,
|
||||||
|
email,
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
active: true,
|
active: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -308,8 +310,19 @@ export function register(app) {
|
|||||||
data.hosts.push(host)
|
data.hosts.push(host)
|
||||||
await saveUploadHosts(data)
|
await saveUploadHosts(data)
|
||||||
|
|
||||||
|
// Send welcome email if an address was provided
|
||||||
|
if (email) {
|
||||||
|
sendNuggetsHostWelcomeEmail({
|
||||||
|
to: email,
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`,
|
||||||
|
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
|
||||||
|
}
|
||||||
|
|
||||||
const { passwordHash: _ph, ...safeHost } = host
|
const { passwordHash: _ph, ...safeHost } = host
|
||||||
res.json({ ok: true, host: safeHost })
|
res.json({ ok: true, host: safeHost, emailSent: Boolean(email) })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||||
@@ -332,11 +345,23 @@ export function register(app) {
|
|||||||
patch.passwordHash = hashPassword(req.body.password)
|
patch.passwordHash = hashPassword(req.body.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newPassword = typeof req.body?.password === 'string' && req.body.password.length > 0 ? req.body.password : null
|
||||||
data.hosts[idx] = { ...data.hosts[idx], ...patch }
|
data.hosts[idx] = { ...data.hosts[idx], ...patch }
|
||||||
await saveUploadHosts(data)
|
await saveUploadHosts(data)
|
||||||
|
|
||||||
|
// Email the host their new password if they have an address on file
|
||||||
|
if (newPassword && data.hosts[idx].email) {
|
||||||
|
sendNuggetsHostWelcomeEmail({
|
||||||
|
to: data.hosts[idx].email,
|
||||||
|
name: data.hosts[idx].name,
|
||||||
|
username: data.hosts[idx].username,
|
||||||
|
password: newPassword,
|
||||||
|
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`,
|
||||||
|
}).catch(err => console.error('[nuggets-host] reset email error:', err))
|
||||||
|
}
|
||||||
|
|
||||||
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
|
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
|
||||||
res.json({ ok: true, host: safeHost })
|
res.json({ ok: true, host: safeHost, emailSent: Boolean(newPassword && data.hosts[idx].email) })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||||
|
|||||||
+19
-5
@@ -1101,6 +1101,7 @@ interface UploadHost {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
username: string
|
username: string
|
||||||
|
email: string
|
||||||
active: boolean
|
active: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
@@ -1111,13 +1112,16 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [addName, setAddName] = useState('')
|
const [addName, setAddName] = useState('')
|
||||||
const [addUsername, setAddUsername] = useState('')
|
const [addUsername, setAddUsername] = useState('')
|
||||||
|
const [addEmail, setAddEmail] = useState('')
|
||||||
const [addPassword, setAddPassword] = useState('')
|
const [addPassword, setAddPassword] = useState('')
|
||||||
const [addError, setAddError] = useState('')
|
const [addError, setAddError] = useState('')
|
||||||
const [addSaving, setAddSaving] = useState(false)
|
const [addSaving, setAddSaving] = useState(false)
|
||||||
|
const [addEmailSent, setAddEmailSent] = useState(false)
|
||||||
const [resetId, setResetId] = useState<string | null>(null)
|
const [resetId, setResetId] = useState<string | null>(null)
|
||||||
const [resetPw, setResetPw] = useState('')
|
const [resetPw, setResetPw] = useState('')
|
||||||
const [resetError, setResetError] = useState('')
|
const [resetError, setResetError] = useState('')
|
||||||
const [resetSaving, setResetSaving] = useState(false)
|
const [resetSaving, setResetSaving] = useState(false)
|
||||||
|
const [resetEmailSent, setResetEmailSent] = useState(false)
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -1138,11 +1142,12 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
const r = await fetch('/api/admin/nuggets-upload/hosts', {
|
const r = await fetch('/api/admin/nuggets-upload/hosts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: addName, username: addUsername, password: addPassword }),
|
body: JSON.stringify({ name: addName, username: addUsername, email: addEmail, password: addPassword }),
|
||||||
})
|
})
|
||||||
const d = await r.json()
|
const d = await r.json()
|
||||||
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
|
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
|
||||||
setAddName(''); setAddUsername(''); setAddPassword('')
|
setAddEmailSent(Boolean(d.emailSent))
|
||||||
|
setAddName(''); setAddUsername(''); setAddEmail(''); setAddPassword('')
|
||||||
reload()
|
reload()
|
||||||
} finally {
|
} finally {
|
||||||
setAddSaving(false)
|
setAddSaving(false)
|
||||||
@@ -1177,6 +1182,7 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
})
|
})
|
||||||
const d = await r.json()
|
const d = await r.json()
|
||||||
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
|
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
|
||||||
|
setResetEmailSent(Boolean(d.emailSent))
|
||||||
setResetId(null); setResetPw('')
|
setResetId(null); setResetPw('')
|
||||||
reload()
|
reload()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1190,7 +1196,7 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
<h2>Nuggets — Upload Portal</h2>
|
<h2>Nuggets — Upload Portal</h2>
|
||||||
<p>
|
<p>
|
||||||
Manage host login accounts for the upload portal at{' '}
|
Manage host login accounts for the upload portal at{' '}
|
||||||
<a href="/host-upload" target="_blank" rel="noopener noreferrer">/host-upload</a>.
|
<a href="/host" target="_blank" rel="noopener noreferrer">/host</a>.
|
||||||
Each host gets a username and password to upload episode files to Seafile.
|
Each host gets a username and password to upload episode files to Seafile.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
|
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
|
||||||
@@ -1207,6 +1213,7 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
|
||||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Username</th>
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Username</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Email</th>
|
||||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
|
||||||
<th style={{ padding: '6px 8px' }}></th>
|
<th style={{ padding: '6px 8px' }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1216,6 +1223,7 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
<td style={{ padding: '7px 8px' }}>{h.name}</td>
|
<td style={{ padding: '7px 8px' }}>{h.name}</td>
|
||||||
<td style={{ padding: '7px 8px', fontFamily: 'monospace' }}>{h.username}</td>
|
<td style={{ padding: '7px 8px', fontFamily: 'monospace' }}>{h.username}</td>
|
||||||
|
<td style={{ padding: '7px 8px', fontSize: '0.82rem', color: '#888' }}>{h.email || <em>none</em>}</td>
|
||||||
<td style={{ padding: '7px 8px' }}>
|
<td style={{ padding: '7px 8px' }}>
|
||||||
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
|
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -1249,6 +1257,7 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
|
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{resetEmailSent && !resetId && <p style={{ color: '#27ae60', fontSize: '0.85rem', marginBottom: 8 }}>✓ New password emailed to host.</p>}
|
||||||
|
|
||||||
<form onSubmit={addHost}>
|
<form onSubmit={addHost}>
|
||||||
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
|
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
|
||||||
@@ -1257,17 +1266,22 @@ function NuggetsUploadPortalAdmin() {
|
|||||||
<label>Display Name</label>
|
<label>Display Name</label>
|
||||||
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
|
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field" style={{ flex: '1 1 140px' }}>
|
<div className="admin-field" style={{ flex: '1 1 130px' }}>
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" placeholder="janesmith" value={addUsername} onChange={e => setAddUsername(e.target.value.toLowerCase())} required />
|
<input type="text" placeholder="janesmith" value={addUsername} onChange={e => setAddUsername(e.target.value.toLowerCase())} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field" style={{ flex: '1 1 160px' }}>
|
<div className="admin-field" style={{ flex: '1 1 180px' }}>
|
||||||
|
<label>Email (optional — sends welcome)</label>
|
||||||
|
<input type="email" placeholder="jane@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ flex: '1 1 150px' }}>
|
||||||
<label>Password (min 8 chars)</label>
|
<label>Password (min 8 chars)</label>
|
||||||
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
|
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
|
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
|
||||||
</div>
|
</div>
|
||||||
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
|
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
|
||||||
|
{addEmailSent && <p style={{ color: '#27ae60', marginTop: 6, fontSize: '0.85rem' }}>✓ Welcome email sent to host with login details.</p>}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -3140,7 +3140,7 @@ export default function App() {
|
|||||||
<Route path="/subscribe" element={<SubscribePage />} />
|
<Route path="/subscribe" element={<SubscribePage />} />
|
||||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||||
<Route path="/host-upload" element={<NuggetsHostPortal />} />
|
<Route path="/host" element={<NuggetsHostPortal />} />
|
||||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||||
<Route path="/preview" element={<PreviewPage />} />
|
<Route path="/preview" element={<PreviewPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
Reference in new issue
Block a user