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:
@@ -161,3 +161,12 @@ NEXT_PUBLIC_UMAMI_DOMAINS=
|
|||||||
# WARNING: rotating this secret invalidates every share file ever issued
|
# WARNING: rotating this secret invalidates every share file ever issued
|
||||||
# (decryption will fail with "文件校验失败"). Only change when you're OK with that.
|
# (decryption will fail with "文件校验失败"). Only change when you're OK with that.
|
||||||
GALLERY_SECRET=
|
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 type { BeatAudioRequest } from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: BeatAudioRequest;
|
let body: BeatAudioRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as BeatAudioRequest;
|
body = (await req.json()) as BeatAudioRequest;
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { classifyFreeform } from "@infiplot/engine";
|
|||||||
import type { FreeformClassifyRequest } from "@infiplot/types";
|
import type { FreeformClassifyRequest } from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: FreeformClassifyRequest;
|
let body: FreeformClassifyRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as FreeformClassifyRequest;
|
body = (await req.json()) as FreeformClassifyRequest;
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { requestInsertBeat } from "@infiplot/engine";
|
|||||||
import type { InsertBeatRequest } from "@infiplot/types";
|
import type { InsertBeatRequest } from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: InsertBeatRequest;
|
let body: InsertBeatRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as InsertBeatRequest;
|
body = (await req.json()) as InsertBeatRequest;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from "@infiplot/types";
|
} from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
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>"}`;
|
{"stylePrompt": "<comma-separated English visual-style attributes, ~30-60 words>"}`;
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: ParseStyleImageRequest;
|
let body: ParseStyleImageRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as ParseStyleImageRequest;
|
body = (await req.json()) as ParseStyleImageRequest;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { requestScene } from "@infiplot/engine";
|
|||||||
import type { Character, SceneRequest } from "@infiplot/types";
|
import type { Character, SceneRequest } from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
function stripKnownVoices(
|
function stripKnownVoices(
|
||||||
characters: Character[],
|
characters: Character[],
|
||||||
@@ -15,6 +16,9 @@ function stripKnownVoices(
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: SceneRequest;
|
let body: SceneRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as SceneRequest;
|
body = (await req.json()) as SceneRequest;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { startSession } from "@infiplot/engine";
|
|||||||
import type { StartRequest } from "@infiplot/types";
|
import type { StartRequest } from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ export const runtime = "nodejs";
|
|||||||
const MAX_STYLE_REF_BYTES = 3 * 1024 * 1024;
|
const MAX_STYLE_REF_BYTES = 3 * 1024 * 1024;
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: StartRequest;
|
let body: StartRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as StartRequest;
|
body = (await req.json()) as StartRequest;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { visionDecide } from "@infiplot/engine";
|
|||||||
import type { VisionRequest } from "@infiplot/types";
|
import type { VisionRequest } from "@infiplot/types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loadEngineConfig } from "@/lib/config";
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ export const runtime = "nodejs";
|
|||||||
const MAX_ANNOTATED_BYTES = 3 * 1024 * 1024;
|
const MAX_ANNOTATED_BYTES = 3 * 1024 * 1024;
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
|
||||||
let body: VisionRequest;
|
let body: VisionRequest;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as VisionRequest;
|
body = (await req.json()) as VisionRequest;
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,10 @@ import { analyzeImageDataUrl } from "@infiplot/ai-client";
|
|||||||
import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig";
|
import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig";
|
||||||
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
|
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
|
||||||
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
|
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 · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||||
@@ -847,6 +851,51 @@ function CategorySelect({
|
|||||||
|
|
||||||
/* ---------- style picker modal ---------- */
|
/* ---------- 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({
|
function StyleModal({
|
||||||
items,
|
items,
|
||||||
value,
|
value,
|
||||||
@@ -856,6 +905,7 @@ function StyleModal({
|
|||||||
setCustomStyleGuide,
|
setCustomStyleGuide,
|
||||||
customStyleRefImage,
|
customStyleRefImage,
|
||||||
setCustomStyleRefImage,
|
setCustomStyleRefImage,
|
||||||
|
onRequireAuth,
|
||||||
}: {
|
}: {
|
||||||
items: string[];
|
items: string[];
|
||||||
value: number;
|
value: number;
|
||||||
@@ -865,6 +915,7 @@ function StyleModal({
|
|||||||
setCustomStyleGuide: (s: string) => void;
|
setCustomStyleGuide: (s: string) => void;
|
||||||
customStyleRefImage: string;
|
customStyleRefImage: string;
|
||||||
setCustomStyleRefImage: (s: string) => void;
|
setCustomStyleRefImage: (s: string) => void;
|
||||||
|
onRequireAuth: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [shown, setShown] = useState(false);
|
const [shown, setShown] = useState(false);
|
||||||
@@ -979,31 +1030,18 @@ function StyleModal({
|
|||||||
setParsing(true);
|
setParsing(true);
|
||||||
try {
|
try {
|
||||||
const resized = await resizeImageToDataUrl(file);
|
const resized = await resizeImageToDataUrl(file);
|
||||||
const modelCfg = readStoredModelConfig();
|
// The parse is a paid vision call, so require login first. The resize is
|
||||||
let stylePrompt: string;
|
// already done — stash it so login can auto-resume the parse on return.
|
||||||
if (modelCfg) {
|
if (!(await isAuthed())) {
|
||||||
const config = resolveEngineConfig(modelCfg, null);
|
|
||||||
const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT);
|
|
||||||
let parsed: { stylePrompt?: string };
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
sessionStorage.setItem(PENDING_PARSE_KEY, resized);
|
||||||
} catch {
|
} catch {
|
||||||
parsed = { stylePrompt: raw };
|
/* too big to stash — user re-uploads after login */
|
||||||
}
|
}
|
||||||
stylePrompt = (parsed.stylePrompt ?? "").trim();
|
onRequireAuth();
|
||||||
} else {
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
const stylePrompt = await extractStylePromptFromImage(resized);
|
||||||
if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述");
|
if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述");
|
||||||
setDraft(stylePrompt);
|
setDraft(stylePrompt);
|
||||||
setCustomStyleRefImage(resized);
|
setCustomStyleRefImage(resized);
|
||||||
@@ -1281,6 +1319,8 @@ export default function HomePage() {
|
|||||||
const [ttsConfigured, setTtsConfigured] = useState(false);
|
const [ttsConfigured, setTtsConfigured] = useState(false);
|
||||||
const [playerName, setPlayerName] = useState("");
|
const [playerName, setPlayerName] = useState("");
|
||||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
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 styleRow = OPTS.findIndex((o) => o.modal);
|
||||||
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
|
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 当前闪动的示例——用户看到啥就玩啥,
|
// 空输入时落回 Typewriter 当前闪动的示例——用户看到啥就玩啥,
|
||||||
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
|
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
|
||||||
const userPrompt =
|
const userPrompt =
|
||||||
@@ -1525,6 +1676,7 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
<i className="fa-brands fa-x-twitter" />
|
<i className="fa-brands fa-x-twitter" />
|
||||||
</a>
|
</a>
|
||||||
|
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -1794,6 +1946,7 @@ export default function HomePage() {
|
|||||||
setCustomStyleGuide={setCustomStyleGuide}
|
setCustomStyleGuide={setCustomStyleGuide}
|
||||||
customStyleRefImage={customStyleRefImage}
|
customStyleRefImage={customStyleRefImage}
|
||||||
setCustomStyleRefImage={setCustomStyleRefImage}
|
setCustomStyleRefImage={setCustomStyleRefImage}
|
||||||
|
onRequireAuth={() => setAuthModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settingsOpen && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-19
@@ -35,6 +35,7 @@ import {
|
|||||||
visionDecide,
|
visionDecide,
|
||||||
classifyFreeform,
|
classifyFreeform,
|
||||||
requestInsertBeat,
|
requestInsertBeat,
|
||||||
|
AuthRequiredError,
|
||||||
} from "@/lib/engineClient";
|
} from "@/lib/engineClient";
|
||||||
import type {
|
import type {
|
||||||
Beat,
|
Beat,
|
||||||
@@ -50,6 +51,9 @@ import type {
|
|||||||
TtsConfig,
|
TtsConfig,
|
||||||
} from "@infiplot/types";
|
} from "@infiplot/types";
|
||||||
import { track } from "@/lib/analytics";
|
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";
|
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||||||
|
|
||||||
@@ -601,12 +605,29 @@ function PlayInner() {
|
|||||||
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
|
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
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.
|
// Top-of-screen progress toast for the gallery / story export pipeline.
|
||||||
// null when idle; { done, total, label } while collecting beat audio.
|
// null when idle; { done, total, label } while collecting beat audio.
|
||||||
const [exportProgress, setExportProgress] = useState<
|
const [exportProgress, setExportProgress] = useState<
|
||||||
{ done: number; total: number; label: string } | null
|
{ done: number; total: number; label: string } | null
|
||||||
>(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 startedRef = useRef(false);
|
||||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||||
// Accumulator for resolved prefetches across the whole session — every
|
// Accumulator for resolved prefetches across the whole session — every
|
||||||
@@ -1336,8 +1357,10 @@ function PlayInner() {
|
|||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
track("scene_reached", { scene_index: 1 });
|
track("scene_reached", { scene_index: 1 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackPlayError("start", e, t0);
|
if (!handleAuthError(e)) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
trackPlayError("start", e, t0);
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
@@ -1476,8 +1499,10 @@ function PlayInner() {
|
|||||||
track("scene_reached", { scene_index: initial.history.length });
|
track("scene_reached", { scene_index: initial.history.length });
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
trackPlayError("start", e, startT0);
|
if (!handleAuthError(e)) {
|
||||||
setError(String(e));
|
trackPlayError("start", e, startT0);
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [params, router]);
|
}, [params, router]);
|
||||||
|
|
||||||
@@ -1546,6 +1571,7 @@ function PlayInner() {
|
|||||||
exit: SceneExit,
|
exit: SceneExit,
|
||||||
visitedForCurrent: string[],
|
visitedForCurrent: string[],
|
||||||
exitLabel: string,
|
exitLabel: string,
|
||||||
|
retry?: () => void,
|
||||||
) {
|
) {
|
||||||
const sceneT0 = Date.now();
|
const sceneT0 = Date.now();
|
||||||
setPhase("transitioning");
|
setPhase("transitioning");
|
||||||
@@ -1605,8 +1631,10 @@ function PlayInner() {
|
|||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trackPlayError("scene", e, sceneT0);
|
if (!handleAuthError(e, retry)) {
|
||||||
setError(String(e));
|
trackPlayError("scene", e, sceneT0);
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1682,8 +1710,10 @@ function PlayInner() {
|
|||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
track("scene_reached", { scene_index: nextSession.history.length });
|
track("scene_reached", { scene_index: nextSession.history.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackPlayError("scene", e, replayT0);
|
if (!handleAuthError(e)) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
trackPlayError("scene", e, replayT0);
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -1798,7 +1828,9 @@ function PlayInner() {
|
|||||||
|
|
||||||
const cached = consumeChoice(poolRef.current, choice.id);
|
const cached = consumeChoice(poolRef.current, choice.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
void performSceneTransition(cached, exit, visited, choice.label);
|
void performSceneTransition(cached, exit, visited, choice.label, () =>
|
||||||
|
onSelectChoice(choice),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1823,7 +1855,9 @@ function PlayInner() {
|
|||||||
return data;
|
return data;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
void performSceneTransition(promise, exit, visited, choice.label);
|
void performSceneTransition(promise, exit, visited, choice.label, () =>
|
||||||
|
onSelectChoice(choice),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFreeformInput(text: string) {
|
async function onFreeformInput(text: string) {
|
||||||
@@ -1922,10 +1956,18 @@ function PlayInner() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
setPendingClick(null);
|
setPendingClick(null);
|
||||||
void performSceneTransition(promise, exit, visited, decision.freeformAction);
|
void performSceneTransition(
|
||||||
|
promise,
|
||||||
|
exit,
|
||||||
|
visited,
|
||||||
|
decision.freeformAction,
|
||||||
|
() => onFreeformInput(text),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackPlayError("freeform", e, freeformT0);
|
if (!handleAuthError(e, () => onFreeformInput(text))) {
|
||||||
setError(String(e));
|
trackPlayError("freeform", e, freeformT0);
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2028,11 +2070,14 @@ function PlayInner() {
|
|||||||
exit,
|
exit,
|
||||||
visited,
|
visited,
|
||||||
decision.intent.freeformAction,
|
decision.intent.freeformAction,
|
||||||
|
() => onBackgroundClick(click),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackPlayError("vision", e, visionT0);
|
if (!handleAuthError(e, () => onBackgroundClick(click))) {
|
||||||
setError(String(e));
|
trackPlayError("vision", e, visionT0);
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
setPendingClick(null);
|
setPendingClick(null);
|
||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
}
|
}
|
||||||
@@ -2165,10 +2210,13 @@ function PlayInner() {
|
|||||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
|
<div className="flex items-center gap-3">
|
||||||
<span>第 · {String(sceneCount).padStart(3, "0")} · 幕</span>
|
<div className="text-[10px] smallcaps text-clay-500 num flex items-center gap-3">
|
||||||
<span className="text-clay-300">·</span>
|
<span>第 · {String(sceneCount).padStart(3, "0")} · 幕</span>
|
||||||
<span>{String(beatCount).padStart(3, "0")} · 拍</span>
|
<span className="text-clay-300">·</span>
|
||||||
|
<span>{String(beatCount).padStart(3, "0")} · 拍</span>
|
||||||
|
</div>
|
||||||
|
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -2274,6 +2322,21 @@ function PlayInner() {
|
|||||||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
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>
|
</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 };
|
fullscreen_toggle: { on: boolean };
|
||||||
play_heartbeat: never;
|
play_heartbeat: never;
|
||||||
gallery_export: { scene_count: number; audio_count: number };
|
gallery_export: { scene_count: number; audio_count: number };
|
||||||
|
login_success: { provider: "google" | "github" | "email" };
|
||||||
play_error: {
|
play_error: {
|
||||||
source: "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch";
|
source: "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch";
|
||||||
kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown";
|
kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown";
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ function getClientConfig(): EngineConfig | null {
|
|||||||
return resolveEngineConfig(modelCfg, ttsCfg);
|
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> {
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -38,6 +45,7 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) throw new AuthRequiredError();
|
||||||
let message = `HTTP ${res.status}`;
|
let message = `HTTP ${res.status}`;
|
||||||
try {
|
try {
|
||||||
const data = (await res.json()) as { error?: string };
|
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,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.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"deploy:cf": "opennextjs-cloudflare deploy"
|
"deploy:cf": "opennextjs-cloudflare deploy"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.12",
|
||||||
|
"@supabase/supabase-js": "^2.108",
|
||||||
"jsonrepair": "^3.14.0",
|
"jsonrepair": "^3.14.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
|
|||||||
Generated
+81
@@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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:
|
jsonrepair:
|
||||||
specifier: ^3.14.0
|
specifier: ^3.14.0
|
||||||
version: 3.14.0
|
version: 3.14.0
|
||||||
@@ -1214,6 +1220,38 @@ packages:
|
|||||||
'@speed-highlight/core@1.2.15':
|
'@speed-highlight/core@1.2.15':
|
||||||
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
|
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':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -1707,6 +1745,10 @@ packages:
|
|||||||
humanize-ms@1.2.1:
|
humanize-ms@1.2.1:
|
||||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
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:
|
iconv-lite@0.7.2:
|
||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3757,6 +3799,43 @@ snapshots:
|
|||||||
|
|
||||||
'@speed-highlight/core@1.2.15': {}
|
'@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':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4313,6 +4392,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
iceberg-js@0.8.1: {}
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user