Merge pull request #68 from zonghaoyuan/feat/supabase-auth

feat(auth): add Supabase auth with Google, GitHub, and email OTP login
This commit is contained in:
Zonghao Yuan
2026-06-13 23:49:15 +08:00
committed by GitHub
22 changed files with 888 additions and 41 deletions
+9
View File
@@ -161,3 +161,12 @@ NEXT_PUBLIC_UMAMI_DOMAINS=
# WARNING: rotating this secret invalidates every share file ever issued
# (decryption will fail with "文件校验失败"). Only change when you're OK with that.
GALLERY_SECRET=
# ---- 8. Auth · Supabase (optional — leave blank to disable) -------
# Sign up at https://supabase.com, create a project, copy the URL and
# publishable key (starts with sb_publishable_ or eyJ…).
# Both blank → login UI is completely absent, all API routes run unguarded,
# and the app behaves exactly as before this feature existed.
# NEXT_PUBLIC_ vars are inlined at BUILD time.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
+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;
+40
View File
@@ -0,0 +1,40 @@
import { type NextRequest, NextResponse } from "next/server";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
// Only allow same-origin relative paths. Rejects `//evil.com`, `/\evil.com`,
// and absolute URLs that would otherwise turn `${origin}${next}` into an
// open redirect (CWE-601).
function safeNext(raw: string | null): string {
if (!raw || !raw.startsWith("/")) return "/";
if (raw.startsWith("//") || raw.startsWith("/\\")) return "/";
// Reject control chars (CR/LF etc.) — defense-in-depth against header
// injection if `next` ever reaches a context that doesn't re-encode it.
for (let i = 0; i < raw.length; i++) {
const code = raw.charCodeAt(i);
if (code < 0x20 || code === 0x7f) return "/";
}
return raw;
}
export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl;
// Auth not configured: nothing can legitimately hit this route, so just
// bounce home instead of constructing a Supabase client from blank env vars.
if (!AUTH_ENABLED) {
return NextResponse.redirect(`${origin}/`);
}
const code = searchParams.get("code");
const next = safeNext(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`);
}
+204 -22
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 · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -847,6 +851,51 @@ function CategorySelect({
/* ---------- style picker modal ---------- */
const PENDING_START_KEY = "infiplot:pending-start";
const PENDING_PARSE_KEY = "infiplot:pending-parse";
// True when auth is disabled (self-host with blank Supabase env) or the visitor
// already has a session. Gates the vision call behind login.
async function isAuthed(): Promise<boolean> {
if (!AUTH_ENABLED) return true;
const sb = createSupabaseClient();
const { data } = await sb.auth.getUser();
return !!data.user;
}
// Shared by the StyleModal uploader and the post-login resume path: turns a
// resized data URL into an English style prompt, via the browser engine when a
// BYO model config is present, otherwise the server route.
async function extractStylePromptFromImage(resized: string): Promise<string> {
const modelCfg = readStoredModelConfig();
if (modelCfg) {
const config = resolveEngineConfig(modelCfg, null);
const raw = await analyzeImageDataUrl(
config.vision,
resized,
STYLE_EXTRACTION_PROMPT,
);
let parsed: { stylePrompt?: string };
try {
parsed = JSON.parse(raw);
} catch {
parsed = { stylePrompt: raw };
}
return (parsed.stylePrompt ?? "").trim();
}
const r = await fetch("/api/parse-style-image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageDataUrl: resized }),
});
if (!r.ok) {
const data = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `HTTP ${r.status}`);
}
const data = (await r.json()) as { stylePrompt?: string };
return (data.stylePrompt ?? "").trim();
}
function StyleModal({
items,
value,
@@ -856,6 +905,7 @@ function StyleModal({
setCustomStyleGuide,
customStyleRefImage,
setCustomStyleRefImage,
onRequireAuth,
}: {
items: string[];
value: number;
@@ -865,6 +915,7 @@ function StyleModal({
setCustomStyleGuide: (s: string) => void;
customStyleRefImage: string;
setCustomStyleRefImage: (s: string) => void;
onRequireAuth: () => void;
}) {
const [q, setQ] = useState("");
const [shown, setShown] = useState(false);
@@ -979,31 +1030,18 @@ function StyleModal({
setParsing(true);
try {
const resized = await resizeImageToDataUrl(file);
const modelCfg = readStoredModelConfig();
let stylePrompt: string;
if (modelCfg) {
const config = resolveEngineConfig(modelCfg, null);
const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT);
let parsed: { stylePrompt?: string };
// The parse is a paid vision call, so require login first. The resize is
// already done — stash it so login can auto-resume the parse on return.
if (!(await isAuthed())) {
try {
parsed = JSON.parse(raw);
sessionStorage.setItem(PENDING_PARSE_KEY, resized);
} catch {
parsed = { stylePrompt: raw };
/* too big to stash — user re-uploads after login */
}
stylePrompt = (parsed.stylePrompt ?? "").trim();
} else {
const r = await fetch("/api/parse-style-image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageDataUrl: resized }),
});
if (!r.ok) {
const data = await r.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${r.status}`);
}
const data = (await r.json()) as { stylePrompt?: string };
stylePrompt = (data.stylePrompt ?? "").trim();
onRequireAuth();
return;
}
const stylePrompt = await extractStylePromptFromImage(resized);
if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述");
setDraft(stylePrompt);
setCustomStyleRefImage(resized);
@@ -1281,6 +1319,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 +1390,118 @@ export default function HomePage() {
}
};
const start = () => {
// ── Auth-gated resume (OAuth round-trips lose all React state) ──────────
// An OAuth login unmounts the homepage and discards everything the user
// typed. We snapshot the form before redirecting and replay it on return.
// The email-OTP path keeps state in place and resumes synchronously via
// AuthModal.onSuccess instead.
const [autoStartPending, setAutoStartPending] = useState(false);
const persistPendingStart = () => {
const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName };
try {
sessionStorage.setItem(PENDING_START_KEY, JSON.stringify(snap));
} catch {
// Quota is usually blown by the data-URL style ref; drop it, keep text.
try {
sessionStorage.setItem(
PENDING_START_KEY,
JSON.stringify({ ...snap, customStyleRefImage: "" }),
);
} catch {
/* still too big — give up on resume, the form just clears */
}
}
};
const resumePendingParse = async () => {
const resized = sessionStorage.getItem(PENDING_PARSE_KEY);
if (!resized) return;
sessionStorage.removeItem(PENDING_PARSE_KEY);
try {
const stylePrompt = await extractStylePromptFromImage(resized);
if (!stylePrompt) return;
setCustomStyleGuide(stylePrompt);
setCustomStyleRefImage(resized);
const customIdx = ART_STYLES.indexOf("自定义风格");
if (styleRow >= 0 && customIdx >= 0) {
setSel((s) => s.map((v, j) => (j === styleRow ? customIdx : v)));
}
track("style_image_upload", { ok: true });
} catch {
/* resume parse failed — stay silent, user can re-upload */
}
};
const resumePendingStart = () => {
const raw = sessionStorage.getItem(PENDING_START_KEY);
if (!raw) return;
sessionStorage.removeItem(PENDING_START_KEY);
try {
const snap = JSON.parse(raw) as {
prompt?: string;
sel?: number[];
customStyleGuide?: string;
customStyleRefImage?: string;
playerName?: string;
};
setPrompt(snap.prompt ?? "");
if (Array.isArray(snap.sel)) setSel(snap.sel);
setCustomStyleGuide(snap.customStyleGuide ?? "");
setCustomStyleRefImage(snap.customStyleRefImage ?? "");
if (snap.playerName) setPlayerName(snap.playerName);
// Defer start() to the next render so it reads the restored state.
setAutoStartPending(true);
} catch {
/* corrupt snapshot — ignore */
}
};
// On mount after an OAuth redirect: if a pending action was left and the user
// is now signed in, restore and continue; otherwise clear stale snapshots.
useEffect(() => {
if (!AUTH_ENABLED) return;
const hasPending =
sessionStorage.getItem(PENDING_START_KEY) !== null ||
sessionStorage.getItem(PENDING_PARSE_KEY) !== null;
if (!hasPending) return;
let cancelled = false;
void (async () => {
if (!(await isAuthed())) {
sessionStorage.removeItem(PENDING_START_KEY);
sessionStorage.removeItem(PENDING_PARSE_KEY);
return;
}
if (cancelled) return;
await resumePendingParse();
resumePendingStart();
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Run the resumed start() only after restored form state has committed.
useEffect(() => {
if (!autoStartPending) return;
setAutoStartPending(false);
void start();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStartPending]);
const start = async () => {
if (AUTH_ENABLED) {
const sb = createSupabaseClient();
const { data } = await sb.auth.getUser();
if (!data.user) {
persistPendingStart();
setPendingAction("start");
setAuthModalOpen(true);
return;
}
}
// 空输入时落回 Typewriter 当前闪动的示例——用户看到啥就玩啥,
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
const userPrompt =
@@ -1525,6 +1676,7 @@ export default function HomePage() {
>
<i className="fa-brands fa-x-twitter" />
</a>
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
</div>
</header>
@@ -1794,6 +1946,7 @@ export default function HomePage() {
setCustomStyleGuide={setCustomStyleGuide}
customStyleRefImage={customStyleRefImage}
setCustomStyleRefImage={setCustomStyleRefImage}
onRequireAuth={() => setAuthModalOpen(true)}
/>
)}
{settingsOpen && (
@@ -1813,6 +1966,35 @@ export default function HomePage() {
}}
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => {
setAuthModalOpen(false);
setPendingAction(null);
try {
sessionStorage.removeItem(PENDING_START_KEY);
sessionStorage.removeItem(PENDING_PARSE_KEY);
} catch {
/* ignore */
}
}}
onSuccess={() => {
setAuthModalOpen(false);
// Email-OTP stays on the page, so resume inline: parse first (it
// reads its own snapshot), then the pending start.
void resumePendingParse();
if (pendingAction === "start") {
setPendingAction(null);
try {
sessionStorage.removeItem(PENDING_START_KEY);
} catch {
/* ignore */
}
start();
}
}}
/>
)}
</div>
);
}
+82 -19
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";
@@ -601,12 +605,29 @@ 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);
// `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess
// after the user signs in. Omitted by callers whose path can't actually 401
// (initial load already gated on the homepage, recorded replay is local).
const handleAuthError = useCallback(
(e: unknown, retry?: () => void): boolean => {
if (e instanceof AuthRequiredError) {
authResolveRef.current = retry ?? null;
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
@@ -1336,8 +1357,10 @@ function PlayInner() {
setPhase("ready");
track("scene_reached", { scene_index: 1 });
} catch (e) {
trackPlayError("start", e, t0);
setError(e instanceof Error ? e.message : String(e));
if (!handleAuthError(e)) {
trackPlayError("start", e, t0);
setError(e instanceof Error ? e.message : String(e));
}
}
})();
return;
@@ -1476,8 +1499,10 @@ function PlayInner() {
track("scene_reached", { scene_index: initial.history.length });
})
.catch((e) => {
trackPlayError("start", e, startT0);
setError(String(e));
if (!handleAuthError(e)) {
trackPlayError("start", e, startT0);
setError(String(e));
}
});
}, [params, router]);
@@ -1546,6 +1571,7 @@ function PlayInner() {
exit: SceneExit,
visitedForCurrent: string[],
exitLabel: string,
retry?: () => void,
) {
const sceneT0 = Date.now();
setPhase("transitioning");
@@ -1605,8 +1631,10 @@ function PlayInner() {
setPhase("ready");
return;
}
trackPlayError("scene", e, sceneT0);
setError(String(e));
if (!handleAuthError(e, retry)) {
trackPlayError("scene", e, sceneT0);
setError(String(e));
}
setPhase("ready");
}
}
@@ -1682,8 +1710,10 @@ function PlayInner() {
setPhase("ready");
track("scene_reached", { scene_index: nextSession.history.length });
} catch (e) {
trackPlayError("scene", e, replayT0);
setError(e instanceof Error ? e.message : String(e));
if (!handleAuthError(e)) {
trackPlayError("scene", e, replayT0);
setError(e instanceof Error ? e.message : String(e));
}
setPhase("ready");
}
})();
@@ -1798,7 +1828,9 @@ function PlayInner() {
const cached = consumeChoice(poolRef.current, choice.id);
if (cached) {
void performSceneTransition(cached, exit, visited, choice.label);
void performSceneTransition(cached, exit, visited, choice.label, () =>
onSelectChoice(choice),
);
return;
}
@@ -1823,7 +1855,9 @@ function PlayInner() {
return data;
})();
void performSceneTransition(promise, exit, visited, choice.label);
void performSceneTransition(promise, exit, visited, choice.label, () =>
onSelectChoice(choice),
);
}
async function onFreeformInput(text: string) {
@@ -1922,10 +1956,18 @@ function PlayInner() {
})();
setPendingClick(null);
void performSceneTransition(promise, exit, visited, decision.freeformAction);
void performSceneTransition(
promise,
exit,
visited,
decision.freeformAction,
() => onFreeformInput(text),
);
} catch (e) {
trackPlayError("freeform", e, freeformT0);
setError(String(e));
if (!handleAuthError(e, () => onFreeformInput(text))) {
trackPlayError("freeform", e, freeformT0);
setError(String(e));
}
setPhase("ready");
}
}
@@ -2028,11 +2070,14 @@ function PlayInner() {
exit,
visited,
decision.intent.freeformAction,
() => onBackgroundClick(click),
);
}
} catch (e) {
trackPlayError("vision", e, visionT0);
setError(String(e));
if (!handleAuthError(e, () => onBackgroundClick(click))) {
trackPlayError("vision", e, visionT0);
setError(String(e));
}
setPendingClick(null);
setPhase("ready");
}
@@ -2165,10 +2210,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>
@@ -2274,6 +2322,21 @@ function PlayInner() {
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => {
setAuthModalOpen(false);
// User dismissed login — drop the retry, don't re-run the action.
authResolveRef.current = null;
}}
onSuccess={() => {
setAuthModalOpen(false);
const retry = authResolveRef.current;
authResolveRef.current = null;
retry?.();
}}
/>
)}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { track } from "@/lib/analytics";
type AuthStep = "pick" | "email-input" | "otp-verify";
export function AuthModal({
onClose,
onSuccess,
}: {
onClose: () => void;
onSuccess: () => void;
}) {
const [step, setStep] = useState<AuthStep>("pick");
const [email, setEmail] = useState("");
const [otp, setOtp] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const handleOAuth = useCallback(
async (provider: "google" | "github") => {
setLoading(true);
setError("");
const supabase = createClient();
const { error: oauthError } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(window.location.pathname + window.location.search)}`,
},
});
if (oauthError) {
setError(oauthError.message);
setLoading(false);
}
},
[],
);
const handleSendOtp = useCallback(async () => {
const trimmed = email.trim();
if (!trimmed) return;
setLoading(true);
setError("");
const supabase = createClient();
const { error: otpError } = await supabase.auth.signInWithOtp({
email: trimmed,
});
setLoading(false);
if (otpError) {
setError(otpError.message);
} else {
setStep("otp-verify");
}
}, [email]);
const handleVerifyOtp = useCallback(async () => {
const trimmedOtp = otp.trim();
if (!trimmedOtp) return;
setLoading(true);
setError("");
const supabase = createClient();
const { error: verifyError } = await supabase.auth.verifyOtp({
email: email.trim(),
token: trimmedOtp,
type: "email",
});
setLoading(false);
if (verifyError) {
setError(verifyError.message);
} else {
track("login_success", { provider: "email" });
onSuccess();
}
}, [email, otp, onSuccess]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: "rgba(0,0,0,0.55)" }}
onClick={onClose}
>
<div
className="w-full max-w-sm overflow-hidden"
onClick={(e) => e.stopPropagation()}
style={{
background: "rgba(14, 10, 6, 0.92)",
border: "1.5px solid rgba(175, 138, 72, 0.72)",
borderRadius: "8px",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
boxShadow:
"0 10px 42px rgba(0,0,0,0.62), inset 0 1px 0 rgba(200,165,90,0.12)",
}}
role="dialog"
aria-modal="true"
aria-label="登录"
>
{/* header */}
<div className="flex items-center justify-between border-b border-cream-50/10 px-5 py-3.5">
<div className="flex items-center gap-2 text-[11px] smallcaps text-cream-50/70">
<i className="fa-solid fa-right-to-bracket text-[11px]" />
{step === "pick" && "登录以继续"}
{step === "email-input" && "邮箱登录"}
{step === "otp-verify" && "验证码"}
</div>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-cream-50/60 transition-colors hover:text-cream-50"
aria-label="关闭"
>
<i className="fa-solid fa-xmark text-[12px]" />
</button>
</div>
<div className="px-5 py-5 space-y-3">
{error && (
<p className="text-[12px] text-red-400/90 leading-snug">{error}</p>
)}
{step === "pick" && (
<>
<button
type="button"
disabled={loading}
onClick={() => handleOAuth("google")}
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50"
>
<i className="fa-brands fa-google text-[14px]" />
Google
</button>
<button
type="button"
disabled={loading}
onClick={() => handleOAuth("github")}
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50"
>
<i className="fa-brands fa-github text-[14px]" />
GitHub
</button>
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-cream-50/10" />
<span className="text-[10px] text-cream-50/40"></span>
<div className="h-px flex-1 bg-cream-50/10" />
</div>
<button
type="button"
onClick={() => setStep("email-input")}
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12]"
>
<i className="fa-solid fa-envelope text-[13px]" />
</button>
</>
)}
{step === "email-input" && (
<>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSendOtp()}
placeholder="your@email.com"
autoFocus
className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-[13px] text-cream-50/90 placeholder:text-cream-50/30 outline-none focus:border-[rgba(175,138,72,0.6)]"
/>
<button
type="button"
disabled={loading || !email.trim()}
onClick={handleSendOtp}
className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50"
>
{loading ? "发送中..." : "发送验证码"}
</button>
<button
type="button"
onClick={() => {
setStep("pick");
setError("");
}}
className="w-full text-center text-[12px] text-cream-50/50 transition-colors hover:text-cream-50/80"
>
</button>
</>
)}
{step === "otp-verify" && (
<>
<p className="text-[12px] text-cream-50/60 leading-snug">
<span className="text-cream-50/90">{email.trim()}</span>
</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
onKeyDown={(e) => e.key === "Enter" && handleVerifyOtp()}
placeholder="6 位验证码"
autoFocus
className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-center text-[16px] tracking-[0.35em] text-cream-50/90 placeholder:text-cream-50/30 placeholder:tracking-normal outline-none focus:border-[rgba(175,138,72,0.6)]"
/>
<button
type="button"
disabled={loading || otp.length < 6}
onClick={handleVerifyOtp}
className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50"
>
{loading ? "验证中..." : "确认"}
</button>
<button
type="button"
onClick={() => {
setStep("email-input");
setOtp("");
setError("");
}}
className="w-full text-center text-[12px] text-cream-50/50 transition-colors hover:text-cream-50/80"
>
</button>
</>
)}
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/client";
import type { AuthChangeEvent, Session, User } from "@supabase/supabase-js";
export function UserChip({
onLoginClick,
}: {
onLoginClick: () => void;
}) {
const [user, setUser] = useState<User | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!AUTH_ENABLED) return;
const supabase = createClient();
supabase.auth.getUser().then(({ data }: { data: { user: User | null } }) => setUser(data.user));
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => {
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe();
}, []);
const handleLogout = useCallback(async () => {
const supabase = createClient();
await supabase.auth.signOut();
setUser(null);
setMenuOpen(false);
}, []);
if (!AUTH_ENABLED) return null;
if (!user) {
return (
<button
type="button"
onClick={onLoginClick}
className="flex items-center gap-1.5 rounded-full border border-cream-50/15 bg-cream-50/[0.06] px-3 py-1.5 text-[11px] text-cream-50/70 transition-colors hover:bg-cream-50/[0.12] hover:text-cream-50/90"
>
<i className="fa-solid fa-right-to-bracket text-[10px]" />
</button>
);
}
const label =
user.user_metadata?.full_name ??
user.email?.split("@")[0] ??
"User";
const avatarUrl = user.user_metadata?.avatar_url as string | undefined;
const initial = label.charAt(0).toUpperCase();
return (
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center gap-2 rounded-full border border-cream-50/15 bg-cream-50/[0.06] pl-1.5 pr-3 py-1 text-[11px] text-cream-50/80 transition-colors hover:bg-cream-50/[0.12]"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-5 w-5 rounded-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[rgba(175,138,72,0.6)] text-[10px] font-medium text-cream-50">
{initial}
</span>
)}
<span className="max-w-[100px] truncate">{label}</span>
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div
className="absolute right-0 top-full z-50 mt-1 min-w-[120px] overflow-hidden rounded-md"
style={{
background: "rgba(14, 10, 6, 0.92)",
border: "1px solid rgba(175, 138, 72, 0.5)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
}}
>
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-2 px-3.5 py-2.5 text-[12px] text-cream-50/70 transition-colors hover:bg-cream-50/[0.08] hover:text-cream-50/90"
>
<i className="fa-solid fa-right-from-bracket text-[11px]" />
退
</button>
</div>
</>
)}
</div>
);
}
+1
View File
@@ -54,6 +54,7 @@ type AnalyticsEventData = {
fullscreen_toggle: { on: boolean };
play_heartbeat: never;
gallery_export: { scene_count: number; audio_count: number };
login_success: { provider: "google" | "github" | "email" };
play_error: {
source: "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch";
kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown";
+8
View File
@@ -31,6 +31,13 @@ function getClientConfig(): EngineConfig | null {
return resolveEngineConfig(modelCfg, ttsCfg);
}
export class AuthRequiredError extends Error {
constructor() {
super("Unauthorized");
this.name = "AuthRequiredError";
}
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
@@ -38,6 +45,7 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
body: JSON.stringify(body),
});
if (!res.ok) {
if (res.status === 401) throw new AuthRequiredError();
let message = `HTTP ${res.status}`;
try {
const data = (await res.json()) as { error?: string };
+12
View File
@@ -0,0 +1,12 @@
import { createBrowserClient } from "@supabase/ssr";
let client: ReturnType<typeof createBrowserClient> | null = null;
export function createClient() {
if (client) return client;
client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
return client;
}
+3
View File
@@ -0,0 +1,3 @@
export const AUTH_ENABLED =
!!process.env.NEXT_PUBLIC_SUPABASE_URL &&
!!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { AUTH_ENABLED } from "./config";
import { createClient } from "./server";
export async function requireUser(): Promise<
{ userId: string } | NextResponse
> {
if (!AUTH_ENABLED) return { userId: "anonymous" };
const supabase = await createClient();
const claims = await supabase.auth.getClaims();
if (claims.error || !claims.data?.claims?.sub) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return { userId: claims.data.claims.sub };
}
+26
View File
@@ -0,0 +1,26 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
try {
for (const { name, value, options } of cookiesToSet) {
cookieStore.set(name, value, options);
}
} catch {
// `setAll` can be invoked from a Server Component, where the cookie
// store is read-only and throws. Safe to ignore — the proxy
// middleware refreshes the session on the next request.
}
},
},
},
);
}
+2
View File
@@ -20,6 +20,8 @@
"deploy:cf": "opennextjs-cloudflare deploy"
},
"dependencies": {
"@supabase/ssr": "^0.12",
"@supabase/supabase-js": "^2.108",
"jsonrepair": "^3.14.0",
"jszip": "^3.10.1",
"next": "^16.0.0",
+81
View File
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@supabase/ssr':
specifier: ^0.12
version: 0.12.0(@supabase/supabase-js@2.108.1)
'@supabase/supabase-js':
specifier: ^2.108
version: 2.108.1
jsonrepair:
specifier: ^3.14.0
version: 3.14.0
@@ -1214,6 +1220,38 @@ packages:
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
'@supabase/auth-js@2.108.1':
resolution: {integrity: sha512-Lle5rKU8f9LF3K5dDd8Or8mkkG+ptzRZZWKPVMm9B9UuovH65Ss2+iFnQqRsCqaGouvJEcTWyl0cj2riNrrDLQ==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.108.1':
resolution: {integrity: sha512-fxBRW/A4IG7ADQztVt0NaEy5ysiO1WJ2pbldsnBchrkHuyepX0Krek9qA9T4gUQBVVTCE9Ea4pdsM5hfn3nc4A==}
engines: {node: '>=20.0.0'}
'@supabase/phoenix@0.4.2':
resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==}
'@supabase/postgrest-js@2.108.1':
resolution: {integrity: sha512-9lj2MCPPMgSTaJ5y+amnhb3TWPtMFVlbDn2hmX/VV91xQU4j0AauwfMaBErHBJ+zzsSwjc0jLU+zLIZFLQzfig==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.108.1':
resolution: {integrity: sha512-mHGGqOjwd1XTydcoffUqEMsbFQHUi6A3uhQ0EXr3iqzpLqItxKA9nbN6gIQxrZ7JRRnuUe/iOFPUkYV9Tdc5lg==}
engines: {node: '>=20.0.0'}
'@supabase/ssr@0.12.0':
resolution: {integrity: sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==}
peerDependencies:
'@supabase/supabase-js': ^2.108.0
'@supabase/storage-js@2.108.1':
resolution: {integrity: sha512-Er0SGGt85iT6ye+SSh98Az6L2CesoZJuyzEZYH2oBOAnIxa9Nn4CtwUC3veGxYggoT56X+3tVuuQeDBP8kR8sg==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.108.1':
resolution: {integrity: sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==}
engines: {node: '>=20.0.0'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1707,6 +1745,10 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
iceberg-js@0.8.1:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -3757,6 +3799,43 @@ snapshots:
'@speed-highlight/core@1.2.15': {}
'@supabase/auth-js@2.108.1':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.108.1':
dependencies:
tslib: 2.8.1
'@supabase/phoenix@0.4.2': {}
'@supabase/postgrest-js@2.108.1':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.108.1':
dependencies:
'@supabase/phoenix': 0.4.2
tslib: 2.8.1
'@supabase/ssr@0.12.0(@supabase/supabase-js@2.108.1)':
dependencies:
'@supabase/supabase-js': 2.108.1
cookie: 1.1.1
'@supabase/storage-js@2.108.1':
dependencies:
iceberg-js: 0.8.1
tslib: 2.8.1
'@supabase/supabase-js@2.108.1':
dependencies:
'@supabase/auth-js': 2.108.1
'@supabase/functions-js': 2.108.1
'@supabase/postgrest-js': 2.108.1
'@supabase/realtime-js': 2.108.1
'@supabase/storage-js': 2.108.1
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -4313,6 +4392,8 @@ snapshots:
dependencies:
ms: 2.1.3
iceberg-js@0.8.1: {}
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
+31
View File
@@ -0,0 +1,31 @@
import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function proxy(request: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) return NextResponse.next();
let response = NextResponse.next({ request });
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
for (const { name, value } of cookiesToSet) {
request.cookies.set(name, value);
}
response = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
},
},
});
// Must await: getUser() triggers the token refresh, and the refreshed
// cookies are written to `response` via the setAll callback above. Returning
// before it resolves can drop the refreshed session cookie.
await supabase.auth.getUser();
return response;
}