Files
wangjia bb031a04c1 fix(client/web): 语言跟随系统 + SSO 落地补 basePath + 移除账户页续费按钮
- 语言(#1):LocaleNotifier 首启无用户选择时读设备 locale(zh/ja/ko/ru/es 命中
  对应语种,余回退英文),原来恒为固定默认 en、从不看系统语言。用户显式切换仍持久
  化优先。
- 用户中心 SSO(#3):usercenter 运行在 basePath=/user,但 /sso 落地页用原生
  window.location.replace('/') 跳转(Next 不补 basePath)→ 落到站点根=官网主页,
  且换票已建立的会话看起来像未登录。改为 withBase() 统一补 /user 前缀,兑票成功
  直达 /user/ 且保持登录态。(服务端 issue/exchange 流程实测均 200,非后端问题)
- 账户页(#4):移除"续费/升级"按钮(免费卡片 + PRO 横幅两处)及其未用组件;购买入口
  仍由"购买套餐"行保留,不影响下单路径。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
2026-08-09 09:42:25 +08:00

115 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。
//
// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、
// 单次可用),再打开系统浏览器到 https://<usercenter>/sso/?t=<ticket>&redirect=<本站路径>。
// 本页凭票 POST /v1/auth/web-ticket/exchange(公开端点)兑换与 login() 同形状的
// token pair,经与登录页相同的 setSession(见 lib/api/http.ts::exchangeWebTicket)
// 落地会话,再跳 redirect —— 其余页面(UserCenter.tsx)据此把它当作一次正常登录。
//
// 因 next.config.js 是 output:'export' 的纯静态导出,这里没有服务端 searchParams
// 可用,票据与 redirect 目标都在挂载后从 window.location.search 读取。
//
// 安全要点(与 jiu 的 sso.njk 一致)
// - URL 只携带一次性票据,从不携带真正的 access/refresh token;
// - redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
// 路径,其余一律回退到 '/'(登录页),防 open redirect;
// - 票据用后立即从地址栏抹掉(history.replaceState),不留浏览器历史;
// - 兑换失败(票据无效/过期/已用)一律落回登录页('/'),不泄露失败原因细节。
import React, { useEffect, useState } from 'react';
import { Mark } from '../../components/icons';
import { ErrorLine } from '../../components/Login';
import { card } from '../../components/shared';
import { useUI } from '../../lib/theme';
import { makeT } from '../../lib/i18n';
import { getClient } from '../../lib/api/client';
import { bilingual } from '../../lib/api/errors';
/** 站点 basePath(与 next.config.js 一致)。本页用的是 window.location 原生跳转,
* Next 不会自动补 basePath,故绝对路径必须手动加前缀,否则会落到站点根(官网主页)
* 而不是用户中心 —— 这正是「点用户中心却跳到主页、还要求重新登录」的根因。 */
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/user';
/** 把本站相对路径补上 basePath(供 window.location 原生跳转用)。 */
function withBase(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`;
return `${BASE_PATH}${p}`;
}
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
function safeRedirect(raw: string | null): string {
if (!raw) return '/';
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return '/';
return raw;
}
export default function SsoPage() {
const { lang } = useUI();
const t = makeT(lang);
const [failed, setFailed] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
let alive = true;
const params = new URLSearchParams(window.location.search);
const ticket = params.get('t');
const redirect = safeRedirect(params.get('redirect'));
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
// 原生 history/location 不吃 Next 的 basePath,须手动补,否则地址与跳转都会漂到站点根。
try {
window.history.replaceState(null, '', withBase('/sso/'));
} catch {
/* ignore */
}
if (!ticket) {
window.location.replace(withBase('/'));
return;
}
const api = getClient();
api
.exchangeWebTicket(ticket)
.then(() => {
if (!alive) return;
window.location.replace(withBase(redirect));
})
.catch((e) => {
if (!alive) return;
setMsg(bilingual(e, lang));
setFailed(true);
setTimeout(() => {
window.location.replace(withBase('/'));
}, 1500);
});
return () => {
alive = false;
};
// 仅挂载时读取一次 URL 并发起兑换;lang 变化不应重跑网络请求。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
<div style={{ ...card, width: 420, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box', textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
<Mark size={32} />
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>
{failed ? t('ssoFailTitle') : t('ssoTitle')}
</div>
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '8px 0 0' }}>
{failed ? t('ssoFailHint') : t('ssoMsg')}
</div>
{failed && msg && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<ErrorLine text={msg} />
</div>
)}
</div>
</div>
);
}