新版首页支持弹窗登录并接入Google后台配置
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 00:39:46 +08:00
parent bc5838f9e1
commit 3f3523f1bb
14 changed files with 616 additions and 117 deletions

View File

@ -217,7 +217,11 @@ type AuthSettings = {
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
dingTalkAppKey?: string | null;
dingTalkAppSecret?: string | null;
googleClientId?: string | null;
googleClientSecret?: string | null;
authUrl?: string | null;
isDingTalkEnabled: boolean;
isGoogleEnabled: boolean;
isRegistrationEnabled?: boolean;
dingTalkOrganizations: DingTalkOrganization[];
};
@ -776,10 +780,15 @@ export function AdminSettingsClient() {
const [authSettings, setAuthSettings] = useState<AuthSettings>({
dingTalkAppKey: "",
dingTalkAppSecret: "",
googleClientId: "",
googleClientSecret: "",
authUrl: "",
isDingTalkEnabled: false,
isGoogleEnabled: false,
isRegistrationEnabled: true,
dingTalkOrganizations: [],
});
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
useEffect(() => {
if (!authSettingsData) return;
@ -804,10 +813,15 @@ export function AdminSettingsClient() {
setAuthSettings({
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
googleClientId: authSettingsData.googleClientId || "",
googleClientSecret: authSettingsData.googleClientSecret || "",
authUrl: authSettingsData.authUrl || "",
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
isGoogleEnabled: authSettingsData.isGoogleEnabled || false,
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
dingTalkOrganizations: normalizedOrgs,
});
setIsGoogleConfigured(Boolean(authSettingsData.isGoogleConfigured));
}, [authSettingsData]);
// AI 提供商 & 模型状态
@ -3863,6 +3877,69 @@ export function AdminSettingsClient() {
</div>
</div>
<Separator />
<div className="space-y-3">
<h3 className="text-lg font-semibold">Google 登录</h3>
<p className="text-sm text-muted-foreground">
通过 Google OAuth 登录,可直接在这里配置 Google 凭据与 AUTH_URL。
</p>
<div className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="googleClientId">Google Client ID</Label>
<Input
id="googleClientId"
value={authSettings.googleClientId || ""}
onChange={(e) =>
setAuthSettings({ ...authSettings, googleClientId: e.target.value })
}
placeholder="Google OAuth Client ID"
/>
</div>
<div className="space-y-1">
<Label htmlFor="googleClientSecret">Google Client Secret</Label>
<Input
id="googleClientSecret"
type="password"
value={authSettings.googleClientSecret || ""}
onChange={(e) =>
setAuthSettings({ ...authSettings, googleClientSecret: e.target.value })
}
placeholder="Google OAuth Client Secret"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="authUrl">AUTH_URL(站点地址)</Label>
<Input
id="authUrl"
value={authSettings.authUrl || ""}
onChange={(e) =>
setAuthSettings({ ...authSettings, authUrl: e.target.value })
}
placeholder="https://your-domain.com"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="googleEnabled">启用 Google 登录</Label>
<p className="text-xs text-muted-foreground">
当前服务器配置状态:{isGoogleConfigured ? "已配置" : "未配置"}
</p>
</div>
<Switch
id="googleEnabled"
checked={authSettings.isGoogleEnabled}
onCheckedChange={(checked) =>
setAuthSettings({ ...authSettings, isGoogleEnabled: checked })
}
/>
</div>
{!isGoogleConfigured ? (
<p className="text-xs text-amber-600">
未检测到完整 Google 配置(Client ID / Client Secret)。
</p>
) : null}
</div>
<Separator />
<Button onClick={handleSaveAuthSettings}>
<Save className="mr-2 h-4 w-4" /> 保存认证配置
</Button>

View File

@ -1,49 +1,48 @@
import type { NextAuthConfig } from "next-auth";
// 检测是否为 HTTPS 环境
const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false;
const hostName = process.env.AUTH_URL ? new URL(process.env.AUTH_URL).hostname : undefined;
const cookieDomain = hostName && hostName !== "localhost" ? hostName : undefined;
export function buildAuthConfig(authUrl?: string | null): NextAuthConfig {
const resolvedAuthUrl = authUrl || process.env.AUTH_URL || "";
const isSecure = resolvedAuthUrl.startsWith("https://");
const hostName = resolvedAuthUrl ? new URL(resolvedAuthUrl).hostname : undefined;
const cookieDomain = hostName && hostName !== "localhost" ? hostName : undefined;
export const authConfig = {
trustHost: true,
pages: {
signIn: "/login",
newUser: "/",
},
cookies: {
sessionToken: {
name: isSecure ? "__Secure-authjs.session-token" : "authjs.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: isSecure,
domain: cookieDomain,
return {
trustHost: true,
pages: {
signIn: "/login",
newUser: "/",
},
cookies: {
sessionToken: {
name: isSecure ? "__Secure-authjs.session-token" : "authjs.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: isSecure,
domain: cookieDomain,
},
},
callbackUrl: {
name: isSecure ? "__Secure-authjs.callback-url" : "authjs.callback-url",
options: {
sameSite: "lax",
path: "/",
secure: isSecure,
domain: cookieDomain,
},
},
csrfToken: {
name: isSecure ? "__Host-authjs.csrf-token" : "authjs.csrf-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: isSecure,
},
},
},
callbackUrl: {
name: isSecure ? "__Secure-authjs.callback-url" : "authjs.callback-url",
options: {
sameSite: "lax",
path: "/",
secure: isSecure,
domain: cookieDomain,
},
},
csrfToken: {
name: isSecure ? "__Host-authjs.csrf-token" : "authjs.csrf-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: isSecure,
},
},
},
providers: [
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
// while this file is also used in non-Node.js environments
],
callbacks: {},
} satisfies NextAuthConfig;
providers: [],
callbacks: {},
};
}

View File

@ -2,11 +2,12 @@ import { compare } from "bcrypt-ts";
import NextAuth, { type DefaultSession } from "next-auth";
import type { DefaultJWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { DUMMY_PASSWORD } from "@/lib/constants";
import { createGuestUser, getUser } from "@/lib/db/queries";
import { authConfig } from "./auth.config";
import { createGuestUser, getAuthSettings, getUser } from "@/lib/db/queries";
import { buildAuthConfig } from "./auth.config";
import { db } from "@/lib/db";
import { loginToken } from "@/lib/db/schema";
import { loginToken, user as userTable } from "@/lib/db/schema";
import { eq, and, gt } from "drizzle-orm";
export type UserType = "guest" | "regular" | "admin";
@ -25,7 +26,7 @@ declare module "next-auth" {
id?: string;
email?: string | null;
name?: string | null;
type: UserType;
type?: UserType;
}
}
@ -37,15 +38,28 @@ declare module "next-auth/jwt" {
}
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
...authConfig,
trustHost: true,
providers: [
async function getOrCreateGoogleUser(email: string, name?: string | null) {
const existingUsers = await getUser(email);
if (existingUsers.length > 0) {
return existingUsers[0];
}
const [createdUser] = await db
.insert(userTable)
.values({
email,
password: "",
name: (name || email.split("@")[0] || "").slice(0, 100),
role: "regular",
isActive: true,
})
.returning();
return createdUser || null;
}
function createBaseProviders(googleClientId?: string, googleClientSecret?: string) {
const providers: any[] = [
Credentials({
credentials: {},
async authorize(credentials: any) {
@ -141,25 +155,101 @@ export const {
return { ...guestUser, type: "guest" };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id as string;
token.type = user.type;
token.name = user.name;
}
];
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.type = token.type;
session.user.name = token.name as string;
}
if (googleClientId && googleClientSecret) {
providers.push(
Google({
clientId: googleClientId,
clientSecret: googleClientSecret,
})
);
}
return session;
return providers;
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth(async () => {
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
const resolvedAuthUrl =
runtimeAuthSettings?.authUrl || process.env.AUTH_URL || undefined;
const googleClientId =
runtimeAuthSettings?.googleClientId || process.env.GOOGLE_CLIENT_ID || "";
const googleClientSecret =
runtimeAuthSettings?.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET || "";
return {
...buildAuthConfig(resolvedAuthUrl),
trustHost: true,
providers: createBaseProviders(googleClientId, googleClientSecret),
callbacks: {
async signIn({ account, user, profile }) {
if (account?.provider !== "google") {
return true;
}
const authSettings = await getAuthSettings();
if (!authSettings.isGoogleEnabled) {
return false;
}
const email = (user?.email || (profile as any)?.email || "").trim().toLowerCase();
if (!email) {
return false;
}
const dbUser = await getOrCreateGoogleUser(
email,
user?.name || (profile as any)?.name || null
);
return Boolean(dbUser?.isActive);
},
async jwt({ token, user, account, profile }) {
if (user?.id && user?.type) {
token.id = user.id as string;
token.type = user.type;
token.name = user.name;
}
if (account?.provider === "google") {
const email = String(
user?.email || (profile as any)?.email || token.email || ""
)
.trim()
.toLowerCase();
if (email) {
const dbUser = await getOrCreateGoogleUser(
email,
user?.name || (profile as any)?.name || null
);
if (dbUser) {
token.id = dbUser.id;
token.type = (dbUser.role as UserType) || "regular";
token.name = dbUser.name;
token.email = dbUser.email;
}
}
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.type = token.type || "regular";
session.user.name = token.name as string;
}
return session;
},
},
},
};
});

View File

@ -28,6 +28,8 @@ export default function Page() {
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
const [isDingTalkEnabled, setIsDingTalkEnabled] = useState(false);
const [isGoogleEnabled, setIsGoogleEnabled] = useState(false);
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
const [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
const [dingTalkOrganizations, setDingTalkOrganizations] = useState<string[]>([]);
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
@ -215,6 +217,8 @@ export default function Page() {
.then((res) => res.json())
.then((data) => {
if (data.isDingTalkEnabled) setIsDingTalkEnabled(true);
if (data.isGoogleEnabled) setIsGoogleEnabled(true);
if (data.isGoogleConfigured) setIsGoogleConfigured(true);
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
if (Array.isArray(data.organizations)) {
setDingTalkOrganizations(
@ -230,6 +234,20 @@ export default function Page() {
.catch(console.error);
}, []);
const handleGoogleLogin = async () => {
if (!isGoogleEnabled) {
toast({ type: "error", description: "Google 登录未启用" });
return;
}
if (!isGoogleConfigured) {
toast({ type: "error", description: "Google 登录未完成服务器配置" });
return;
}
await signIn("google", { callbackUrl: "/" });
};
// 检查 URL 中是否有钉钉 Token(页面加载时自动登录)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@ -413,6 +431,9 @@ export default function Page() {
const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo;
const hasThirdPartyLogin =
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured);
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden">
@ -448,7 +469,7 @@ export default function Page() {
<AuthForm action={handleSubmit} defaultEmail={email} className="pointer-events-auto">
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) && (
{hasThirdPartyLogin && (
<div className="mt-4 w-full relative">
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
@ -460,19 +481,36 @@ export default function Page() {
</span>
</div>
</div>
<button
type="button"
onClick={handleDingTalkLogin}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg style={{ width: "20px", height: "20px" }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z"
fill="#3296FA"
/>
</svg>
钉钉登录
</button>
<div className="space-y-2">
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) ? (
<button
type="button"
onClick={handleDingTalkLogin}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg style={{ width: "20px", height: "20px" }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z"
fill="#3296FA"
/>
</svg>
钉钉登录
</button>
) : null}
{isGoogleEnabled && isGoogleConfigured ? (
<button
type="button"
onClick={handleGoogleLogin}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#EA4335" d="M12 10.2v3.9h5.5c-.2 1.2-1.4 3.5-5.5 3.5-3.3 0-6-2.7-6-6s2.7-6 6-6c1.9 0 3.2.8 3.9 1.5l2.7-2.6C16.9 2.9 14.6 2 12 2 6.5 2 2 6.5 2 12s4.5 10 10 10c5.8 0 9.6-4.1 9.6-9.8 0-.7-.1-1.2-.2-1.7H12z" />
</svg>
Google 登录
</button>
) : null}
</div>
{/* 多组织下的小型选择框(悬浮在按钮附近) */}
{dingTalkOrganizations.length > 1 && showOrgMenu && (

View File

@ -34,11 +34,6 @@ async function NewChatPage() {
getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined),
]);
// 如果用户未登录,重定向到登录页面
if (!session?.user) {
redirect("/login");
}
const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value)
? surpriseCategoriesSetting?.value
@ -46,15 +41,24 @@ async function NewChatPage() {
.filter(Boolean)
: ["全部", "惊喜"];
if (!session?.user && homeVariant !== "lovart") {
redirect("/login");
}
if (homeVariant === "lovart") {
return (
<LovartHome
user={{
id: session.user.id,
email: session.user.email,
name: session.user.name,
type: session.user.type,
}}
user={
session?.user
? {
id: session.user.id,
email: session.user.email,
name: session.user.name,
type: session.user.type,
}
: null
}
isAuthenticated={Boolean(session?.user)}
siteName={siteSettings.siteName || "AI Chatbot"}
siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""}
surpriseCategories={surpriseCategories}

View File

@ -12,6 +12,14 @@ export async function GET() {
const isAdmin = session?.user?.type === "admin";
const [settings] = await db.select().from(authSettings).limit(1);
const resolvedGoogleClientId =
settings?.googleClientId || process.env.GOOGLE_CLIENT_ID || "";
const resolvedGoogleClientSecret =
settings?.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET || "";
const resolvedAuthUrl = settings?.authUrl || process.env.AUTH_URL || "";
const isGoogleConfigured = Boolean(
resolvedGoogleClientId && resolvedGoogleClientSecret
);
// 如果没有配置记录,返回默认值
if (!settings) {
@ -20,6 +28,11 @@ export async function GET() {
dingTalkAppSecret: "", // 即使是空的,非管理员也不应该看到这个字段名,但因为是空字符串,问题不大
dingTalkOrganizations: [],
isDingTalkEnabled: false,
isGoogleEnabled: false,
googleClientId: "",
googleClientSecret: "",
authUrl: process.env.AUTH_URL || "",
isGoogleConfigured,
isRegistrationEnabled: true,
});
}
@ -27,6 +40,10 @@ export async function GET() {
if (isAdmin) {
return NextResponse.json({
...settings,
googleClientId: resolvedGoogleClientId,
googleClientSecret: resolvedGoogleClientSecret,
authUrl: resolvedAuthUrl,
isGoogleConfigured,
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
});
}
@ -40,6 +57,9 @@ export async function GET() {
return NextResponse.json({
dingTalkAppKey: settings.dingTalkAppKey,
isDingTalkEnabled: settings.isDingTalkEnabled,
isGoogleEnabled: settings.isGoogleEnabled,
authUrl: resolvedAuthUrl,
isGoogleConfigured,
isRegistrationEnabled: settings.isRegistrationEnabled,
organizations,
// 不返回 dingTalkAppSecret 以及各组织的密钥
@ -64,6 +84,10 @@ const updateSchema = z.object({
dingTalkAppSecret: z.string().optional(),
dingTalkOrganizations: z.array(orgSchema).optional(),
isDingTalkEnabled: z.boolean().optional(),
isGoogleEnabled: z.boolean().optional(),
googleClientId: z.string().optional(),
googleClientSecret: z.string().optional(),
authUrl: z.string().optional(),
isRegistrationEnabled: z.boolean().optional(),
});
@ -95,6 +119,11 @@ export async function PUT(request: NextRequest) {
data.dingTalkAppKey ?? primaryOrg?.appKey ?? existing?.dingTalkAppKey ?? null;
const resolvedAppSecret =
data.dingTalkAppSecret ?? primaryOrg?.appSecret ?? existing?.dingTalkAppSecret ?? null;
const resolvedGoogleClientId =
data.googleClientId ?? existing?.googleClientId ?? "";
const resolvedGoogleClientSecret =
data.googleClientSecret ?? existing?.googleClientSecret ?? "";
const resolvedAuthUrl = data.authUrl ?? existing?.authUrl ?? "";
if (existing) {
// 更新现有配置
@ -103,9 +132,13 @@ export async function PUT(request: NextRequest) {
.set({
dingTalkAppKey: resolvedAppKey,
dingTalkAppSecret: resolvedAppSecret,
googleClientId: resolvedGoogleClientId,
googleClientSecret: resolvedGoogleClientSecret,
authUrl: resolvedAuthUrl,
dingTalkOrganizations: organizations,
isDingTalkEnabled:
data.isDingTalkEnabled ?? existing.isDingTalkEnabled,
isGoogleEnabled: data.isGoogleEnabled ?? existing.isGoogleEnabled,
isRegistrationEnabled:
data.isRegistrationEnabled ?? existing.isRegistrationEnabled,
updatedAt: new Date(),
@ -123,8 +156,12 @@ export async function PUT(request: NextRequest) {
.values({
dingTalkAppKey: resolvedAppKey,
dingTalkAppSecret: resolvedAppSecret,
googleClientId: resolvedGoogleClientId,
googleClientSecret: resolvedGoogleClientSecret,
authUrl: resolvedAuthUrl,
dingTalkOrganizations: organizations,
isDingTalkEnabled: data.isDingTalkEnabled || false,
isGoogleEnabled: data.isGoogleEnabled || false,
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
updatedBy: session.user.id,
})

View File

@ -2,17 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generatedImage, user, aiModel } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
// GET: 获取精选图片列表
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");

View File

@ -2,6 +2,7 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import {
@ -25,6 +26,7 @@ import {
Star,
Upload,
User,
X,
Zap,
} from "lucide-react";
import { toast } from "sonner";
@ -32,6 +34,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
@ -87,6 +90,12 @@ type ModelOption = {
supportsImageGen?: boolean;
};
type PublicAuthSettings = {
isDingTalkEnabled?: boolean;
isGoogleEnabled?: boolean;
isGoogleConfigured?: boolean;
};
type MembershipSummaryResponse = {
config: MembershipConfig;
currentPlanId: string;
@ -112,7 +121,8 @@ type MembershipSummaryResponse = {
};
type LovartHomeProps = {
user: HomeUser;
user: HomeUser | null;
isAuthenticated: boolean;
siteName: string;
siteLogo?: string | null;
surpriseCategories: string[];
@ -171,6 +181,7 @@ function formatPrice(currency: string, value: number) {
export function LovartHome({
user,
isAuthenticated,
siteName,
siteLogo,
surpriseCategories,
@ -193,6 +204,10 @@ export function LovartHome({
imageSize: "2K",
imageCount: 1,
});
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [typedPreviewText, setTypedPreviewText] = useState("");
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
const projectSectionRef = useRef<HTMLDivElement | null>(null);
@ -202,7 +217,7 @@ export function LovartHome({
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
success: boolean;
data: CanvasProject[];
}>("/api/canvas", fetcher);
}>(isAuthenticated ? "/api/canvas" : null, fetcher);
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
useSWRInfinite<FeaturedResponse>(
@ -224,19 +239,24 @@ export function LovartHome({
);
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
isAuthenticated ? "/api/user/membership-summary" : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: ModelOption[];
}>("/api/models", fetcher);
}>(isAuthenticated ? "/api/models" : null, fetcher);
const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null;
lovartComposerPreviewTexts?: string[];
}>("/api/site-settings", fetcher);
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
"/api/auth-settings",
fetcher
);
const membershipConfig = membershipData?.config;
const membershipPlans = membershipConfig?.plans || [];
const currentPlan = membershipData?.currentPlan;
@ -337,6 +357,12 @@ export function LovartHome({
return (canvasData?.data || []).length > 4;
}, [canvasData?.data]);
const canUseGoogleLogin = Boolean(
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
);
const canUseDingTalkLogin = Boolean(authSettingsData?.isDingTalkEnabled);
const hasThirdPartyLoginOptions = canUseGoogleLogin || canUseDingTalkLogin;
useEffect(() => {
if (selectedModelId) return;
const defaultModelId = siteSettingsData?.canvasDefaultModelId || "auto";
@ -351,6 +377,18 @@ export function LovartHome({
setSelectedModelId("auto");
}, [allComposerModels, selectedModelId, modelsData]);
useEffect(() => {
if (!isAuthenticated) return;
setLoginDialogOpen(false);
}, [isAuthenticated]);
useEffect(() => {
if (!loginDialogOpen) {
setIsLoggingIn(false);
setLoginPassword("");
}
}, [loginDialogOpen]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
@ -508,7 +546,56 @@ export function LovartHome({
return result;
};
const openLoginDialog = () => {
setLoginDialogOpen(true);
};
const handleEmailLogin = async () => {
const email = loginEmail.trim();
if (!email || !loginPassword) {
toast.error("请输入邮箱和密码");
return;
}
setIsLoggingIn(true);
try {
const result = await signIn("credentials", {
email,
password: loginPassword,
redirect: false,
});
if (result?.error) {
toast.error("登录失败,请检查账号密码");
return;
}
window.location.href = "/";
} catch (_error) {
toast.error("登录失败,请稍后重试");
} finally {
setIsLoggingIn(false);
}
};
const handleGoogleLogin = async () => {
if (!canUseGoogleLogin) {
toast.error("Google 登录未启用");
return;
}
await signIn("google", { callbackUrl: "/" });
};
const handleDingTalkLogin = () => {
window.location.href = "/login";
};
const createCanvasFromComposer = async () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
if (!prompt.trim() && files.length === 0) return;
setIsSubmitting(true);
@ -562,10 +649,18 @@ export function LovartHome({
};
const openNewCanvas = () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
router.push("/canvas?new=1");
};
const openAllProjectsInPage = () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
setShowAllProjects(true);
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
@ -658,9 +753,19 @@ export function LovartHome({
</HoverCardContent>
</HoverCard>
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{(user.name || user.email || "mu").slice(0, 2).toLowerCase()}
</button>
{isAuthenticated ? (
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{(user?.name || user?.email || "mu").slice(0, 2).toLowerCase()}
</button>
) : (
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5"
onClick={openLoginDialog}
>
登录
</button>
)}
</div>
</div>
</header>
@ -705,7 +810,7 @@ export function LovartHome({
>
<Info className="h-[18px] w-[18px]" />
</button>
{user.type === "admin" ? (
{user?.type === "admin" ? (
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
@ -990,6 +1095,120 @@ export function LovartHome({
<Send className="h-4 w-4 -rotate-90" />
</button>
{loginDialogOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-[1px]"
onClick={() => setLoginDialogOpen(false)}
>
<div
className="w-full max-w-[580px] rounded-[28px] bg-white p-8 shadow-2xl sm:p-10"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-2">
{siteLogo ? (
<img
src={siteLogo}
alt={siteName}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
<Sparkles className="h-4 w-4" />
</div>
)}
<span className="text-[20px] font-semibold leading-none tracking-tight text-black">
{siteName}
</span>
</div>
<button
type="button"
className="rounded-full p-1 text-black/45 hover:bg-black/5 hover:text-black/70"
onClick={() => setLoginDialogOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="mb-7 text-center">
<h3 className="text-[22px] font-semibold text-black">欢迎来到 {siteName}</h3>
<p className="mt-2 text-sm text-black/45">登录或注册以继续</p>
</div>
{hasThirdPartyLoginOptions ? (
<div className="space-y-2.5">
{canUseGoogleLogin ? (
<button
type="button"
className="flex h-14 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[18px] font-medium text-black hover:bg-black/[0.03]"
onClick={handleGoogleLogin}
>
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#EA4335"
d="M12 10.2v3.9h5.5c-.2 1.2-1.4 3.5-5.5 3.5-3.3 0-6-2.7-6-6s2.7-6 6-6c1.9 0 3.2.8 3.9 1.5l2.7-2.6C16.9 2.9 14.6 2 12 2 6.5 2 2 6.5 2 12s4.5 10 10 10c5.8 0 9.6-4.1 9.6-9.8 0-.7-.1-1.2-.2-1.7H12z"
/>
</svg>
使用 Google
</button>
) : null}
{canUseDingTalkLogin ? (
<button
type="button"
className="flex h-14 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[18px] font-medium text-black hover:bg-black/[0.03]"
onClick={handleDingTalkLogin}
>
使用钉钉
</button>
) : null}
<div className="my-2 border-t border-black/10" />
</div>
) : null}
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
handleEmailLogin();
}}
>
<Input
value={loginEmail}
onChange={(event) => setLoginEmail(event.target.value)}
placeholder="电子邮箱"
type="email"
className="h-14 rounded-xl border-black/20 px-4 text-base"
/>
<Input
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
placeholder="密码"
type="password"
className="h-14 rounded-xl border-black/20 px-4 text-base"
/>
<Button
type="submit"
className="mt-1 h-14 w-full rounded-xl bg-black text-[17px] text-white hover:bg-black/90"
disabled={isLoggingIn}
>
{isLoggingIn ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
登录中...
</span>
) : (
"使用邮箱继续"
)}
</Button>
</form>
<p className="mt-5 text-center text-xs text-black/40">
继续即表示您同意使用条款和隐私政策
</p>
</div>
</div>
) : null}
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogContent className="max-h-[92vh] w-[95vw] max-w-[1240px] overflow-hidden rounded-3xl border-black/10 bg-[#f7f7f7] p-0">
<div className="flex h-full max-h-[92vh] flex-col">

View File

@ -0,0 +1,2 @@
ALTER TABLE "AuthSettings"
ADD COLUMN IF NOT EXISTS "isGoogleEnabled" boolean NOT NULL DEFAULT false;

View File

@ -0,0 +1,4 @@
ALTER TABLE "AuthSettings"
ADD COLUMN IF NOT EXISTS "googleClientId" text,
ADD COLUMN IF NOT EXISTS "googleClientSecret" text,
ADD COLUMN IF NOT EXISTS "authUrl" text;

View File

@ -113,6 +113,20 @@
"when": 1769251200000,
"tag": "0029_add_lovart_home_copy_settings",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1769255400000,
"tag": "0030_add_google_auth_toggle",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1769257800000,
"tag": "0031_add_google_oauth_settings",
"breakpoints": true
}
]
}

View File

@ -1605,16 +1605,24 @@ export async function getAuthSettings() {
return {
dingTalkAppKey: "",
dingTalkAppSecret: "",
googleClientId: "",
googleClientSecret: "",
authUrl: process.env.AUTH_URL || "",
dingTalkOrganizations: [],
isDingTalkEnabled: false,
isGoogleEnabled: false,
isRegistrationEnabled: true,
};
} catch (_error) {
return {
dingTalkAppKey: "",
dingTalkAppSecret: "",
googleClientId: "",
googleClientSecret: "",
authUrl: process.env.AUTH_URL || "",
dingTalkOrganizations: [],
isDingTalkEnabled: false,
isGoogleEnabled: false,
isRegistrationEnabled: true,
};
}

View File

@ -358,11 +358,15 @@ export const authSettings = pgTable("AuthSettings", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
dingTalkAppKey: text("dingTalkAppKey"),
dingTalkAppSecret: text("dingTalkAppSecret"),
googleClientId: text("googleClientId"),
googleClientSecret: text("googleClientSecret"),
authUrl: text("authUrl"),
// 多组织钉钉配置,存储为 JSON 数组:[{ name, appKey, appSecret }]
dingTalkOrganizations: jsonb("dingTalkOrganizations").$type<
{ name: string; appKey: string; appSecret: string }[] | null
>(),
isDingTalkEnabled: boolean("isDingTalkEnabled").notNull().default(false),
isGoogleEnabled: boolean("isGoogleEnabled").notNull().default(false),
isRegistrationEnabled: boolean("isRegistrationEnabled").notNull().default(true),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
updatedBy: uuid("updatedBy").references(() => user.id),

View File

@ -69,6 +69,16 @@ export async function proxy(request: NextRequest) {
return NextResponse.next();
}
// 允许访问公开的认证配置(登录弹窗需要)
if (pathname === "/api/auth-settings") {
return NextResponse.next();
}
// 允许匿名访问新版首页灵感数据
if (pathname === "/api/featured" || pathname.startsWith("/api/featured/")) {
return NextResponse.next();
}
// 允许访问钉钉回调页面(public 文件夹中的文件)
if (pathname === "/dingtalk-callback.html") {
return NextResponse.next();
@ -88,8 +98,8 @@ export async function proxy(request: NextRequest) {
process.env.AUTH_URL ??
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
// 未登录用户只能访问 /login 和 /register
if (!token && !["/login", "/register"].includes(pathname)) {
// 未登录用户允许访问 /(新版首页)、/login、/register,其余需要登录
if (!token && !["/", "/login", "/register"].includes(pathname)) {
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
if (pathname.startsWith("/api/") && request.method !== "GET") {
return NextResponse.json(