Files
website/client/src/routes/admin/views/EmailDelivery.jsx
wtclaude 12d50fd615
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
chore(quality): resolve SonarQube code smells across website
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00

246 lines
8.8 KiB
JavaScript

import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
// captures a refresh token server-side — the token is write-only over the API
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
// requires the Google provider to be configured on the Authentication page first.
const STATUS_COLOR = {
connected: '#7fd0a4',
error: '#d98b84',
unconfigured: 'var(--muted)',
}
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
const ERROR_TEXT = {
denied: 'Google sign-in was cancelled or denied.',
bad_state: 'The connect session expired. Please try again.',
no_client: 'The Google OAuth client is not configured.',
no_refresh_token:
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
no_email: 'Could not read the Gmail address from Google.',
error: 'Could not connect the Gmail account. Please try again.',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'unconfigured'}
</span>
</div>
{config.senderEmail && (
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: 'var(--ink)' }}>
Sending as <strong>{config.senderEmail}</strong>
</p>
)}
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
{config.lastVerifiedAt && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Last verified: {new Date(config.lastVerifiedAt).toLocaleString()}
</p>
)}
</div>
)
}
export default function EmailDelivery() {
const { siteTitle } = useSite()
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [senderName, setSenderName] = useState('')
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('')
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
const load = useCallback(async (seedForm = false) => {
try {
const c = await api.admin.getEmailConfig()
setConfig(c)
if (seedForm) {
setSenderName(c.senderName || '')
setEnabled(c.enabled)
}
return c
} catch {
setError('Could not load email settings.')
return null
}
}, [])
// On mount, surface the outcome of a just-completed connect redirect, strip the
// query params so a refresh doesn't replay the banner, then load config.
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('email_connected')) {
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
} else if (params.has('email_error')) {
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
}
if (params.has('email_connected') || params.has('email_error')) {
params.delete('email_connected')
params.delete('email_error')
const qs = params.toString()
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
}
load(true)
}, [load])
async function connect() {
setBusy('connect')
setActionError('')
try {
const { url } = await api.admin.emailConnectUrl()
window.location.href = url
} catch (err) {
setActionError(err.message || 'Could not start the connect flow.')
setBusy('')
}
}
async function save() {
setBusy('save')
setMsg('')
setActionError('')
try {
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
setConfig(saved)
setMsg('Saved.')
} catch (err) {
setActionError(err.message || 'Could not save.')
} finally {
setBusy('')
}
}
async function sendTest() {
setBusy('test')
setMsg('')
setActionError('')
try {
const r = await api.admin.testEmail()
setMsg(`Test email sent to ${r.to}.`)
await load()
} catch (err) {
setActionError(err.message || 'Could not send the test email.')
} finally {
setBusy('')
}
}
async function disconnect() {
setBusy('disconnect')
setMsg('')
setActionError('')
try {
const c = await api.admin.disconnectEmail()
setConfig(c)
setEnabled(false)
setMsg('Disconnected.')
} catch (err) {
setActionError(err.message || 'Could not disconnect.')
} finally {
setBusy('')
}
}
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
if (!config) return null
const connected = config.hasRefreshToken
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
<div>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
Sends the contact form through Gmail over OAuth2, delivered to the
<strong> Contact email</strong> above. Reuses the Google authentication
client configure that on the Authentication page first.
</p>
</div>
{banner && (
<div
className="sans"
style={{
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
}}
>
{banner.text}
</div>
)}
<StatusPanel config={config} />
{!config.googleConfigured && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
The Google authentication provider needs a client ID and secret before
you can connect a Gmail account.
</p>
)}
{!connected ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
</button>
</div>
) : (
<>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
<button onClick={connect} disabled={busy === 'connect'} className="pill">
Reconnect
</button>
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
Disconnect
</button>
</div>
</>
)}
<div style={{ minHeight: 18 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{actionError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{actionError}</span>}
</div>
</section>
)
}