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:
@@ -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=
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+40
-7
@@ -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,11 +2043,14 @@ 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">
|
||||
<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>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-6 md:py-10">
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
};
|
||||
|
||||
export type AnalyticsEvent = keyof AnalyticsEventData;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const AUTH_ENABLED =
|
||||
!!process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
||||
!!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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) => {
|
||||
for (const { name, value, options } of cookiesToSet) {
|
||||
cookieStore.set(name, value, options);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+81
@@ -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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
|
||||
export 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
supabase.auth.getUser();
|
||||
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user