feat(auth): add Supabase auth with Google, GitHub, and email OTP login
Introduce user registration/login gated behind optional NEXT_PUBLIC_SUPABASE_* env vars (leave blank to disable — app behaves exactly as before). Adds proxy.ts for automatic cookie session refresh, requireUser() API route guards on all 7 compute-consuming routes, AuthModal (Google/GitHub OAuth + 6-digit email OTP), UserChip header component, and login_success analytics event. Identity is fully decoupled from Session/engine — no type changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+43
-10
@@ -35,6 +35,7 @@ import {
|
||||
visionDecide,
|
||||
classifyFreeform,
|
||||
requestInsertBeat,
|
||||
AuthRequiredError,
|
||||
} from "@/lib/engineClient";
|
||||
import type {
|
||||
Beat,
|
||||
@@ -50,6 +51,9 @@ import type {
|
||||
TtsConfig,
|
||||
} from "@infiplot/types";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||
import { AuthModal } from "@/components/AuthModal";
|
||||
import { UserChip } from "@/components/UserChip";
|
||||
|
||||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||||
|
||||
@@ -536,12 +540,22 @@ function PlayInner() {
|
||||
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const authResolveRef = useRef<(() => void) | null>(null);
|
||||
// Top-of-screen progress toast for the gallery / story export pipeline.
|
||||
// null when idle; { done, total, label } while collecting beat audio.
|
||||
const [exportProgress, setExportProgress] = useState<
|
||||
{ done: number; total: number; label: string } | null
|
||||
>(null);
|
||||
|
||||
const handleAuthError = useCallback((e: unknown): boolean => {
|
||||
if (e instanceof AuthRequiredError) {
|
||||
setAuthModalOpen(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const startedRef = useRef(false);
|
||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||
// Accumulator for resolved prefetches across the whole session — every
|
||||
@@ -1215,7 +1229,7 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: 1 });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
@@ -1352,7 +1366,9 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: initial.history.length });
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setError(String(e));
|
||||
});
|
||||
}, [params, router]);
|
||||
|
||||
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
|
||||
@@ -1477,7 +1493,7 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
return;
|
||||
}
|
||||
setError(String(e));
|
||||
if (!handleAuthError(e)) setError(String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
}
|
||||
@@ -1550,7 +1566,7 @@ function PlayInner() {
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: nextSession.history.length });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
})();
|
||||
@@ -1790,7 +1806,7 @@ function PlayInner() {
|
||||
setPendingClick(null);
|
||||
void performSceneTransition(promise, exit, visited, decision.freeformAction);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
if (!handleAuthError(e)) setError(String(e));
|
||||
setPhase("ready");
|
||||
}
|
||||
}
|
||||
@@ -1895,7 +1911,7 @@ function PlayInner() {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
if (!handleAuthError(e)) setError(String(e));
|
||||
setPendingClick(null);
|
||||
setPhase("ready");
|
||||
}
|
||||
@@ -2027,10 +2043,13 @@ function PlayInner() {
|
||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
|
||||
<span>第 · {String(sceneCount).padStart(3, "0")} · 幕</span>
|
||||
<span className="text-clay-300">·</span>
|
||||
<span>{String(beatCount).padStart(3, "0")} · 拍</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[10px] smallcaps text-clay-500 num flex items-center gap-3">
|
||||
<span>第 · {String(sceneCount).padStart(3, "0")} · 幕</span>
|
||||
<span className="text-clay-300">·</span>
|
||||
<span>{String(beatCount).padStart(3, "0")} · 拍</span>
|
||||
</div>
|
||||
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2135,6 +2154,20 @@ function PlayInner() {
|
||||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||
/>
|
||||
)}
|
||||
{authModalOpen && (
|
||||
<AuthModal
|
||||
onClose={() => {
|
||||
setAuthModalOpen(false);
|
||||
authResolveRef.current?.();
|
||||
authResolveRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setAuthModalOpen(false);
|
||||
authResolveRef.current?.();
|
||||
authResolveRef.current = null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user