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:
yuanzonghao
2026-06-13 17:33:55 +08:00
parent 2a2d58a64f
commit 87a2f93edb
22 changed files with 646 additions and 11 deletions
+43 -10
View File
@@ -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>
);
}