新版首页支持弹窗登录并接入Google后台配置
This commit is contained in:
parent
bc5838f9e1
commit
3f3523f1bb
@ -217,7 +217,11 @@ type AuthSettings = {
|
|||||||
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
|
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
|
||||||
dingTalkAppKey?: string | null;
|
dingTalkAppKey?: string | null;
|
||||||
dingTalkAppSecret?: string | null;
|
dingTalkAppSecret?: string | null;
|
||||||
|
googleClientId?: string | null;
|
||||||
|
googleClientSecret?: string | null;
|
||||||
|
authUrl?: string | null;
|
||||||
isDingTalkEnabled: boolean;
|
isDingTalkEnabled: boolean;
|
||||||
|
isGoogleEnabled: boolean;
|
||||||
isRegistrationEnabled?: boolean;
|
isRegistrationEnabled?: boolean;
|
||||||
dingTalkOrganizations: DingTalkOrganization[];
|
dingTalkOrganizations: DingTalkOrganization[];
|
||||||
};
|
};
|
||||||
@ -776,10 +780,15 @@ export function AdminSettingsClient() {
|
|||||||
const [authSettings, setAuthSettings] = useState<AuthSettings>({
|
const [authSettings, setAuthSettings] = useState<AuthSettings>({
|
||||||
dingTalkAppKey: "",
|
dingTalkAppKey: "",
|
||||||
dingTalkAppSecret: "",
|
dingTalkAppSecret: "",
|
||||||
|
googleClientId: "",
|
||||||
|
googleClientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
isDingTalkEnabled: false,
|
isDingTalkEnabled: false,
|
||||||
|
isGoogleEnabled: false,
|
||||||
isRegistrationEnabled: true,
|
isRegistrationEnabled: true,
|
||||||
dingTalkOrganizations: [],
|
dingTalkOrganizations: [],
|
||||||
});
|
});
|
||||||
|
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSettingsData) return;
|
if (!authSettingsData) return;
|
||||||
@ -804,10 +813,15 @@ export function AdminSettingsClient() {
|
|||||||
setAuthSettings({
|
setAuthSettings({
|
||||||
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
|
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
|
||||||
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
|
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
|
||||||
|
googleClientId: authSettingsData.googleClientId || "",
|
||||||
|
googleClientSecret: authSettingsData.googleClientSecret || "",
|
||||||
|
authUrl: authSettingsData.authUrl || "",
|
||||||
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
|
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
|
||||||
|
isGoogleEnabled: authSettingsData.isGoogleEnabled || false,
|
||||||
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
|
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
|
||||||
dingTalkOrganizations: normalizedOrgs,
|
dingTalkOrganizations: normalizedOrgs,
|
||||||
});
|
});
|
||||||
|
setIsGoogleConfigured(Boolean(authSettingsData.isGoogleConfigured));
|
||||||
}, [authSettingsData]);
|
}, [authSettingsData]);
|
||||||
|
|
||||||
// AI 提供商 & 模型状态
|
// AI 提供商 & 模型状态
|
||||||
@ -3863,6 +3877,69 @@ export function AdminSettingsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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}>
|
<Button onClick={handleSaveAuthSettings}>
|
||||||
<Save className="mr-2 h-4 w-4" /> 保存认证配置
|
<Save className="mr-2 h-4 w-4" /> 保存认证配置
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,49 +1,48 @@
|
|||||||
import type { NextAuthConfig } from "next-auth";
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
// 检测是否为 HTTPS 环境
|
export function buildAuthConfig(authUrl?: string | null): NextAuthConfig {
|
||||||
const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false;
|
const resolvedAuthUrl = authUrl || process.env.AUTH_URL || "";
|
||||||
const hostName = process.env.AUTH_URL ? new URL(process.env.AUTH_URL).hostname : undefined;
|
const isSecure = resolvedAuthUrl.startsWith("https://");
|
||||||
const cookieDomain = hostName && hostName !== "localhost" ? hostName : undefined;
|
const hostName = resolvedAuthUrl ? new URL(resolvedAuthUrl).hostname : undefined;
|
||||||
|
const cookieDomain = hostName && hostName !== "localhost" ? hostName : undefined;
|
||||||
|
|
||||||
export const authConfig = {
|
return {
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
newUser: "/",
|
newUser: "/",
|
||||||
},
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
sessionToken: {
|
sessionToken: {
|
||||||
name: isSecure ? "__Secure-authjs.session-token" : "authjs.session-token",
|
name: isSecure ? "__Secure-authjs.session-token" : "authjs.session-token",
|
||||||
options: {
|
options: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: isSecure,
|
secure: isSecure,
|
||||||
domain: cookieDomain,
|
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: {
|
providers: [],
|
||||||
name: isSecure ? "__Secure-authjs.callback-url" : "authjs.callback-url",
|
callbacks: {},
|
||||||
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;
|
|
||||||
|
|||||||
@ -2,11 +2,12 @@ import { compare } from "bcrypt-ts";
|
|||||||
import NextAuth, { type DefaultSession } from "next-auth";
|
import NextAuth, { type DefaultSession } from "next-auth";
|
||||||
import type { DefaultJWT } from "next-auth/jwt";
|
import type { DefaultJWT } from "next-auth/jwt";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import Google from "next-auth/providers/google";
|
||||||
import { DUMMY_PASSWORD } from "@/lib/constants";
|
import { DUMMY_PASSWORD } from "@/lib/constants";
|
||||||
import { createGuestUser, getUser } from "@/lib/db/queries";
|
import { createGuestUser, getAuthSettings, getUser } from "@/lib/db/queries";
|
||||||
import { authConfig } from "./auth.config";
|
import { buildAuthConfig } from "./auth.config";
|
||||||
import { db } from "@/lib/db";
|
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";
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
|
||||||
export type UserType = "guest" | "regular" | "admin";
|
export type UserType = "guest" | "regular" | "admin";
|
||||||
@ -25,7 +26,7 @@ declare module "next-auth" {
|
|||||||
id?: string;
|
id?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
type: UserType;
|
type?: UserType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,15 +38,28 @@ declare module "next-auth/jwt" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
async function getOrCreateGoogleUser(email: string, name?: string | null) {
|
||||||
handlers: { GET, POST },
|
const existingUsers = await getUser(email);
|
||||||
auth,
|
if (existingUsers.length > 0) {
|
||||||
signIn,
|
return existingUsers[0];
|
||||||
signOut,
|
}
|
||||||
} = NextAuth({
|
|
||||||
...authConfig,
|
const [createdUser] = await db
|
||||||
trustHost: true,
|
.insert(userTable)
|
||||||
providers: [
|
.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({
|
||||||
credentials: {},
|
credentials: {},
|
||||||
async authorize(credentials: any) {
|
async authorize(credentials: any) {
|
||||||
@ -141,25 +155,101 @@ export const {
|
|||||||
return { ...guestUser, type: "guest" };
|
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;
|
if (googleClientId && googleClientSecret) {
|
||||||
},
|
providers.push(
|
||||||
session({ session, token }) {
|
Google({
|
||||||
if (session.user) {
|
clientId: googleClientId,
|
||||||
session.user.id = token.id;
|
clientSecret: googleClientSecret,
|
||||||
session.user.type = token.type;
|
})
|
||||||
session.user.name = token.name as string;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export default function Page() {
|
|||||||
const [siteLogo, setSiteLogo] = useState<string | null>(null);
|
const [siteLogo, setSiteLogo] = useState<string | null>(null);
|
||||||
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
|
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
|
||||||
const [isDingTalkEnabled, setIsDingTalkEnabled] = useState(false);
|
const [isDingTalkEnabled, setIsDingTalkEnabled] = useState(false);
|
||||||
|
const [isGoogleEnabled, setIsGoogleEnabled] = useState(false);
|
||||||
|
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
|
||||||
const [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
|
const [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
|
||||||
const [dingTalkOrganizations, setDingTalkOrganizations] = useState<string[]>([]);
|
const [dingTalkOrganizations, setDingTalkOrganizations] = useState<string[]>([]);
|
||||||
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
|
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
|
||||||
@ -215,6 +217,8 @@ export default function Page() {
|
|||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.isDingTalkEnabled) setIsDingTalkEnabled(true);
|
if (data.isDingTalkEnabled) setIsDingTalkEnabled(true);
|
||||||
|
if (data.isGoogleEnabled) setIsGoogleEnabled(true);
|
||||||
|
if (data.isGoogleConfigured) setIsGoogleConfigured(true);
|
||||||
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
|
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
|
||||||
if (Array.isArray(data.organizations)) {
|
if (Array.isArray(data.organizations)) {
|
||||||
setDingTalkOrganizations(
|
setDingTalkOrganizations(
|
||||||
@ -230,6 +234,20 @@ export default function Page() {
|
|||||||
.catch(console.error);
|
.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(页面加载时自动登录)
|
// 检查 URL 中是否有钉钉 Token(页面加载时自动登录)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@ -413,6 +431,9 @@ export default function Page() {
|
|||||||
const displayLogo = mounted
|
const displayLogo = mounted
|
||||||
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
||||||
: siteLogo;
|
: siteLogo;
|
||||||
|
const hasThirdPartyLogin =
|
||||||
|
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||||||
|
(isGoogleEnabled && isGoogleConfigured);
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<AuthForm action={handleSubmit} defaultEmail={email} className="pointer-events-auto">
|
||||||
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
|
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
|
||||||
|
|
||||||
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) && (
|
{hasThirdPartyLogin && (
|
||||||
<div className="mt-4 w-full relative">
|
<div className="mt-4 w-full relative">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
@ -460,19 +481,36 @@ export default function Page() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="space-y-2">
|
||||||
type="button"
|
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) ? (
|
||||||
onClick={handleDingTalkLogin}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={handleDingTalkLogin}
|
||||||
<svg style={{ width: "20px", height: "20px" }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
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"
|
||||||
<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"
|
<svg style={{ width: "20px", height: "20px" }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
fill="#3296FA"
|
<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"
|
||||||
</svg>
|
fill="#3296FA"
|
||||||
钉钉登录
|
/>
|
||||||
</button>
|
</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 && (
|
{dingTalkOrganizations.length > 1 && showOrgMenu && (
|
||||||
|
|||||||
@ -34,11 +34,6 @@ async function NewChatPage() {
|
|||||||
getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined),
|
getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 如果用户未登录,重定向到登录页面
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
|
const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
|
||||||
const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value)
|
const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value)
|
||||||
? surpriseCategoriesSetting?.value
|
? surpriseCategoriesSetting?.value
|
||||||
@ -46,15 +41,24 @@ async function NewChatPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: ["全部", "惊喜"];
|
: ["全部", "惊喜"];
|
||||||
|
|
||||||
|
if (!session?.user && homeVariant !== "lovart") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
if (homeVariant === "lovart") {
|
if (homeVariant === "lovart") {
|
||||||
return (
|
return (
|
||||||
<LovartHome
|
<LovartHome
|
||||||
user={{
|
user={
|
||||||
id: session.user.id,
|
session?.user
|
||||||
email: session.user.email,
|
? {
|
||||||
name: session.user.name,
|
id: session.user.id,
|
||||||
type: session.user.type,
|
email: session.user.email,
|
||||||
}}
|
name: session.user.name,
|
||||||
|
type: session.user.type,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isAuthenticated={Boolean(session?.user)}
|
||||||
siteName={siteSettings.siteName || "AI Chatbot"}
|
siteName={siteSettings.siteName || "AI Chatbot"}
|
||||||
siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""}
|
siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""}
|
||||||
surpriseCategories={surpriseCategories}
|
surpriseCategories={surpriseCategories}
|
||||||
|
|||||||
@ -12,6 +12,14 @@ export async function GET() {
|
|||||||
const isAdmin = session?.user?.type === "admin";
|
const isAdmin = session?.user?.type === "admin";
|
||||||
|
|
||||||
const [settings] = await db.select().from(authSettings).limit(1);
|
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) {
|
if (!settings) {
|
||||||
@ -20,6 +28,11 @@ export async function GET() {
|
|||||||
dingTalkAppSecret: "", // 即使是空的,非管理员也不应该看到这个字段名,但因为是空字符串,问题不大
|
dingTalkAppSecret: "", // 即使是空的,非管理员也不应该看到这个字段名,但因为是空字符串,问题不大
|
||||||
dingTalkOrganizations: [],
|
dingTalkOrganizations: [],
|
||||||
isDingTalkEnabled: false,
|
isDingTalkEnabled: false,
|
||||||
|
isGoogleEnabled: false,
|
||||||
|
googleClientId: "",
|
||||||
|
googleClientSecret: "",
|
||||||
|
authUrl: process.env.AUTH_URL || "",
|
||||||
|
isGoogleConfigured,
|
||||||
isRegistrationEnabled: true,
|
isRegistrationEnabled: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -27,6 +40,10 @@ export async function GET() {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...settings,
|
...settings,
|
||||||
|
googleClientId: resolvedGoogleClientId,
|
||||||
|
googleClientSecret: resolvedGoogleClientSecret,
|
||||||
|
authUrl: resolvedAuthUrl,
|
||||||
|
isGoogleConfigured,
|
||||||
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
|
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -40,6 +57,9 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
dingTalkAppKey: settings.dingTalkAppKey,
|
dingTalkAppKey: settings.dingTalkAppKey,
|
||||||
isDingTalkEnabled: settings.isDingTalkEnabled,
|
isDingTalkEnabled: settings.isDingTalkEnabled,
|
||||||
|
isGoogleEnabled: settings.isGoogleEnabled,
|
||||||
|
authUrl: resolvedAuthUrl,
|
||||||
|
isGoogleConfigured,
|
||||||
isRegistrationEnabled: settings.isRegistrationEnabled,
|
isRegistrationEnabled: settings.isRegistrationEnabled,
|
||||||
organizations,
|
organizations,
|
||||||
// 不返回 dingTalkAppSecret 以及各组织的密钥
|
// 不返回 dingTalkAppSecret 以及各组织的密钥
|
||||||
@ -64,6 +84,10 @@ const updateSchema = z.object({
|
|||||||
dingTalkAppSecret: z.string().optional(),
|
dingTalkAppSecret: z.string().optional(),
|
||||||
dingTalkOrganizations: z.array(orgSchema).optional(),
|
dingTalkOrganizations: z.array(orgSchema).optional(),
|
||||||
isDingTalkEnabled: z.boolean().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(),
|
isRegistrationEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,6 +119,11 @@ export async function PUT(request: NextRequest) {
|
|||||||
data.dingTalkAppKey ?? primaryOrg?.appKey ?? existing?.dingTalkAppKey ?? null;
|
data.dingTalkAppKey ?? primaryOrg?.appKey ?? existing?.dingTalkAppKey ?? null;
|
||||||
const resolvedAppSecret =
|
const resolvedAppSecret =
|
||||||
data.dingTalkAppSecret ?? primaryOrg?.appSecret ?? existing?.dingTalkAppSecret ?? null;
|
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) {
|
if (existing) {
|
||||||
// 更新现有配置
|
// 更新现有配置
|
||||||
@ -103,9 +132,13 @@ export async function PUT(request: NextRequest) {
|
|||||||
.set({
|
.set({
|
||||||
dingTalkAppKey: resolvedAppKey,
|
dingTalkAppKey: resolvedAppKey,
|
||||||
dingTalkAppSecret: resolvedAppSecret,
|
dingTalkAppSecret: resolvedAppSecret,
|
||||||
|
googleClientId: resolvedGoogleClientId,
|
||||||
|
googleClientSecret: resolvedGoogleClientSecret,
|
||||||
|
authUrl: resolvedAuthUrl,
|
||||||
dingTalkOrganizations: organizations,
|
dingTalkOrganizations: organizations,
|
||||||
isDingTalkEnabled:
|
isDingTalkEnabled:
|
||||||
data.isDingTalkEnabled ?? existing.isDingTalkEnabled,
|
data.isDingTalkEnabled ?? existing.isDingTalkEnabled,
|
||||||
|
isGoogleEnabled: data.isGoogleEnabled ?? existing.isGoogleEnabled,
|
||||||
isRegistrationEnabled:
|
isRegistrationEnabled:
|
||||||
data.isRegistrationEnabled ?? existing.isRegistrationEnabled,
|
data.isRegistrationEnabled ?? existing.isRegistrationEnabled,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@ -123,8 +156,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
.values({
|
.values({
|
||||||
dingTalkAppKey: resolvedAppKey,
|
dingTalkAppKey: resolvedAppKey,
|
||||||
dingTalkAppSecret: resolvedAppSecret,
|
dingTalkAppSecret: resolvedAppSecret,
|
||||||
|
googleClientId: resolvedGoogleClientId,
|
||||||
|
googleClientSecret: resolvedGoogleClientSecret,
|
||||||
|
authUrl: resolvedAuthUrl,
|
||||||
dingTalkOrganizations: organizations,
|
dingTalkOrganizations: organizations,
|
||||||
isDingTalkEnabled: data.isDingTalkEnabled || false,
|
isDingTalkEnabled: data.isDingTalkEnabled || false,
|
||||||
|
isGoogleEnabled: data.isGoogleEnabled || false,
|
||||||
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
|
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
|
||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,17 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
|
|
||||||
// GET: 获取精选图片列表
|
// GET: 获取精选图片列表
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
import {
|
import {
|
||||||
@ -25,6 +26,7 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -32,6 +34,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
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 { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { fetcher } from "@/lib/utils";
|
import { fetcher } from "@/lib/utils";
|
||||||
@ -87,6 +90,12 @@ type ModelOption = {
|
|||||||
supportsImageGen?: boolean;
|
supportsImageGen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PublicAuthSettings = {
|
||||||
|
isDingTalkEnabled?: boolean;
|
||||||
|
isGoogleEnabled?: boolean;
|
||||||
|
isGoogleConfigured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type MembershipSummaryResponse = {
|
type MembershipSummaryResponse = {
|
||||||
config: MembershipConfig;
|
config: MembershipConfig;
|
||||||
currentPlanId: string;
|
currentPlanId: string;
|
||||||
@ -112,7 +121,8 @@ type MembershipSummaryResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LovartHomeProps = {
|
type LovartHomeProps = {
|
||||||
user: HomeUser;
|
user: HomeUser | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteLogo?: string | null;
|
siteLogo?: string | null;
|
||||||
surpriseCategories: string[];
|
surpriseCategories: string[];
|
||||||
@ -171,6 +181,7 @@ function formatPrice(currency: string, value: number) {
|
|||||||
|
|
||||||
export function LovartHome({
|
export function LovartHome({
|
||||||
user,
|
user,
|
||||||
|
isAuthenticated,
|
||||||
siteName,
|
siteName,
|
||||||
siteLogo,
|
siteLogo,
|
||||||
surpriseCategories,
|
surpriseCategories,
|
||||||
@ -193,6 +204,10 @@ export function LovartHome({
|
|||||||
imageSize: "2K",
|
imageSize: "2K",
|
||||||
imageCount: 1,
|
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 [typedPreviewText, setTypedPreviewText] = useState("");
|
||||||
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
|
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
|
||||||
const projectSectionRef = useRef<HTMLDivElement | null>(null);
|
const projectSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -202,7 +217,7 @@ export function LovartHome({
|
|||||||
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
|
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: CanvasProject[];
|
data: CanvasProject[];
|
||||||
}>("/api/canvas", fetcher);
|
}>(isAuthenticated ? "/api/canvas" : null, fetcher);
|
||||||
|
|
||||||
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
|
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
|
||||||
useSWRInfinite<FeaturedResponse>(
|
useSWRInfinite<FeaturedResponse>(
|
||||||
@ -224,19 +239,24 @@ export function LovartHome({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
|
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
|
||||||
"/api/user/membership-summary",
|
isAuthenticated ? "/api/user/membership-summary" : null,
|
||||||
fetcher
|
fetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: modelsData } = useSWR<{
|
const { data: modelsData } = useSWR<{
|
||||||
models: ModelOption[];
|
models: ModelOption[];
|
||||||
}>("/api/models", fetcher);
|
}>(isAuthenticated ? "/api/models" : null, fetcher);
|
||||||
|
|
||||||
const { data: siteSettingsData } = useSWR<{
|
const { data: siteSettingsData } = useSWR<{
|
||||||
canvasDefaultModelId?: string | null;
|
canvasDefaultModelId?: string | null;
|
||||||
lovartComposerPreviewTexts?: string[];
|
lovartComposerPreviewTexts?: string[];
|
||||||
}>("/api/site-settings", fetcher);
|
}>("/api/site-settings", fetcher);
|
||||||
|
|
||||||
|
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
|
||||||
|
"/api/auth-settings",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
const membershipConfig = membershipData?.config;
|
const membershipConfig = membershipData?.config;
|
||||||
const membershipPlans = membershipConfig?.plans || [];
|
const membershipPlans = membershipConfig?.plans || [];
|
||||||
const currentPlan = membershipData?.currentPlan;
|
const currentPlan = membershipData?.currentPlan;
|
||||||
@ -337,6 +357,12 @@ export function LovartHome({
|
|||||||
return (canvasData?.data || []).length > 4;
|
return (canvasData?.data || []).length > 4;
|
||||||
}, [canvasData?.data]);
|
}, [canvasData?.data]);
|
||||||
|
|
||||||
|
const canUseGoogleLogin = Boolean(
|
||||||
|
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
|
||||||
|
);
|
||||||
|
const canUseDingTalkLogin = Boolean(authSettingsData?.isDingTalkEnabled);
|
||||||
|
const hasThirdPartyLoginOptions = canUseGoogleLogin || canUseDingTalkLogin;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedModelId) return;
|
if (selectedModelId) return;
|
||||||
const defaultModelId = siteSettingsData?.canvasDefaultModelId || "auto";
|
const defaultModelId = siteSettingsData?.canvasDefaultModelId || "auto";
|
||||||
@ -351,6 +377,18 @@ export function LovartHome({
|
|||||||
setSelectedModelId("auto");
|
setSelectedModelId("auto");
|
||||||
}, [allComposerModels, selectedModelId, modelsData]);
|
}, [allComposerModels, selectedModelId, modelsData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
setLoginDialogOpen(false);
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loginDialogOpen) {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setLoginPassword("");
|
||||||
|
}
|
||||||
|
}, [loginDialogOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
@ -508,7 +546,56 @@ export function LovartHome({
|
|||||||
return result;
|
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 () => {
|
const createCanvasFromComposer = async () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
openLoginDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!prompt.trim() && files.length === 0) return;
|
if (!prompt.trim() && files.length === 0) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
@ -562,10 +649,18 @@ export function LovartHome({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openNewCanvas = () => {
|
const openNewCanvas = () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
openLoginDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push("/canvas?new=1");
|
router.push("/canvas?new=1");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAllProjectsInPage = () => {
|
const openAllProjectsInPage = () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
openLoginDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowAllProjects(true);
|
setShowAllProjects(true);
|
||||||
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
};
|
};
|
||||||
@ -658,9 +753,19 @@ export function LovartHome({
|
|||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|
||||||
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
{isAuthenticated ? (
|
||||||
{(user.name || user.email || "mu").slice(0, 2).toLowerCase()}
|
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||||||
</button>
|
{(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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -705,7 +810,7 @@ export function LovartHome({
|
|||||||
>
|
>
|
||||||
<Info className="h-[18px] w-[18px]" />
|
<Info className="h-[18px] w-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
{user.type === "admin" ? (
|
{user?.type === "admin" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
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" />
|
<Send className="h-4 w-4 -rotate-90" />
|
||||||
</button>
|
</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}>
|
<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">
|
<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">
|
<div className="flex h-full max-h-[92vh] flex-col">
|
||||||
|
|||||||
2
lib/db/migrations/0030_add_google_auth_toggle.sql
Normal file
2
lib/db/migrations/0030_add_google_auth_toggle.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "AuthSettings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "isGoogleEnabled" boolean NOT NULL DEFAULT false;
|
||||||
4
lib/db/migrations/0031_add_google_oauth_settings.sql
Normal file
4
lib/db/migrations/0031_add_google_oauth_settings.sql
Normal 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;
|
||||||
@ -113,6 +113,20 @@
|
|||||||
"when": 1769251200000,
|
"when": 1769251200000,
|
||||||
"tag": "0029_add_lovart_home_copy_settings",
|
"tag": "0029_add_lovart_home_copy_settings",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1605,16 +1605,24 @@ export async function getAuthSettings() {
|
|||||||
return {
|
return {
|
||||||
dingTalkAppKey: "",
|
dingTalkAppKey: "",
|
||||||
dingTalkAppSecret: "",
|
dingTalkAppSecret: "",
|
||||||
|
googleClientId: "",
|
||||||
|
googleClientSecret: "",
|
||||||
|
authUrl: process.env.AUTH_URL || "",
|
||||||
dingTalkOrganizations: [],
|
dingTalkOrganizations: [],
|
||||||
isDingTalkEnabled: false,
|
isDingTalkEnabled: false,
|
||||||
|
isGoogleEnabled: false,
|
||||||
isRegistrationEnabled: true,
|
isRegistrationEnabled: true,
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return {
|
return {
|
||||||
dingTalkAppKey: "",
|
dingTalkAppKey: "",
|
||||||
dingTalkAppSecret: "",
|
dingTalkAppSecret: "",
|
||||||
|
googleClientId: "",
|
||||||
|
googleClientSecret: "",
|
||||||
|
authUrl: process.env.AUTH_URL || "",
|
||||||
dingTalkOrganizations: [],
|
dingTalkOrganizations: [],
|
||||||
isDingTalkEnabled: false,
|
isDingTalkEnabled: false,
|
||||||
|
isGoogleEnabled: false,
|
||||||
isRegistrationEnabled: true,
|
isRegistrationEnabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -358,11 +358,15 @@ export const authSettings = pgTable("AuthSettings", {
|
|||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
dingTalkAppKey: text("dingTalkAppKey"),
|
dingTalkAppKey: text("dingTalkAppKey"),
|
||||||
dingTalkAppSecret: text("dingTalkAppSecret"),
|
dingTalkAppSecret: text("dingTalkAppSecret"),
|
||||||
|
googleClientId: text("googleClientId"),
|
||||||
|
googleClientSecret: text("googleClientSecret"),
|
||||||
|
authUrl: text("authUrl"),
|
||||||
// 多组织钉钉配置,存储为 JSON 数组:[{ name, appKey, appSecret }]
|
// 多组织钉钉配置,存储为 JSON 数组:[{ name, appKey, appSecret }]
|
||||||
dingTalkOrganizations: jsonb("dingTalkOrganizations").$type<
|
dingTalkOrganizations: jsonb("dingTalkOrganizations").$type<
|
||||||
{ name: string; appKey: string; appSecret: string }[] | null
|
{ name: string; appKey: string; appSecret: string }[] | null
|
||||||
>(),
|
>(),
|
||||||
isDingTalkEnabled: boolean("isDingTalkEnabled").notNull().default(false),
|
isDingTalkEnabled: boolean("isDingTalkEnabled").notNull().default(false),
|
||||||
|
isGoogleEnabled: boolean("isGoogleEnabled").notNull().default(false),
|
||||||
isRegistrationEnabled: boolean("isRegistrationEnabled").notNull().default(true),
|
isRegistrationEnabled: boolean("isRegistrationEnabled").notNull().default(true),
|
||||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
updatedBy: uuid("updatedBy").references(() => user.id),
|
updatedBy: uuid("updatedBy").references(() => user.id),
|
||||||
|
|||||||
14
proxy.ts
14
proxy.ts
@ -69,6 +69,16 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 允许访问公开的认证配置(登录弹窗需要)
|
||||||
|
if (pathname === "/api/auth-settings") {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 允许匿名访问新版首页灵感数据
|
||||||
|
if (pathname === "/api/featured" || pathname.startsWith("/api/featured/")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
// 允许访问钉钉回调页面(public 文件夹中的文件)
|
// 允许访问钉钉回调页面(public 文件夹中的文件)
|
||||||
if (pathname === "/dingtalk-callback.html") {
|
if (pathname === "/dingtalk-callback.html") {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
@ -88,8 +98,8 @@ export async function proxy(request: NextRequest) {
|
|||||||
process.env.AUTH_URL ??
|
process.env.AUTH_URL ??
|
||||||
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
|
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
|
||||||
|
|
||||||
// 未登录用户只能访问 /login 和 /register
|
// 未登录用户允许访问 /(新版首页)、/login、/register,其余需要登录
|
||||||
if (!token && !["/login", "/register"].includes(pathname)) {
|
if (!token && !["/", "/login", "/register"].includes(pathname)) {
|
||||||
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
|
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
|
||||||
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user