chore: standardize security and quality foundations
This commit is contained in:
@@ -1,66 +1,145 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getApiBase } from '../lib/auth'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
import './recaps.css'
|
||||
import { useEffect, useState } from "react";
|
||||
import { getApiBase } from "../lib/auth";
|
||||
import BrandingLogo from "../ui/BrandingLogo";
|
||||
import "./recaps.css";
|
||||
|
||||
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string }
|
||||
type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
|
||||
|
||||
export default function EmailRecapLinkPage() {
|
||||
const [link, setLink] = useState<LinkAction | null>(null)
|
||||
const [state, setState] = useState('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [link, setLink] = useState<LinkAction | null>(null);
|
||||
const [state, setState] = useState("loading");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let controller: AbortController | null = null
|
||||
let controller: AbortController | null = null;
|
||||
const checkLink = () => {
|
||||
controller?.abort()
|
||||
const abort = new AbortController()
|
||||
controller = abort
|
||||
setError(''); setState('loading'); setLink(null)
|
||||
controller?.abort();
|
||||
const abort = new AbortController();
|
||||
controller = abort;
|
||||
setError("");
|
||||
setState("loading");
|
||||
setLink(null);
|
||||
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
const action = params.get('action')
|
||||
const token = params.get('token') || ''
|
||||
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
||||
setError('This email link is incomplete. Open Profile to manage your monthly recaps.'); setState('error'); return
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const action = params.get("action");
|
||||
const token = params.get("token") || "";
|
||||
if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
||||
setError("This email link is incomplete. Open Profile to manage your monthly recaps.");
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
const payload = { action, token } as LinkAction
|
||||
setLink(payload)
|
||||
void fetch(`${getApiBase()}/email-recaps/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => {
|
||||
const result = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.')
|
||||
if (!abort.signal.aborted) setState(result.state)
|
||||
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } })
|
||||
}
|
||||
checkLink()
|
||||
window.addEventListener('hashchange', checkLink)
|
||||
return () => { controller?.abort(); window.removeEventListener('hashchange', checkLink) }
|
||||
}, [])
|
||||
const payload = { action, token } as LinkAction;
|
||||
setLink(payload);
|
||||
void fetch(`${getApiBase()}/email-recaps/check`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: abort.signal,
|
||||
credentials: "omit",
|
||||
})
|
||||
.then(async (response) => {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof result.detail === "string"
|
||||
? result.detail
|
||||
: "Could not check this email link. Please open it again.",
|
||||
);
|
||||
if (!abort.signal.aborted) setState(result.state);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!abort.signal.aborted) {
|
||||
setError(err.message);
|
||||
setState("error");
|
||||
}
|
||||
});
|
||||
};
|
||||
checkLink();
|
||||
window.addEventListener("hashchange", checkLink);
|
||||
return () => {
|
||||
controller?.abort();
|
||||
window.removeEventListener("hashchange", checkLink);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const apply = async () => {
|
||||
if (!link || busy) return
|
||||
setBusy(true); setError('')
|
||||
if (!link || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(link), credentials: 'omit' })
|
||||
const result = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.')
|
||||
setState(result.state)
|
||||
window.history.replaceState(null, '', '/email-recaps')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Could not update your preference.') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(link),
|
||||
credentials: "omit",
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
|
||||
);
|
||||
setState(result.state);
|
||||
window.history.replaceState(null, "", "/email-recaps");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not update your preference.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const done = state === 'enabled' || state === 'off'
|
||||
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel">
|
||||
<span className="recap-eyebrow">Personal viewing reports</span>
|
||||
<h1>{state === 'enabled' ? 'You’re on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}</h1>
|
||||
<p>{state === 'enabled' ? 'Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You won’t receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off all personal viewing report emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile.' : ''}</p>
|
||||
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>}
|
||||
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences ↗</a>}
|
||||
{state === 'loading' && <p role="status">One moment…</p>}
|
||||
</section></main>
|
||||
const done = state === "enabled" || state === "off";
|
||||
return (
|
||||
<main className="recap-link-page">
|
||||
<a className="recap-brand" href="/login">
|
||||
<BrandingLogo className="brand-logo" />
|
||||
<span>Magent</span>
|
||||
</a>
|
||||
<section className="account-panel">
|
||||
<span className="recap-eyebrow">Personal viewing reports</span>
|
||||
<h1>
|
||||
{state === "enabled"
|
||||
? "You’re on the list."
|
||||
: state === "off"
|
||||
? "Recaps are turned off."
|
||||
: state === "loading"
|
||||
? "Checking your email link"
|
||||
: state === "error"
|
||||
? "This link needs another look"
|
||||
: link?.action === "unsubscribe"
|
||||
? "Unsubscribe from recaps?"
|
||||
: "Your month, delivered."}
|
||||
</h1>
|
||||
<p>
|
||||
{state === "enabled"
|
||||
? "Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs."
|
||||
: state === "off"
|
||||
? "You won’t receive further monthly recaps. You can turn them back on in Profile."
|
||||
: state === "ready" && link?.action === "unsubscribe"
|
||||
? "This turns off all personal viewing report emails. You can still explore all your reports in Magent."
|
||||
: state === "ready"
|
||||
? "Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile."
|
||||
: ""}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="account-notice is-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{state === "ready" && (
|
||||
<button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>
|
||||
{busy ? "Updating…" : link?.action === "unsubscribe" ? "Unsubscribe from recaps" : "Confirm email recaps"}
|
||||
</button>
|
||||
)}
|
||||
{(done || state === "error") && (
|
||||
<a className="recap-text-link" href="/profile#monthly-recaps">
|
||||
Manage email preferences ↗
|
||||
</a>
|
||||
)}
|
||||
{state === "loading" && <p role="status">One moment…</p>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user