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
+4
View File
@@ -2,10 +2,14 @@ import { requestBeatAudio } from "@infiplot/engine";
import type { BeatAudioRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: BeatAudioRequest;
try {
body = (await req.json()) as BeatAudioRequest;
+4
View File
@@ -2,10 +2,14 @@ import { classifyFreeform } from "@infiplot/engine";
import type { FreeformClassifyRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: FreeformClassifyRequest;
try {
body = (await req.json()) as FreeformClassifyRequest;
+4
View File
@@ -2,10 +2,14 @@ import { requestInsertBeat } from "@infiplot/engine";
import type { InsertBeatRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: InsertBeatRequest;
try {
body = (await req.json()) as InsertBeatRequest;
+4
View File
@@ -5,6 +5,7 @@ import type {
} from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -26,6 +27,9 @@ Do NOT describe the characters, objects, or scene contents. Output exactly one J
{"stylePrompt": "<comma-separated English visual-style attributes, ~30-60 words>"}`;
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: ParseStyleImageRequest;
try {
body = (await req.json()) as ParseStyleImageRequest;
+4
View File
@@ -2,6 +2,7 @@ import { requestScene } from "@infiplot/engine";
import type { Character, SceneRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
function stripKnownVoices(
characters: Character[],
@@ -15,6 +16,9 @@ function stripKnownVoices(
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: SceneRequest;
try {
body = (await req.json()) as SceneRequest;
+4
View File
@@ -2,6 +2,7 @@ import { startSession } from "@infiplot/engine";
import type { StartRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -11,6 +12,9 @@ export const runtime = "nodejs";
const MAX_STYLE_REF_BYTES = 3 * 1024 * 1024;
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: StartRequest;
try {
body = (await req.json()) as StartRequest;
+4
View File
@@ -2,6 +2,7 @@ import { visionDecide } from "@infiplot/engine";
import type { VisionRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -11,6 +12,9 @@ export const runtime = "nodejs";
const MAX_ANNOTATED_BYTES = 3 * 1024 * 1024;
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: VisionRequest;
try {
body = (await req.json()) as VisionRequest;
+17
View File
@@ -0,0 +1,17 @@
import { type NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl;
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/";
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(`${origin}/?auth_error=1`);
}
+33 -1
View File
@@ -16,6 +16,10 @@ import { analyzeImageDataUrl } from "@infiplot/ai-client";
import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig";
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient as createSupabaseClient } from "@/lib/supabase/client";
import { AuthModal } from "@/components/AuthModal";
import { UserChip } from "@/components/UserChip";
/* ============================================================================
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -1281,6 +1285,8 @@ export default function HomePage() {
const [ttsConfigured, setTtsConfigured] = useState(false);
const [playerName, setPlayerName] = useState("");
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [pendingAction, setPendingAction] = useState<"start" | null>(null);
const styleRow = OPTS.findIndex((o) => o.modal);
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
@@ -1350,7 +1356,17 @@ export default function HomePage() {
}
};
const start = () => {
const start = async () => {
if (AUTH_ENABLED) {
const sb = createSupabaseClient();
const { data } = await sb.auth.getUser();
if (!data.user) {
setPendingAction("start");
setAuthModalOpen(true);
return;
}
}
// 空输入时落回 Typewriter 当前闪动的示例——用户看到啥就玩啥,
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
const userPrompt =
@@ -1525,6 +1541,7 @@ export default function HomePage() {
>
<i className="fa-brands fa-x-twitter" />
</a>
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
</div>
</header>
@@ -1813,6 +1830,21 @@ export default function HomePage() {
}}
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => {
setAuthModalOpen(false);
setPendingAction(null);
}}
onSuccess={() => {
setAuthModalOpen(false);
if (pendingAction === "start") {
setPendingAction(null);
start();
}
}}
/>
)}
</div>
);
}
+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>
);
}