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:
yuanzonghao
2026-06-13 17:33:55 +08:00
parent 2a2d58a64f
commit 87a2f93edb
22 changed files with 646 additions and 11 deletions
+9
View File
@@ -161,3 +161,12 @@ NEXT_PUBLIC_UMAMI_DOMAINS=
# WARNING: rotating this secret invalidates every share file ever issued
# (decryption will fail with "文件校验失败"). Only change when you're OK with that.
GALLERY_SECRET=
# ---- 8. Auth · Supabase (optional — leave blank to disable) -------
# Sign up at https://supabase.com, create a project, copy the URL and
# publishable key (starts with sb_publishable_ or eyJ…).
# Both blank → login UI is completely absent, all API routes run unguarded,
# and the app behaves exactly as before this feature existed.
# NEXT_PUBLIC_ vars are inlined at BUILD time.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
+4
View File
@@ -2,10 +2,14 @@ import { requestBeatAudio } from "@infiplot/engine";
import type { BeatAudioRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: BeatAudioRequest;
try {
body = (await req.json()) as BeatAudioRequest;
+4
View File
@@ -2,10 +2,14 @@ import { classifyFreeform } from "@infiplot/engine";
import type { FreeformClassifyRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: FreeformClassifyRequest;
try {
body = (await req.json()) as FreeformClassifyRequest;
+4
View File
@@ -2,10 +2,14 @@ import { requestInsertBeat } from "@infiplot/engine";
import type { InsertBeatRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: InsertBeatRequest;
try {
body = (await req.json()) as InsertBeatRequest;
+4
View File
@@ -5,6 +5,7 @@ import type {
} from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -26,6 +27,9 @@ Do NOT describe the characters, objects, or scene contents. Output exactly one J
{"stylePrompt": "<comma-separated English visual-style attributes, ~30-60 words>"}`;
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: ParseStyleImageRequest;
try {
body = (await req.json()) as ParseStyleImageRequest;
+4
View File
@@ -2,6 +2,7 @@ import { requestScene } from "@infiplot/engine";
import type { Character, SceneRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
function stripKnownVoices(
characters: Character[],
@@ -15,6 +16,9 @@ function stripKnownVoices(
export const runtime = "nodejs";
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: SceneRequest;
try {
body = (await req.json()) as SceneRequest;
+4
View File
@@ -2,6 +2,7 @@ import { startSession } from "@infiplot/engine";
import type { StartRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -11,6 +12,9 @@ export const runtime = "nodejs";
const MAX_STYLE_REF_BYTES = 3 * 1024 * 1024;
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: StartRequest;
try {
body = (await req.json()) as StartRequest;
+4
View File
@@ -2,6 +2,7 @@ import { visionDecide } from "@infiplot/engine";
import type { VisionRequest } from "@infiplot/types";
import { NextResponse } from "next/server";
import { loadEngineConfig } from "@/lib/config";
import { requireUser } from "@/lib/supabase/guard";
export const runtime = "nodejs";
@@ -11,6 +12,9 @@ export const runtime = "nodejs";
const MAX_ANNOTATED_BYTES = 3 * 1024 * 1024;
export async function POST(req: Request) {
const auth = await requireUser();
if (auth instanceof NextResponse) return auth;
let body: VisionRequest;
try {
body = (await req.json()) as VisionRequest;
+17
View File
@@ -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
View File
@@ -16,6 +16,10 @@ import { analyzeImageDataUrl } from "@infiplot/ai-client";
import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig";
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient as createSupabaseClient } from "@/lib/supabase/client";
import { AuthModal } from "@/components/AuthModal";
import { UserChip } from "@/components/UserChip";
/* ============================================================================
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -1281,6 +1285,8 @@ export default function HomePage() {
const [ttsConfigured, setTtsConfigured] = useState(false);
const [playerName, setPlayerName] = useState("");
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [pendingAction, setPendingAction] = useState<"start" | null>(null);
const styleRow = OPTS.findIndex((o) => o.modal);
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
@@ -1350,7 +1356,17 @@ export default function HomePage() {
}
};
const start = () => {
const start = async () => {
if (AUTH_ENABLED) {
const sb = createSupabaseClient();
const { data } = await sb.auth.getUser();
if (!data.user) {
setPendingAction("start");
setAuthModalOpen(true);
return;
}
}
// 空输入时落回 Typewriter 当前闪动的示例——用户看到啥就玩啥,
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
const userPrompt =
@@ -1525,6 +1541,7 @@ export default function HomePage() {
>
<i className="fa-brands fa-x-twitter" />
</a>
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
</div>
</header>
@@ -1813,6 +1830,21 @@ export default function HomePage() {
}}
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => {
setAuthModalOpen(false);
setPendingAction(null);
}}
onSuccess={() => {
setAuthModalOpen(false);
if (pendingAction === "start") {
setPendingAction(null);
start();
}
}}
/>
)}
</div>
);
}
+43 -10
View File
@@ -35,6 +35,7 @@ import {
visionDecide,
classifyFreeform,
requestInsertBeat,
AuthRequiredError,
} from "@/lib/engineClient";
import type {
Beat,
@@ -50,6 +51,9 @@ import type {
TtsConfig,
} from "@infiplot/types";
import { track } from "@/lib/analytics";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { AuthModal } from "@/components/AuthModal";
import { UserChip } from "@/components/UserChip";
const MUTED_STORAGE_KEY = "infiplot:muted";
@@ -536,12 +540,22 @@ function PlayInner() {
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
const [settingsOpen, setSettingsOpen] = useState(false);
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
const [authModalOpen, setAuthModalOpen] = useState(false);
const authResolveRef = useRef<(() => void) | null>(null);
// Top-of-screen progress toast for the gallery / story export pipeline.
// null when idle; { done, total, label } while collecting beat audio.
const [exportProgress, setExportProgress] = useState<
{ done: number; total: number; label: string } | null
>(null);
const handleAuthError = useCallback((e: unknown): boolean => {
if (e instanceof AuthRequiredError) {
setAuthModalOpen(true);
return true;
}
return false;
}, []);
const startedRef = useRef(false);
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
// Accumulator for resolved prefetches across the whole session — every
@@ -1215,7 +1229,7 @@ function PlayInner() {
setPhase("ready");
track("scene_reached", { scene_index: 1 });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e));
}
})();
return;
@@ -1352,7 +1366,9 @@ function PlayInner() {
setPhase("ready");
track("scene_reached", { scene_index: initial.history.length });
})
.catch((e) => setError(String(e)));
.catch((e) => {
if (!handleAuthError(e)) setError(String(e));
});
}, [params, router]);
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
@@ -1477,7 +1493,7 @@ function PlayInner() {
setPhase("ready");
return;
}
setError(String(e));
if (!handleAuthError(e)) setError(String(e));
setPhase("ready");
}
}
@@ -1550,7 +1566,7 @@ function PlayInner() {
setPhase("ready");
track("scene_reached", { scene_index: nextSession.history.length });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e));
setPhase("ready");
}
})();
@@ -1790,7 +1806,7 @@ function PlayInner() {
setPendingClick(null);
void performSceneTransition(promise, exit, visited, decision.freeformAction);
} catch (e) {
setError(String(e));
if (!handleAuthError(e)) setError(String(e));
setPhase("ready");
}
}
@@ -1895,7 +1911,7 @@ function PlayInner() {
);
}
} catch (e) {
setError(String(e));
if (!handleAuthError(e)) setError(String(e));
setPendingClick(null);
setPhase("ready");
}
@@ -2027,10 +2043,13 @@ function PlayInner() {
Infi<em className="italic font-light text-ember-500">Plot</em>
</span>
</Link>
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
<span> · {String(sceneCount).padStart(3, "0")} · </span>
<span className="text-clay-300">·</span>
<span>{String(beatCount).padStart(3, "0")} · </span>
<div className="flex items-center gap-3">
<div className="text-[10px] smallcaps text-clay-500 num flex items-center gap-3">
<span> · {String(sceneCount).padStart(3, "0")} · </span>
<span className="text-clay-300">·</span>
<span>{String(beatCount).padStart(3, "0")} · </span>
</div>
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
</div>
</header>
@@ -2135,6 +2154,20 @@ function PlayInner() {
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => {
setAuthModalOpen(false);
authResolveRef.current?.();
authResolveRef.current = null;
}}
onSuccess={() => {
setAuthModalOpen(false);
authResolveRef.current?.();
authResolveRef.current = null;
}}
/>
)}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { track } from "@/lib/analytics";
type AuthStep = "pick" | "email-input" | "otp-verify";
export function AuthModal({
onClose,
onSuccess,
}: {
onClose: () => void;
onSuccess: () => void;
}) {
const [step, setStep] = useState<AuthStep>("pick");
const [email, setEmail] = useState("");
const [otp, setOtp] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const handleOAuth = useCallback(
async (provider: "google" | "github") => {
setLoading(true);
setError("");
const supabase = createClient();
const { error: oauthError } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(window.location.pathname + window.location.search)}`,
},
});
if (oauthError) {
setError(oauthError.message);
setLoading(false);
}
},
[],
);
const handleSendOtp = useCallback(async () => {
const trimmed = email.trim();
if (!trimmed) return;
setLoading(true);
setError("");
const supabase = createClient();
const { error: otpError } = await supabase.auth.signInWithOtp({
email: trimmed,
});
setLoading(false);
if (otpError) {
setError(otpError.message);
} else {
setStep("otp-verify");
}
}, [email]);
const handleVerifyOtp = useCallback(async () => {
const trimmedOtp = otp.trim();
if (!trimmedOtp) return;
setLoading(true);
setError("");
const supabase = createClient();
const { error: verifyError } = await supabase.auth.verifyOtp({
email: email.trim(),
token: trimmedOtp,
type: "email",
});
setLoading(false);
if (verifyError) {
setError(verifyError.message);
} else {
track("login_success", { provider: "email" });
onSuccess();
}
}, [email, otp, onSuccess]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: "rgba(0,0,0,0.55)" }}
onClick={onClose}
>
<div
className="w-full max-w-sm overflow-hidden"
onClick={(e) => e.stopPropagation()}
style={{
background: "rgba(14, 10, 6, 0.92)",
border: "1.5px solid rgba(175, 138, 72, 0.72)",
borderRadius: "8px",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
boxShadow:
"0 10px 42px rgba(0,0,0,0.62), inset 0 1px 0 rgba(200,165,90,0.12)",
}}
role="dialog"
aria-modal="true"
aria-label="登录"
>
{/* header */}
<div className="flex items-center justify-between border-b border-cream-50/10 px-5 py-3.5">
<div className="flex items-center gap-2 text-[11px] smallcaps text-cream-50/70">
<i className="fa-solid fa-right-to-bracket text-[11px]" />
{step === "pick" && "登录以继续"}
{step === "email-input" && "邮箱登录"}
{step === "otp-verify" && "验证码"}
</div>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-cream-50/60 transition-colors hover:text-cream-50"
aria-label="关闭"
>
<i className="fa-solid fa-xmark text-[12px]" />
</button>
</div>
<div className="px-5 py-5 space-y-3">
{error && (
<p className="text-[12px] text-red-400/90 leading-snug">{error}</p>
)}
{step === "pick" && (
<>
<button
type="button"
disabled={loading}
onClick={() => handleOAuth("google")}
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50"
>
<i className="fa-brands fa-google text-[14px]" />
Google
</button>
<button
type="button"
disabled={loading}
onClick={() => handleOAuth("github")}
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12] disabled:opacity-50"
>
<i className="fa-brands fa-github text-[14px]" />
GitHub
</button>
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-cream-50/10" />
<span className="text-[10px] text-cream-50/40"></span>
<div className="h-px flex-1 bg-cream-50/10" />
</div>
<button
type="button"
onClick={() => setStep("email-input")}
className="flex w-full items-center justify-center gap-2.5 rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-4 py-2.5 text-[13px] text-cream-50/90 transition-colors hover:bg-cream-50/[0.12]"
>
<i className="fa-solid fa-envelope text-[13px]" />
</button>
</>
)}
{step === "email-input" && (
<>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSendOtp()}
placeholder="your@email.com"
autoFocus
className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-[13px] text-cream-50/90 placeholder:text-cream-50/30 outline-none focus:border-[rgba(175,138,72,0.6)]"
/>
<button
type="button"
disabled={loading || !email.trim()}
onClick={handleSendOtp}
className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50"
>
{loading ? "发送中..." : "发送验证码"}
</button>
<button
type="button"
onClick={() => {
setStep("pick");
setError("");
}}
className="w-full text-center text-[12px] text-cream-50/50 transition-colors hover:text-cream-50/80"
>
</button>
</>
)}
{step === "otp-verify" && (
<>
<p className="text-[12px] text-cream-50/60 leading-snug">
<span className="text-cream-50/90">{email.trim()}</span>
</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
onKeyDown={(e) => e.key === "Enter" && handleVerifyOtp()}
placeholder="6 位验证码"
autoFocus
className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-center text-[16px] tracking-[0.35em] text-cream-50/90 placeholder:text-cream-50/30 placeholder:tracking-normal outline-none focus:border-[rgba(175,138,72,0.6)]"
/>
<button
type="button"
disabled={loading || otp.length < 6}
onClick={handleVerifyOtp}
className="w-full rounded-md bg-[rgba(175,138,72,0.85)] px-4 py-2.5 text-[13px] font-medium text-cream-50 transition-colors hover:bg-[rgba(175,138,72,1)] disabled:opacity-50"
>
{loading ? "验证中..." : "确认"}
</button>
<button
type="button"
onClick={() => {
setStep("email-input");
setOtp("");
setError("");
}}
className="w-full text-center text-[12px] text-cream-50/50 transition-colors hover:text-cream-50/80"
>
</button>
</>
)}
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/client";
import type { AuthChangeEvent, Session, User } from "@supabase/supabase-js";
export function UserChip({
onLoginClick,
}: {
onLoginClick: () => void;
}) {
const [user, setUser] = useState<User | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!AUTH_ENABLED) return;
const supabase = createClient();
supabase.auth.getUser().then(({ data }: { data: { user: User | null } }) => setUser(data.user));
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => {
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe();
}, []);
const handleLogout = useCallback(async () => {
const supabase = createClient();
await supabase.auth.signOut();
setUser(null);
setMenuOpen(false);
}, []);
if (!AUTH_ENABLED) return null;
if (!user) {
return (
<button
type="button"
onClick={onLoginClick}
className="flex items-center gap-1.5 rounded-full border border-cream-50/15 bg-cream-50/[0.06] px-3 py-1.5 text-[11px] text-cream-50/70 transition-colors hover:bg-cream-50/[0.12] hover:text-cream-50/90"
>
<i className="fa-solid fa-right-to-bracket text-[10px]" />
</button>
);
}
const label =
user.user_metadata?.full_name ??
user.email?.split("@")[0] ??
"User";
const avatarUrl = user.user_metadata?.avatar_url as string | undefined;
const initial = label.charAt(0).toUpperCase();
return (
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center gap-2 rounded-full border border-cream-50/15 bg-cream-50/[0.06] pl-1.5 pr-3 py-1 text-[11px] text-cream-50/80 transition-colors hover:bg-cream-50/[0.12]"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-5 w-5 rounded-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[rgba(175,138,72,0.6)] text-[10px] font-medium text-cream-50">
{initial}
</span>
)}
<span className="max-w-[100px] truncate">{label}</span>
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div
className="absolute right-0 top-full z-50 mt-1 min-w-[120px] overflow-hidden rounded-md"
style={{
background: "rgba(14, 10, 6, 0.92)",
border: "1px solid rgba(175, 138, 72, 0.5)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
}}
>
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-2 px-3.5 py-2.5 text-[12px] text-cream-50/70 transition-colors hover:bg-cream-50/[0.08] hover:text-cream-50/90"
>
<i className="fa-solid fa-right-from-bracket text-[11px]" />
退
</button>
</div>
</>
)}
</div>
);
}
+1
View File
@@ -54,6 +54,7 @@ type AnalyticsEventData = {
fullscreen_toggle: { on: boolean };
play_heartbeat: never;
gallery_export: { scene_count: number; audio_count: number };
login_success: { provider: "google" | "github" | "email" };
};
export type AnalyticsEvent = keyof AnalyticsEventData;
+8
View File
@@ -31,6 +31,13 @@ function getClientConfig(): EngineConfig | null {
return resolveEngineConfig(modelCfg, ttsCfg);
}
export class AuthRequiredError extends Error {
constructor() {
super("Unauthorized");
this.name = "AuthRequiredError";
}
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
@@ -38,6 +45,7 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
body: JSON.stringify(body),
});
if (!res.ok) {
if (res.status === 401) throw new AuthRequiredError();
let message = `HTTP ${res.status}`;
try {
const data = (await res.json()) as { error?: string };
+12
View File
@@ -0,0 +1,12 @@
import { createBrowserClient } from "@supabase/ssr";
let client: ReturnType<typeof createBrowserClient> | null = null;
export function createClient() {
if (client) return client;
client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
return client;
}
+3
View File
@@ -0,0 +1,3 @@
export const AUTH_ENABLED =
!!process.env.NEXT_PUBLIC_SUPABASE_URL &&
!!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { AUTH_ENABLED } from "./config";
import { createClient } from "./server";
export async function requireUser(): Promise<
{ userId: string } | NextResponse
> {
if (!AUTH_ENABLED) return { userId: "anonymous" };
const supabase = await createClient();
const claims = await supabase.auth.getClaims();
if (claims.error || !claims.data?.claims?.sub) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return { userId: claims.data.claims.sub };
}
+20
View File
@@ -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);
}
},
},
},
);
}
+2
View File
@@ -20,6 +20,8 @@
"deploy:cf": "opennextjs-cloudflare deploy"
},
"dependencies": {
"@supabase/ssr": "^0.12",
"@supabase/supabase-js": "^2.108",
"jsonrepair": "^3.14.0",
"jszip": "^3.10.1",
"next": "^16.0.0",
+81
View File
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@supabase/ssr':
specifier: ^0.12
version: 0.12.0(@supabase/supabase-js@2.108.1)
'@supabase/supabase-js':
specifier: ^2.108
version: 2.108.1
jsonrepair:
specifier: ^3.14.0
version: 3.14.0
@@ -1214,6 +1220,38 @@ packages:
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
'@supabase/auth-js@2.108.1':
resolution: {integrity: sha512-Lle5rKU8f9LF3K5dDd8Or8mkkG+ptzRZZWKPVMm9B9UuovH65Ss2+iFnQqRsCqaGouvJEcTWyl0cj2riNrrDLQ==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.108.1':
resolution: {integrity: sha512-fxBRW/A4IG7ADQztVt0NaEy5ysiO1WJ2pbldsnBchrkHuyepX0Krek9qA9T4gUQBVVTCE9Ea4pdsM5hfn3nc4A==}
engines: {node: '>=20.0.0'}
'@supabase/phoenix@0.4.2':
resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==}
'@supabase/postgrest-js@2.108.1':
resolution: {integrity: sha512-9lj2MCPPMgSTaJ5y+amnhb3TWPtMFVlbDn2hmX/VV91xQU4j0AauwfMaBErHBJ+zzsSwjc0jLU+zLIZFLQzfig==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.108.1':
resolution: {integrity: sha512-mHGGqOjwd1XTydcoffUqEMsbFQHUi6A3uhQ0EXr3iqzpLqItxKA9nbN6gIQxrZ7JRRnuUe/iOFPUkYV9Tdc5lg==}
engines: {node: '>=20.0.0'}
'@supabase/ssr@0.12.0':
resolution: {integrity: sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==}
peerDependencies:
'@supabase/supabase-js': ^2.108.0
'@supabase/storage-js@2.108.1':
resolution: {integrity: sha512-Er0SGGt85iT6ye+SSh98Az6L2CesoZJuyzEZYH2oBOAnIxa9Nn4CtwUC3veGxYggoT56X+3tVuuQeDBP8kR8sg==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.108.1':
resolution: {integrity: sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==}
engines: {node: '>=20.0.0'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1707,6 +1745,10 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
iceberg-js@0.8.1:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -3757,6 +3799,43 @@ snapshots:
'@speed-highlight/core@1.2.15': {}
'@supabase/auth-js@2.108.1':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.108.1':
dependencies:
tslib: 2.8.1
'@supabase/phoenix@0.4.2': {}
'@supabase/postgrest-js@2.108.1':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.108.1':
dependencies:
'@supabase/phoenix': 0.4.2
tslib: 2.8.1
'@supabase/ssr@0.12.0(@supabase/supabase-js@2.108.1)':
dependencies:
'@supabase/supabase-js': 2.108.1
cookie: 1.1.1
'@supabase/storage-js@2.108.1':
dependencies:
iceberg-js: 0.8.1
tslib: 2.8.1
'@supabase/supabase-js@2.108.1':
dependencies:
'@supabase/auth-js': 2.108.1
'@supabase/functions-js': 2.108.1
'@supabase/postgrest-js': 2.108.1
'@supabase/realtime-js': 2.108.1
'@supabase/storage-js': 2.108.1
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -4313,6 +4392,8 @@ snapshots:
dependencies:
ms: 2.1.3
iceberg-js@0.8.1: {}
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
+28
View File
@@ -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;
}