diff --git a/web/website/public/_headers b/web/website/public/_headers index 48f0400..4ea85ee 100644 --- a/web/website/public/_headers +++ b/web/website/public/_headers @@ -1,9 +1,12 @@ # Cloudflare Pages 安全响应头 — 由 public/_headers 原样下发到产物根。 # 设计:严格 CSP(白名单仅 self + 自托管资源)、HSTS、隐私无第三方。 -# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故所有 *-src 收敛到 'self'。 +# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故大部分 *-src 收敛到 'self'; +# 唯一例外 connect-src 放行 api.yanmeiai.com(控制面 API,Cloudflare Tunnel 出口, +# CORS 已放行本站 origin)——落地页/注册页/首页注册入口浏览器端直连该域发验证码、 +# 注册(见 src/components/RegisterCard.jsx),不经站内代理。 /* - Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.yanmeiai.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Content-Type-Options: nosniff X-Frame-Options: DENY diff --git a/web/website/src/components/AnnouncementBar.jsx b/web/website/src/components/AnnouncementBar.jsx index aa2cde2..8a19c3b 100644 --- a/web/website/src/components/AnnouncementBar.jsx +++ b/web/website/src/components/AnnouncementBar.jsx @@ -1,18 +1,29 @@ /** * AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。 + * + * CTA 直接跳独立注册页 /register(不再锚点滚动到 hero 的 #signup —— 那边现在本身也只是 + * 跳 /register 的一层转发,直达更省一跳)。已登录(见 lib/authState.js)时整条隐藏: + * 老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。 */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Gift, X } from 'lucide-react'; +import { isLoggedIn } from '../lib/authState'; export default function AnnouncementBar({ text, cta }) { const [closed, setClosed] = useState(false); - if (closed) return null; + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + setLoggedIn(isLoggedIn()); + }, []); + + if (closed || loggedIn) return null; return (
`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」
+ * 注册流程,行为与后端契约见 server/internal/auth/handler.go:
+ *
+ * POST /v1/auth/code body {email} → 204
+ * POST /v1/auth/register body {email,code,password,invite_code?}
+ * (device 整个省略——web 无设备指纹,后端零值容忍)
+ * → 200 {access_token,refresh_token,...}(网页不存,注册成功即弃)
+ *
+ * 错误统一 {code,message_zh,message_en},按当前语言态取对应文案;网络错用通用双语兜底。
+ *
+ * 双语处理与 InviteCard.jsx 同一路数:不走全站 i18n/strings.ts 的「构建期按路由单显」
+ * (那个模式要求语言在构建期已知),而是本组件内嵌 zh/en 两份文案、用 `.langseg`
+ * 单显 + 运行时切换 —— 任意时刻只渲染一种语言。
+ *
+ * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)直接短路成
+ * 「你已登录」提示,不渲染注册表单(无 JS 时默认展示表单,渐进增强)。
+ */
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Mail, KeyRound, Lock, Loader2, AlertCircle, CheckCircle2, Download, ArrowRight } from 'lucide-react';
+import { SITE } from '../config/site';
+import { isLoggedIn } from '../lib/authState';
+import { normalizeInviteCode } from '../lib/inviteCode';
+
+const API_BASE = import.meta.env.PUBLIC_API_BASE || SITE.api;
+const RESEND_SECS = 60;
+const REDIRECT_SECS = 3;
+
+const COPY = {
+ zh: {
+ eyebrow: '注册',
+ titleInvite: '注册穿山甲,双方各得 3 天',
+ titlePlain: '创建你的穿山甲账户',
+ sub: '一个邮箱即可开始,免费注册,无需任何付款信息。',
+ emailLabel: '邮箱',
+ emailPh: '你的邮箱',
+ sendCode: '发送验证码',
+ resend: '重新发送',
+ resendIn: (n) => `${n}s 后可重发`,
+ sending: '发送中…',
+ codeLabel: '验证码',
+ codePh: '6 位验证码',
+ pwLabel: '设置密码',
+ pwPh: '至少 8 位',
+ pwHint: '密码至少 8 位',
+ submitInvite: '注册并领取 3 天',
+ submitPlain: '注册',
+ submitting: '注册中…',
+ netErr: '网络异常,请检查网络后重试',
+ emailInvalid: '请输入正确的邮箱地址',
+ formIncomplete: '请填写完整的验证码与密码(密码至少 8 位)',
+ successTitle: '注册成功',
+ successSubInvite: '3 天已到账,用刚注册的邮箱登录穿山甲即可查收。',
+ successSubPlain: '请下载穿山甲,用刚注册的邮箱登录。',
+ dlMac: '下载 macOS 版',
+ dlWin: '下载 Windows 版',
+ dlAndroid: '下载 Android 版',
+ dlGeneric: '前往下载',
+ backHome: '返回首页',
+ redirecting: (n) => `${n} 秒后自动返回首页`,
+ loggedInTitle: '你已登录',
+ loggedInSub: '无需重复注册,直接返回首页或前往用户中心即可。',
+ loggedInHome: '返回首页',
+ loggedInCenter: '打开用户中心',
+ },
+ en: {
+ eyebrow: 'Register',
+ titleInvite: 'Sign up — you and your friend each get +3 days',
+ titlePlain: 'Create your Pangolin account',
+ sub: 'Just an email to start. Free to join, no payment details.',
+ emailLabel: 'Email',
+ emailPh: 'Your email',
+ sendCode: 'Send code',
+ resend: 'Resend',
+ resendIn: (n) => `Resend in ${n}s`,
+ sending: 'Sending…',
+ codeLabel: 'Verification code',
+ codePh: '6-digit code',
+ pwLabel: 'Set a password',
+ pwPh: 'At least 8 characters',
+ pwHint: 'Password must be at least 8 characters',
+ submitInvite: 'Register & claim +3 days',
+ submitPlain: 'Register',
+ submitting: 'Registering…',
+ netErr: 'Network error, please check your connection and retry',
+ emailInvalid: 'Please enter a valid email address',
+ formIncomplete: 'Please fill in the code and a password (at least 8 characters)',
+ successTitle: 'Registration complete',
+ successSubInvite: 'Your +3 days are credited. Sign in to the app with this email to see them.',
+ successSubPlain: 'Download Pangolin and sign in with this email.',
+ dlMac: 'Download for macOS',
+ dlWin: 'Download for Windows',
+ dlAndroid: 'Download for Android',
+ dlGeneric: 'Go to downloads',
+ backHome: 'Back to home',
+ redirecting: (n) => `Returning to home in ${n}s`,
+ loggedInTitle: "You're already signed in",
+ loggedInSub: 'No need to register again — head back home or open the account center.',
+ loggedInHome: 'Back to home',
+ loggedInCenter: 'Open account center',
+ },
+};
+
+function detectPlatform() {
+ if (typeof navigator === 'undefined') return 'unknown';
+ const ua = navigator.userAgent || '';
+ const platform = navigator.platform || '';
+ // iPadOS 13+ 把 UA 伪装成 Mac,靠触点数区分(真 Mac 无多点触控)。
+ const isIOS = /iPhone|iPad|iPod/.test(ua) || (platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ if (isIOS) return 'ios';
+ if (/Android/.test(ua)) return 'android';
+ if (/Win(dows)?|Win32|Win64/.test(ua) || /Win32|Win64/.test(platform)) return 'windows';
+ if (/Mac/.test(platform) || /Macintosh/.test(ua)) return 'macos';
+ return 'unknown';
+}
+
+async function callApi(path, body) {
+ let res;
+ try {
+ res = await fetch(API_BASE + path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch {
+ return { ok: false, network: true };
+ }
+ let data = null;
+ try {
+ const text = await res.text();
+ if (text) data = JSON.parse(text);
+ } catch {
+ data = null;
+ }
+ if (!res.ok) return { ok: false, network: false, error: data };
+ return { ok: true, data };
+}
+
+export default function RegisterCard() {
+ const [lang, setLang] = useState('zh');
+ const [loggedIn, setLoggedIn] = useState(false);
+ const [checkedAuth, setCheckedAuth] = useState(false);
+
+ const [email, setEmail] = useState('');
+ const [invite, setInvite] = useState(null); // 规范化后的邀请码,或 null
+
+ const [code, setCode] = useState('');
+ const [password, setPassword] = useState('');
+ const [pwTouched, setPwTouched] = useState(false);
+
+ const [codeSent, setCodeSent] = useState(false);
+ const [sendingCode, setSendingCode] = useState(false);
+ const [codeErr, setCodeErr] = useState('');
+ const [cooldown, setCooldown] = useState(0);
+ const cooldownRef = useRef(null);
+
+ const [submitting, setSubmitting] = useState(false);
+ const [regErr, setRegErr] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [redirectIn, setRedirectIn] = useState(REDIRECT_SECS);
+ const redirectRef = useRef(null);
+ const codeInputRef = useRef(null);
+
+ const t = COPY[lang];
+ const msgKey = lang === 'zh' ? 'message_zh' : 'message_en';
+ const homeHref = lang === 'zh' ? '/zh/' : '/';
+ const dlAnchorHref = `${homeHref}#download`;
+
+ // 读 query 参数(?email=&invite=) + 登录态;均只在挂载时读一次。
+ useEffect(() => {
+ try {
+ const qs = new URLSearchParams(window.location.search);
+ const qEmail = qs.get('email');
+ if (qEmail) setEmail(qEmail);
+ setInvite(normalizeInviteCode(qs.get('invite')));
+ } catch {
+ /* ignore malformed query */
+ }
+ document.documentElement.lang = 'zh-CN';
+ setLoggedIn(isLoggedIn());
+ setCheckedAuth(true);
+ }, []);
+
+ useEffect(() => {
+ document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
+ }, [lang]);
+
+ useEffect(() => () => clearInterval(cooldownRef.current), []);
+ useEffect(() => () => clearInterval(redirectRef.current), []);
+
+ useEffect(() => {
+ if (codeSent && codeInputRef.current) codeInputRef.current.focus();
+ }, [codeSent]);
+
+ // 成功后自动倒计时返回首页(点「返回首页」等价立即触发)。
+ useEffect(() => {
+ if (!success) return undefined;
+ setRedirectIn(REDIRECT_SECS);
+ redirectRef.current = setInterval(() => {
+ setRedirectIn((s) => {
+ if (s <= 1) {
+ clearInterval(redirectRef.current);
+ window.location.href = homeHref;
+ return 0;
+ }
+ return s - 1;
+ });
+ }, 1000);
+ return () => clearInterval(redirectRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [success]);
+
+ function startCooldown() {
+ setCooldown(RESEND_SECS);
+ clearInterval(cooldownRef.current);
+ cooldownRef.current = setInterval(() => {
+ setCooldown((s) => {
+ if (s <= 1) {
+ clearInterval(cooldownRef.current);
+ return 0;
+ }
+ return s - 1;
+ });
+ }, 1000);
+ }
+
+ async function onSendCode(e) {
+ e.preventDefault();
+ setCodeErr('');
+ const trimmed = email.trim();
+ if (!/\S+@\S+\.\S+/.test(trimmed)) {
+ setCodeErr(t.emailInvalid);
+ return;
+ }
+ setSendingCode(true);
+ const r = await callApi('/v1/auth/code', { email: trimmed });
+ setSendingCode(false);
+ if (!r.ok) {
+ setCodeErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
+ return;
+ }
+ setCodeSent(true);
+ startCooldown();
+ }
+
+ async function onRegister(e) {
+ e.preventDefault();
+ setRegErr('');
+ const trimmedEmail = email.trim();
+ if (!/\S+@\S+\.\S+/.test(trimmedEmail) || code.length !== 6 || password.length < 8) {
+ setPwTouched(true);
+ setRegErr(t.formIncomplete);
+ return;
+ }
+ setSubmitting(true);
+ const body = { email: trimmedEmail, code, password };
+ if (invite) body.invite_code = invite;
+ const r = await callApi('/v1/auth/register', body);
+ setSubmitting(false);
+ if (!r.ok) {
+ setRegErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
+ return;
+ }
+ setSuccess(true);
+ }
+
+ const plat = useMemo(() => detectPlatform(), []);
+ const dl = useMemo(() => {
+ if (plat === 'android') return { href: SITE.downloads.android, label: t.dlAndroid };
+ if (plat === 'windows') return { href: SITE.downloads.windows, label: t.dlWin };
+ if (plat === 'macos') return { href: SITE.downloads.macos, label: t.dlMac };
+ return { href: dlAnchorHref, label: t.dlGeneric };
+ }, [plat, t, dlAnchorHref]);
+
+ const pwInvalid = pwTouched && password.length > 0 && password.length < 8;
+ const canSubmit = /\S+@\S+\.\S+/.test(email.trim()) && code.length === 6 && password.length >= 8 && !submitting;
+
+ const LangSeg = (
+
+
+
+
+ );
+
+ // 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染,
+ // 与「无 JS 时默认展示注册表单」的渐进增强保持一致。
+ if (checkedAuth && loggedIn) {
+ return (
+
+
+
+ {LangSeg}
+
+
+
+
+ );
+ }
+
+ if (success) {
+ return (
+
+
+
+ {LangSeg}
+
+
+ {t.successTitle}
+ {invite ? t.successSubInvite : t.successSubPlain}
+
+
+ {dl.label}
+
+
+ {t.backHome}
+ {t.redirecting(redirectIn)}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {LangSeg}
+
+ {t.eyebrow}
+ {invite ? t.titleInvite : t.titlePlain}
+ {t.sub}
+
+
+
+
+
+ );
+}
diff --git a/web/website/src/components/SignupForm.jsx b/web/website/src/components/SignupForm.jsx
index 3e0b6d2..a7107fb 100644
--- a/web/website/src/components/SignupForm.jsx
+++ b/web/website/src/components/SignupForm.jsx
@@ -1,38 +1,54 @@
/**
- * SignupForm.jsx — Hero 内联邮箱注册(演示,React island)。迁自原型 .signup + site.js __signup。
- * 注意(铁律 10):这不是支付表单;仅演示「发送验证码」交互,真实实现由后端 auth 接管。
+ * SignupForm.jsx — Hero 内联邮箱入口(React island)。迁自原型 .signup + site.js __signup。
+ *
+ * 不在这里发验证码 / 建账户:提交只是把邮箱透传给独立注册页 /register(?email= 预填),
+ * 真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx 承接(见该组件顶部注释)。
+ * 空邮箱也允许跳转——/register 页留空即可,用户到那边再填。
+ *
+ * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时,表单换成
+ * 「打开用户中心」按钮:老用户不需要再看到注册入口。无 JS 时默认展示表单(渐进增强)。
*/
-import { useState } from 'react';
-import { Mail, CheckCircle, Download } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { Mail, Download } from 'lucide-react';
+import { SITE } from '../config/site';
+import { isLoggedIn } from '../lib/authState';
-export default function SignupForm({ ph, btn, ok, hint, dl }) {
- const [done, setDone] = useState(false);
+export default function SignupForm({ ph, btn, hint, dl, centerLabel }) {
+ const [loggedIn, setLoggedIn] = useState(false);
+
+ useEffect(() => {
+ setLoggedIn(isLoggedIn());
+ }, []);
const onSubmit = (e) => {
e.preventDefault();
- const email = e.currentTarget.elements['su-email'];
- if (!/\S+@\S+\.\S+/.test(email.value)) {
- email.focus();
- return;
- }
- setDone(true);
+ const emailEl = e.currentTarget.elements['su-email'];
+ const email = (emailEl && emailEl.value ? emailEl.value : '').trim();
+ const qs = email ? `?email=${encodeURIComponent(email)}` : '';
+ window.location.href = `/register${qs}`;
};
+ if (loggedIn) {
+ return (
+
+ );
+ }
+
return (