diff --git a/.env.example b/.env.example index 2141759..0655d9d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/api/beat-audio/route.ts b/app/api/beat-audio/route.ts index 7ef2484..e80726e 100644 --- a/app/api/beat-audio/route.ts +++ b/app/api/beat-audio/route.ts @@ -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; diff --git a/app/api/classify-freeform/route.ts b/app/api/classify-freeform/route.ts index d2c10a2..5ff9733 100644 --- a/app/api/classify-freeform/route.ts +++ b/app/api/classify-freeform/route.ts @@ -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; diff --git a/app/api/insert-beat/route.ts b/app/api/insert-beat/route.ts index 820b514..4d4a72d 100644 --- a/app/api/insert-beat/route.ts +++ b/app/api/insert-beat/route.ts @@ -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; diff --git a/app/api/parse-style-image/route.ts b/app/api/parse-style-image/route.ts index 1fca6e1..7568ef3 100644 --- a/app/api/parse-style-image/route.ts +++ b/app/api/parse-style-image/route.ts @@ -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": ""}`; 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; diff --git a/app/api/scene/route.ts b/app/api/scene/route.ts index ed25b0f..7523054 100644 --- a/app/api/scene/route.ts +++ b/app/api/scene/route.ts @@ -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; diff --git a/app/api/start/route.ts b/app/api/start/route.ts index 3ce3169..980d3b9 100644 --- a/app/api/start/route.ts +++ b/app/api/start/route.ts @@ -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; diff --git a/app/api/vision/route.ts b/app/api/vision/route.ts index 3bd2d59..4280239 100644 --- a/app/api/vision/route.ts +++ b/app/api/vision/route.ts @@ -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; diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..92e0bfd --- /dev/null +++ b/app/auth/callback/route.ts @@ -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`); +} diff --git a/app/page.tsx b/app/page.tsx index 6d61a19..b73d4e0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { > + setAuthModalOpen(true)} /> @@ -1813,6 +1830,21 @@ export default function HomePage() { }} /> )} + {authModalOpen && ( + { + setAuthModalOpen(false); + setPendingAction(null); + }} + onSuccess={() => { + setAuthModalOpen(false); + if (pendingAction === "start") { + setPendingAction(null); + start(); + } + }} + /> + )} ); } diff --git a/app/play/page.tsx b/app/play/page.tsx index 64ec63f..076fb0d 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -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>(new Map()); // Accumulator for resolved prefetches across the whole session — every @@ -1215,7 +1229,7 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: 1 }); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e)); } })(); return; @@ -1352,7 +1366,9 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: initial.history.length }); }) - .catch((e) => setError(String(e))); + .catch((e) => { + if (!handleAuthError(e)) setError(String(e)); + }); }, [params, router]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── @@ -1477,7 +1493,7 @@ function PlayInner() { setPhase("ready"); return; } - setError(String(e)); + if (!handleAuthError(e)) setError(String(e)); setPhase("ready"); } } @@ -1550,7 +1566,7 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: nextSession.history.length }); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e)); setPhase("ready"); } })(); @@ -1790,7 +1806,7 @@ function PlayInner() { setPendingClick(null); void performSceneTransition(promise, exit, visited, decision.freeformAction); } catch (e) { - setError(String(e)); + if (!handleAuthError(e)) setError(String(e)); setPhase("ready"); } } @@ -1895,7 +1911,7 @@ function PlayInner() { ); } } catch (e) { - setError(String(e)); + if (!handleAuthError(e)) setError(String(e)); setPendingClick(null); setPhase("ready"); } @@ -2027,10 +2043,13 @@ function PlayInner() { InfiPlot -
- 第 · {String(sceneCount).padStart(3, "0")} · 幕 - · - {String(beatCount).padStart(3, "0")} · 拍 +
+
+ 第 · {String(sceneCount).padStart(3, "0")} · 幕 + · + {String(beatCount).padStart(3, "0")} · 拍 +
+ setAuthModalOpen(true)} />
@@ -2135,6 +2154,20 @@ function PlayInner() { footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。" /> )} + {authModalOpen && ( + { + setAuthModalOpen(false); + authResolveRef.current?.(); + authResolveRef.current = null; + }} + onSuccess={() => { + setAuthModalOpen(false); + authResolveRef.current?.(); + authResolveRef.current = null; + }} + /> + )}
); } diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx new file mode 100644 index 0000000..8f1cd9b --- /dev/null +++ b/components/AuthModal.tsx @@ -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("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 ( +
+
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 */} +
+
+ + {step === "pick" && "登录以继续"} + {step === "email-input" && "邮箱登录"} + {step === "otp-verify" && "验证码"} +
+ +
+ +
+ {error && ( +

{error}

+ )} + + {step === "pick" && ( + <> + + +
+
+ +
+
+ + + )} + + {step === "email-input" && ( + <> + 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)]" + /> + + + + )} + + {step === "otp-verify" && ( + <> +

+ 验证码已发送至 {email.trim()} +

+ 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)]" + /> + + + + )} +
+
+
+ ); +} diff --git a/components/UserChip.tsx b/components/UserChip.tsx new file mode 100644 index 0000000..2f47fdd --- /dev/null +++ b/components/UserChip.tsx @@ -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(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 ( + + ); + } + + 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 ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} + /> +
+ +
+ + )} +
+ ); +} diff --git a/lib/analytics.ts b/lib/analytics.ts index fb89fb3..32785f1 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -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; diff --git a/lib/engineClient.ts b/lib/engineClient.ts index 066e342..a6163aa 100644 --- a/lib/engineClient.ts +++ b/lib/engineClient.ts @@ -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(path: string, body: unknown): Promise { const res = await fetch(path, { method: "POST", @@ -38,6 +45,7 @@ async function postJson(path: string, body: unknown): Promise { 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 }; diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts new file mode 100644 index 0000000..1078a77 --- /dev/null +++ b/lib/supabase/client.ts @@ -0,0 +1,12 @@ +import { createBrowserClient } from "@supabase/ssr"; + +let client: ReturnType | 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; +} diff --git a/lib/supabase/config.ts b/lib/supabase/config.ts new file mode 100644 index 0000000..7bb2167 --- /dev/null +++ b/lib/supabase/config.ts @@ -0,0 +1,3 @@ +export const AUTH_ENABLED = + !!process.env.NEXT_PUBLIC_SUPABASE_URL && + !!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; diff --git a/lib/supabase/guard.ts b/lib/supabase/guard.ts new file mode 100644 index 0000000..90a98c5 --- /dev/null +++ b/lib/supabase/guard.ts @@ -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 }; +} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts new file mode 100644 index 0000000..ce142b1 --- /dev/null +++ b/lib/supabase/server.ts @@ -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); + } + }, + }, + }, + ); +} diff --git a/package.json b/package.json index 94d7212..91ffb17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb56ee5..8d0e374 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..84c5b62 --- /dev/null +++ b/proxy.ts @@ -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; +}