chore: standardize security and quality foundations
This commit is contained in:
@@ -1,83 +1,223 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../lib/auth'
|
||||
import '../email-recaps/recaps.css'
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authFetch, getApiBase } from "../lib/auth";
|
||||
import "../email-recaps/recaps.css";
|
||||
|
||||
type Preference = { automatic_monthly: boolean; state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null }
|
||||
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
|
||||
type Preference = {
|
||||
automatic_monthly: boolean;
|
||||
state: "off" | "pending" | "expired" | "enabled";
|
||||
email: string | null;
|
||||
can_subscribe: boolean;
|
||||
detail: string;
|
||||
schedule_enabled: boolean;
|
||||
next_send_at: number | null;
|
||||
day: number;
|
||||
hour: number;
|
||||
resend_after: number | null;
|
||||
};
|
||||
const scheduled = (value: number) =>
|
||||
`${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
|
||||
|
||||
export default function MonthlyRecapPreference() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<Preference | null>(null)
|
||||
const [automatic, setAutomatic] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Preference | null>(null);
|
||||
const [automatic, setAutomatic] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
setError('')
|
||||
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => {
|
||||
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
|
||||
const result = await response.json() as Preference
|
||||
if (!abort.signal.aborted) { setData(result); setAutomatic(result.automatic_monthly) }
|
||||
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
||||
return () => abort.abort()
|
||||
}, [revision, router])
|
||||
void revision;
|
||||
const abort = new AbortController();
|
||||
setError("");
|
||||
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
|
||||
.then(async (response) => {
|
||||
if (response.status === 401) {
|
||||
router.replace("/login?next=%2Fprofile");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
|
||||
const result = (await response.json()) as Preference;
|
||||
if (!abort.signal.aborted) {
|
||||
setData(result);
|
||||
setAutomatic(result.automatic_monthly);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!abort.signal.aborted) setError(err.message);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [revision, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [data?.resend_after, data?.state])
|
||||
if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [data?.resend_after, data?.state]);
|
||||
|
||||
const save = async (enabled: boolean, monthly = automatic) => {
|
||||
if (busy) return
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, automatic_monthly: monthly }),
|
||||
})
|
||||
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||
const result = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
|
||||
setData(result); setAutomatic(result.automatic_monthly); setNow(Date.now())
|
||||
setNotice(result.message || (enabled ? 'Personal report emails are enabled.' : 'Personal report emails are off.'))
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled, automatic_monthly: monthly }),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
router.replace("/login?next=%2Fprofile");
|
||||
return;
|
||||
}
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
|
||||
setData(result);
|
||||
setAutomatic(result.automatic_monthly);
|
||||
setNow(Date.now());
|
||||
setNotice(
|
||||
result.message || (enabled ? "Personal report emails are enabled." : "Personal report emails are off."),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
|
||||
setError(err instanceof Error ? err.message : "Could not update your email preference.");
|
||||
// A confirmation may be pending even if SMTP could not confirm delivery.
|
||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null)
|
||||
if (response?.ok) { const fresh = await response.json(); setData(fresh); setAutomatic(fresh.automatic_monthly); setNow(Date.now()) }
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null);
|
||||
if (response?.ok) {
|
||||
const fresh = await response.json();
|
||||
setData(fresh);
|
||||
setAutomatic(fresh.automatic_monthly);
|
||||
setNow(Date.now());
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
|
||||
return <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
|
||||
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your reports, your choice.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Email confirmed' })[data.state]}</span>}</div>
|
||||
<p>Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and decide whether you also want automatic monthly emails. <a href="/insights/reports">Explore your latest report ↗</a></p>
|
||||
{!data && !error && <p role="status">Loading your email preference…</p>}
|
||||
{data && <>
|
||||
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {!data.automatic_monthly ? 'On demand only: choose a month in Reports and email it whenever you want.' : data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}</p>}
|
||||
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>}
|
||||
{data.state !== 'enabled' && data.can_subscribe && automatic && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>}
|
||||
<label className="recap-delivery-choice">Delivery preference<select value={automatic ? 'monthly' : 'manual'} disabled={busy || data.state === 'pending'} onChange={(event) => {
|
||||
const monthly = event.target.value === 'monthly'
|
||||
setAutomatic(monthly)
|
||||
if (data.state === 'enabled') void save(true, monthly)
|
||||
}}><option value="manual">On demand only</option><option value="monthly">On demand + automatic monthly emails</option></select></label>
|
||||
<div className="recap-actions">
|
||||
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Confirm my email for reports' : 'Send a new confirmation'}</button>}
|
||||
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off report emails' : 'Cancel subscription'}</button>}
|
||||
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
|
||||
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
|
||||
return (
|
||||
<section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
|
||||
<div className="recap-section-heading">
|
||||
<div>
|
||||
<span className="recap-eyebrow">A little look back</span>
|
||||
<h2 id="recap-preference-title">Your reports, your choice.</h2>
|
||||
</div>
|
||||
{data && (
|
||||
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
|
||||
{
|
||||
{ off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Email confirmed" }[
|
||||
data.state
|
||||
]
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>}
|
||||
</>}
|
||||
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>}
|
||||
{notice && <p className="account-notice is-status" role="status">{notice}</p>}
|
||||
</section>
|
||||
<p>
|
||||
Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and
|
||||
decide whether you also want automatic monthly emails.{" "}
|
||||
<a href="/insights/reports">Explore your latest report ↗</a>
|
||||
</p>
|
||||
{!data && !error && <p role="status">Loading your email preference…</p>}
|
||||
{data && (
|
||||
<>
|
||||
{data.state === "enabled" ? (
|
||||
<p className="recap-delivery-address">
|
||||
Recaps will go to <strong>{data.email}</strong>.{" "}
|
||||
{!data.automatic_monthly
|
||||
? "On demand only: choose a month in Reports and email it whenever you want."
|
||||
: data.schedule_enabled && data.next_send_at
|
||||
? `Next scheduled send: ${scheduled(data.next_send_at)}.`
|
||||
: "The administrator has paused scheduled delivery."}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{data.state === "pending"
|
||||
? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.`
|
||||
: data.state === "expired"
|
||||
? "Request a new confirmation link to turn on your recaps."
|
||||
: "Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile."}
|
||||
</p>
|
||||
)}
|
||||
{!data.can_subscribe && data.state !== "enabled" && <p className="recap-muted">{data.detail}</p>}
|
||||
{data.state !== "enabled" && data.can_subscribe && automatic && !data.schedule_enabled && (
|
||||
<p className="recap-muted">
|
||||
You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
|
||||
</p>
|
||||
)}
|
||||
<label className="recap-delivery-choice">
|
||||
Delivery preference
|
||||
<select
|
||||
value={automatic ? "monthly" : "manual"}
|
||||
disabled={busy || data.state === "pending"}
|
||||
onChange={(event) => {
|
||||
const monthly = event.target.value === "monthly";
|
||||
setAutomatic(monthly);
|
||||
if (data.state === "enabled") void save(true, monthly);
|
||||
}}
|
||||
>
|
||||
<option value="manual">On demand only</option>
|
||||
<option value="monthly">On demand + automatic monthly emails</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="recap-actions">
|
||||
{data.state !== "enabled" && (
|
||||
<button
|
||||
type="button"
|
||||
className="account-primary"
|
||||
disabled={busy || !data.can_subscribe || cooldown > 0}
|
||||
onClick={() => void save(true)}
|
||||
>
|
||||
{busy
|
||||
? "Sending confirmation…"
|
||||
: data.state === "off"
|
||||
? "Confirm my email for reports"
|
||||
: "Send a new confirmation"}
|
||||
</button>
|
||||
)}
|
||||
{data.state !== "off" && (
|
||||
<button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>
|
||||
{busy ? "Updating…" : data.state === "enabled" ? "Turn off report emails" : "Cancel subscription"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="account-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setNotice("");
|
||||
setRevision((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
Refresh preference
|
||||
</button>
|
||||
</div>
|
||||
{cooldown > 0 && data.state !== "enabled" && (
|
||||
<p className="recap-muted">
|
||||
Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
|
||||
{Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="account-notice is-error" role="alert">
|
||||
{error}
|
||||
{!data && (
|
||||
<button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="account-notice is-status" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user