fix(auth): handle null backend values and surface login errors on splash
Two related fixes for OAuth login failures:
- `UserSchema`: backend (Facebook/Apple) returns `null` for fields like
`email`; Zod's `.default("")` rejects `null`, causing silent login
failures. Introduce `stringOrEmpty` / `numberOrZero` / `booleanOrFalse`
helpers (`nullable().transform(v => v ?? x).default(x)`) and apply
across the schema so input accepts `string | null | undefined` while
output stays the typed primitive.
- `SplashScreen`: render `state.errorMessage` (set by auth state machine
`onError`, e.g. Facebook sync / schema mismatch) so users no longer
get stuck on splash without feedback.
This commit is contained in:
@@ -24,7 +24,7 @@ export function SplashScreen() {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// useEffect ① 启动时检查 localStorage 里的 loginToken:
|
// useEffect ① 启动时检查 localStorage 里的 loginToken:
|
||||||
// - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat
|
// - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat
|
||||||
// - **不**查 guestToken —— 游客用户**停留 splash**(按你要求)
|
// - 不查 guestToken —— 游客用户停留 splash(按你要求)
|
||||||
// - 都没有 → 留 splash
|
// - 都没有 → 留 splash
|
||||||
// proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。
|
// proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -43,11 +43,11 @@ export function SplashScreen() {
|
|||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// useEffect ② 按钮**刚**派了事件(pendingRedirect=true) +
|
// useEffect ② 按钮刚派了事件(pendingRedirect=true) +
|
||||||
// loginStatus 是非 notLoggedIn → 跳 /chat
|
// loginStatus 是非 notLoggedIn → 跳 /chat
|
||||||
//
|
//
|
||||||
// **关键**:`pendingRedirect` 是 auth context 里的**持久**状态(不是 ref)——
|
// 关键:`pendingRedirect` 是 auth context 里的持久状态(不是 ref)——
|
||||||
// re-mount 时**不**重置,区分"刚按了"和"历史登录"准确无误。
|
// re-mount 时不重置,区分"刚按了"和"历史登录"准确无误。
|
||||||
// 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。
|
// 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,6 +88,26 @@ export function SplashScreen() {
|
|||||||
<div className={styles.buttonArea}>
|
<div className={styles.buttonArea}>
|
||||||
<SplashButton />
|
<SplashButton />
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
|
* 登录错误提示 —— auth state machine 的 onError 会设 errorMessage
|
||||||
|
* (如 Facebook 同步后端失败 / 后端返回数据 schema 不匹配等)。
|
||||||
|
* 之前没显示,导致用户卡在 splash 看不到原因。
|
||||||
|
*/}
|
||||||
|
{state.errorMessage ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
color: "#c0392b",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "320px",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className={styles.bottom}>
|
<p className={styles.bottom}>
|
||||||
Elio Silvestri, Your exclusive AI boyfriend
|
Elio Silvestri, Your exclusive AI boyfriend
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -5,32 +5,43 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits";
|
import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性解析:后端对 Facebook / Apple 等 OAuth 用户可能返回 `null`(如 `email: null`),
|
||||||
|
* Zod 默认的 `.default("")` 只接受 `undefined`,遇到 `null` 会抛 ZodError → 登录静默失败。
|
||||||
|
* 用 `z.string().nullable().transform(v => v ?? "").default("")` 模式:
|
||||||
|
* - input 接受 `string | null | undefined`(保持 optional)
|
||||||
|
* - output 永远是 `string`(User 类的 `email: string` 声明不会破)
|
||||||
|
*
|
||||||
|
* 这是前端防御性解析(不要求后端修改)—— 和后端对齐更稳。
|
||||||
|
*/
|
||||||
|
const stringOrEmpty = z.string().nullable().transform((v) => v ?? "").default("");
|
||||||
|
const numberOrZero = z.number().nullable().transform((v) => v ?? 0).default(0);
|
||||||
|
const booleanOrFalse = z.boolean().nullable().transform((v) => v ?? false).default(false);
|
||||||
|
|
||||||
export const UserSchema = z.object({
|
export const UserSchema = z.object({
|
||||||
id: z.string(),
|
id: stringOrEmpty, // 兜底:理论 id 不会 null,但保持防御
|
||||||
username: z.string(),
|
username: stringOrEmpty,
|
||||||
email: z.string().default(""),
|
email: stringOrEmpty,
|
||||||
platform: z.string().default("web"),
|
platform: stringOrEmpty,
|
||||||
intimacy: z.number().default(0),
|
intimacy: numberOrZero,
|
||||||
dolBalance: z.number().default(0),
|
dolBalance: numberOrZero,
|
||||||
relationshipStage: z.string().default("密友"),
|
relationshipStage: stringOrEmpty,
|
||||||
currentMood: z.string().default("happy"),
|
currentMood: stringOrEmpty,
|
||||||
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS),
|
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS),
|
||||||
preferredLanguage: z.string().default(""),
|
preferredLanguage: stringOrEmpty,
|
||||||
createdAt: z.string().default(""),
|
createdAt: stringOrEmpty,
|
||||||
// 后端新游客返回 null("还没发过消息"),`.default("")` 不兜 null —— 需 nullable
|
// 后端新游客返回 null("还没发过消息"),保持 nullable(User 类声明 `string | null`)
|
||||||
lastMessageAt: z.string().nullable().default(null),
|
lastMessageAt: z.string().nullable().default(null),
|
||||||
avatarUrl: z.string().default(""),
|
avatarUrl: stringOrEmpty,
|
||||||
loginProvider: z.string().default("email"),
|
loginProvider: stringOrEmpty,
|
||||||
isGuest: z.boolean().default(false),
|
isGuest: booleanOrFalse,
|
||||||
/** 是否为 VIP 会员(订阅中或未过期) —— Stripe webhook 更新 */
|
/** 是否为 VIP 会员(订阅中或未过期) —— Stripe webhook 更新 */
|
||||||
isVip: z.boolean().default(false),
|
isVip: booleanOrFalse,
|
||||||
/** 语音聊天剩余分钟数(订阅生效后每天补充;用完为 0 不变) */
|
/** 语音聊天剩余分钟数(订阅生效后每天补充;用完为 0 不变) */
|
||||||
voiceMinutesRemaining: z.number().default(0),
|
voiceMinutesRemaining: numberOrZero,
|
||||||
/** Stripe Customer ID —— 首次 Checkout 完成后由 webhook 设置,用于 Customer Portal 自助管理 */
|
/** Stripe Customer ID —— 首次 Checkout 完成后由 webhook 设置,用于 Customer Portal 自助管理 */
|
||||||
stripeCustomerId: z.string().nullable().default(null),
|
stripeCustomerId: z.string().nullable().default(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type UserInput = z.input<typeof UserSchema>;
|
export type UserInput = z.input<typeof UserSchema>;
|
||||||
export type UserData = z.output<typeof UserSchema>;
|
export type UserData = z.output<typeof UserSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user