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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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