web: PRO needs a proven address — email verification gates the studio

A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
This commit is contained in:
2026-09-20 07:39:03 +07:00
parent efe578f61c
commit 52b672deec
16 changed files with 633 additions and 74 deletions
+38 -4
View File
@@ -180,7 +180,9 @@ export function Landing() {
const [temp, setTemp] = useState(0);
const [sat, setSat] = useState(100);
const [con, setCon] = useState(100);
const [toast, setToast] = useState<string | null>(null);
// `lead` is the short bold tag in front of the message; the marketing lines
// share "Coming soon —", the verification ones carry their own.
const [toast, setToast] = useState<{ lead: string; text: string } | null>(null);
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [ratings, setRatings] = useState<Record<string, Rating>>({});
@@ -197,8 +199,13 @@ export function Landing() {
const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0];
const showToast = useCallback((text: Txt | string) => {
setToast(typeof text === 'string' ? text : c(text));
const toastLead: Txt = { en: 'Coming soon —', vi: 'Sắp có —' };
const showToast = useCallback((text: Txt | string, lead?: Txt) => {
setToast({
lead: c(lead ?? toastLead),
text: typeof text === 'string' ? text : c(text),
});
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 3400);
}, [c]);
@@ -219,6 +226,32 @@ export function Landing() {
reload();
}, [reload]);
// The verification link in the letter redirects here with `?verified=1` (or
// `0` for a stale or unknown token). Say which it was, re-read the session so
// a just-proven account comes back PRO, and drop the flag so a reload does
// not repeat the toast.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const flag = params.get('verified');
if (flag === null) return;
params.delete('verified');
const rest = params.toString();
window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`);
if (flag === '1') {
showToast(
{ en: 'Your email is confirmed — the studio is unlocked.', vi: 'Đã xác thực email — studio đã mở khoá.' },
{ en: 'Verified —', vi: 'Đã xác thực —' },
);
reload();
} else {
showToast(
{ en: 'That verification link is invalid or has expired.', vi: 'Liên kết xác thực không hợp lệ hoặc đã hết hạn.' },
{ en: 'Not verified —', vi: 'Chưa xác thực —' },
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// One vote. The visitor may change their mind: the API upserts under the same
// address hash, so a second click replaces the first rather than stacking. A
// vote that never lands is silently dropped — it is not worth a toast.
@@ -949,6 +982,7 @@ export function Landing() {
{auth ? (
<AuthModal
initialMode={auth}
email={user?.email}
onClose={() => setAuth(null)}
onDone={() => {
setAuth(null);
@@ -959,7 +993,7 @@ export function Landing() {
/>
) : null}
{toast ? <div className="lp-toast" role="status"><b>{c({ en: 'Coming soon —', vi: 'Sắp có —' })}</b> {toast}</div> : null}
{toast ? <div className="lp-toast" role="status"><b>{toast.lead}</b> {toast.text}</div> : null}
</div>
);
}