钉钉登录支持配置多组织

This commit is contained in:
Mosijiu 2025-12-18 22:12:03 +08:00
parent 12a383cceb
commit 9285fc1822
8 changed files with 485 additions and 116 deletions

View File

@ -144,11 +144,20 @@ type NavLink = {
isEnabled: boolean;
};
type DingTalkOrganization = {
id: string;
name: string;
appKey: string;
appSecret: string;
};
type AuthSettings = {
dingTalkAppKey: string | null;
dingTalkAppSecret: string | null;
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
dingTalkAppKey?: string | null;
dingTalkAppSecret?: string | null;
isDingTalkEnabled: boolean;
isRegistrationEnabled?: boolean;
dingTalkOrganizations: DingTalkOrganization[];
};
type GeneratedImageRecord = {
@ -266,7 +275,10 @@ export function AdminSettingsClient() {
const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[] }>("/api/admin/storage", fetcher);
const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<SiteSettings>("/api/site-settings", fetcher);
const { data: navLinksData, mutate: mutateNavLinks } = useSWR<{ links: NavLink[] }>("/api/nav-links", fetcher);
const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR<AuthSettings>("/api/auth-settings", fetcher);
const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR<any>(
"/api/auth-settings",
fetcher,
);
// 🆕 生图记录数据
const imageQueryParams = new URLSearchParams({
@ -406,17 +418,36 @@ export function AdminSettingsClient() {
dingTalkAppSecret: "",
isDingTalkEnabled: false,
isRegistrationEnabled: true,
dingTalkOrganizations: [],
});
useEffect(() => {
if (authSettingsData) {
setAuthSettings({
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
});
}
if (!authSettingsData) return;
const organizations: DingTalkOrganization[] =
(authSettingsData.dingTalkOrganizations as DingTalkOrganization[] | null) ?? [];
// 如果没有多组织配置,但有单一 AppKey/Secret,则自动构造一个默认组织
const normalizedOrgs =
organizations.length > 0 ||
!authSettingsData.dingTalkAppKey
? organizations
: [
{
id: "default",
name: "默认组织",
appKey: authSettingsData.dingTalkAppKey ?? "",
appSecret: authSettingsData.dingTalkAppSecret ?? "",
},
];
setAuthSettings({
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
dingTalkOrganizations: normalizedOrgs,
});
}, [authSettingsData]);
// AI 提供商 & 模型状态
@ -2066,25 +2097,125 @@ export function AdminSettingsClient() {
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">钉钉登录</h3>
<div className="space-y-2">
<Label htmlFor="dingTalkAppKey">AppKey</Label>
<Input
id="dingTalkAppKey"
value={authSettings.dingTalkAppKey || ""}
onChange={(e) => setAuthSettings({ ...authSettings, dingTalkAppKey: e.target.value })}
type="password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dingTalkAppSecret">AppSecret</Label>
<Input
id="dingTalkAppSecret"
value={authSettings.dingTalkAppSecret || ""}
onChange={(e) => setAuthSettings({ ...authSettings, dingTalkAppSecret: e.target.value })}
type="password"
/>
<p className="text-sm text-muted-foreground">
支持配置多个钉钉组织,每个组织包含名称、AppKey、AppSecret。默认使用第一个组织作为登录入口。
</p>
<div className="space-y-3">
{authSettings.dingTalkOrganizations.map((org, index) => (
<div
key={org.id}
className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-3"
>
<div className="space-y-1">
<Label htmlFor={`ding-org-name-${index}`}>组织名称</Label>
<Input
id={`ding-org-name-${index}`}
value={org.name}
placeholder={`例如:总部 / 研发中心 ${index + 1}`}
onChange={(e) => {
const value = e.target.value;
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.map(
(item, i) =>
i === index ? { ...item, name: value } : item,
),
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`ding-org-appkey-${index}`}>AppKey</Label>
<Input
id={`ding-org-appkey-${index}`}
type="password"
value={org.appKey}
onChange={(e) => {
const value = e.target.value;
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.map(
(item, i) =>
i === index ? { ...item, appKey: value } : item,
),
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`ding-org-appsecret-${index}`}>
AppSecret
</Label>
<div className="flex items-center gap-2">
<Input
id={`ding-org-appsecret-${index}`}
type="password"
value={org.appSecret}
onChange={(e) => {
const value = e.target.value;
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.map(
(item, i) =>
i === index
? { ...item, appSecret: value }
: item,
),
});
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.filter(
(_, i) => i !== index,
),
});
}}
disabled={authSettings.dingTalkOrganizations.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() => {
setAuthSettings({
...authSettings,
dingTalkOrganizations: [
...authSettings.dingTalkOrganizations,
{
id: crypto.randomUUID(),
name: `新组织 ${authSettings.dingTalkOrganizations.length + 1}`,
appKey: "",
appSecret: "",
},
],
});
}}
>
<Plus className="mr-2 h-4 w-4" />
新增组织
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 pt-2">
<Switch
id="dingTalkEnabled"
checked={authSettings.isDingTalkEnabled}

View File

@ -29,9 +29,11 @@ export default function Page() {
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
const [isDingTalkEnabled, setIsDingTalkEnabled] = useState(false);
const [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
const [dingTalkOrganizations, setDingTalkOrganizations] = useState<string[]>([]);
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
const [showDingTalkModal, setShowDingTalkModal] = useState(false);
const [dingTalkIframeSrc, setDingTalkIframeSrc] = useState("");
const [showOrgMenu, setShowOrgMenu] = useState(false);
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [isHandlingToken, setIsHandlingToken] = useState(false);
@ -204,7 +206,16 @@ export default function Page() {
.then((data) => {
if (data.isDingTalkEnabled) setIsDingTalkEnabled(true);
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
if (data.isRegistrationEnabled !== undefined) setIsRegistrationEnabled(data.isRegistrationEnabled);
if (Array.isArray(data.organizations)) {
setDingTalkOrganizations(
data.organizations
.map((org: { name?: string }) => org.name)
.filter((name: string | undefined): name is string => !!name),
);
}
if (data.isRegistrationEnabled !== undefined) {
setIsRegistrationEnabled(data.isRegistrationEnabled);
}
})
.catch(console.error);
}, []);
@ -269,37 +280,88 @@ export default function Page() {
formAction(formData);
};
// 钉钉登录 - 严格按照钉钉配置文档实现
// 根据组织名称启动钉钉登录(在 iframe 中打开)
const startDingTalkLoginForOrg = (orgName: string) => {
// 清除 URL 中的 token 参数,避免干扰
if (
typeof window !== "undefined" &&
window.location.search.includes("dingtalk_token")
) {
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
orgName,
)}`;
console.log("[DingTalk] Opening org login:", orgName, loginUrl);
setDingTalkIframeSrc(loginUrl);
setShowDingTalkModal(true);
setShowOrgMenu(false);
};
// 钉钉登录 - 支持多组织
const handleDingTalkLogin = () => {
if (!dingTalkAppKey) {
toast({ type: "error", description: "钉钉登录未正确配置" });
if (!isDingTalkEnabled) {
toast({ type: "error", description: "钉钉登录未启用" });
return;
}
// 清除 URL 中的 token 参数,避免干扰
if (typeof window !== 'undefined' && window.location.search.includes('dingtalk_token')) {
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
// 没有配置多组织:回退到单一 appKey 流程
if (dingTalkOrganizations.length === 0) {
if (!dingTalkAppKey) {
toast({ type: "error", description: "钉钉登录未正确配置" });
return;
}
// 兼容:直接使用旧的方式在 iframe 中打开
if (
typeof window === "undefined" ||
typeof window.location === "undefined"
) {
toast({ type: "error", description: "当前环境不支持钉钉登录" });
return;
}
// 清除 URL 中的 token 参数,避免干扰
if (window.location.search.includes("dingtalk_token")) {
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
const param = {
redirect_uri: encodeURIComponent(
window.location.origin + "/dingtalk-callback.html",
),
client_id: dingTalkAppKey,
scope: "openid",
response_type: "code",
state: "default",
prompt: "consent",
};
const redirectUrl =
"https://login.dingtalk.com/oauth2/auth?" +
Object.keys(param)
.map((key) => key + "=" + (param as any)[key])
.join("&");
console.log("[DingTalk] Opening OAuth URL (legacy):", redirectUrl);
setDingTalkIframeSrc(redirectUrl);
setShowDingTalkModal(true);
return;
}
// 构建钉钉 OAuth2 授权 URL
const param = {
redirect_uri: encodeURIComponent(window.location.origin + "/dingtalk-callback.html"),
client_id: dingTalkAppKey,
scope: "openid",
response_type: "code",
state: "default",
prompt: "consent"
};
const redirectUrl = "https://login.dingtalk.com/oauth2/auth?" +
Object.keys(param).map(key => key + "=" + param[key as keyof typeof param]).join("&");
console.log("[DingTalk] Opening OAuth URL:", redirectUrl);
// 在 iframe 中打开钉钉登录页面
setDingTalkIframeSrc(redirectUrl);
setShowDingTalkModal(true);
// 只有一个组织:直接使用该组织登录
if (dingTalkOrganizations.length === 1) {
startDingTalkLoginForOrg(dingTalkOrganizations[0] as string);
return;
}
// 多组织:点击时展开/收起选择菜单(hover 时也会展开)
setShowOrgMenu((prev) => !prev);
};
// 关闭钉钉登录弹窗
@ -347,8 +409,8 @@ export default function Page() {
<AuthForm action={handleSubmit} defaultEmail={email} className="pointer-events-auto">
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
{isDingTalkEnabled && dingTalkAppKey && (
<div className="mt-4 w-full">
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) && (
<div className="mt-4 w-full relative">
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
@ -372,6 +434,22 @@ export default function Page() {
</svg>
钉钉登录
</button>
{/* 多组织下的小型选择框(悬浮在按钮附近) */}
{dingTalkOrganizations.length > 1 && showOrgMenu && (
<div className="absolute left-0 right-0 -top-2 -translate-y-full rounded-md border bg-background shadow-lg z-20 max-h-60 overflow-auto text-sm">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
onClick={() => startDingTalkLoginForOrg(org)}
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted"
>
<span>{org}</span>
</button>
))}
</div>
)}
</div>
)}
@ -390,9 +468,9 @@ export default function Page() {
</AuthForm>
</div>
{/* 钉钉登录 iframe 弹窗 */}
{/* 钉钉登录 iframe 弹窗 / 多组织选择 */}
{showDingTalkModal && (
<div
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) closeDingTalkModal();
@ -400,20 +478,61 @@ export default function Page() {
>
<div className="w-[90%] max-w-[500px] h-[720px] bg-white dark:bg-zinc-900 rounded-xl overflow-hidden flex flex-col shadow-2xl">
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-zinc-800 border-b dark:border-zinc-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">钉钉登录</h3>
<button
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
钉钉登录
</h3>
<button
onClick={closeDingTalkModal}
className="p-2 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
<iframe
ref={iframeRef}
src={dingTalkIframeSrc}
className="flex-1 w-full border-0"
title="钉钉登录"
/>
{/* 多组织选择 */}
{dingTalkOrganizations.length > 1 && !dingTalkIframeSrc && (
<div className="p-4 space-y-3">
<p className="text-sm text-muted-foreground">
请选择要使用的钉钉组织进行登录:
</p>
<div className="space-y-2">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
onClick={() => {
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
org,
)}`;
console.log(
"[DingTalk] Selected organization:",
org,
loginUrl,
);
setDingTalkIframeSrc(loginUrl);
}}
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span>{org}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
选择后将在下方加载钉钉登录页面。
</p>
</div>
)}
{/* 登录 iframe */}
{dingTalkIframeSrc && (
<iframe
ref={iframeRef}
src={dingTalkIframeSrc}
className="flex-1 w-full border-0"
title="钉钉登录"
/>
)}
<div className="p-4 bg-gray-50 dark:bg-zinc-800 border-t dark:border-zinc-700 flex justify-center">
<button
onClick={closeDingTalkModal}

View File

@ -18,22 +18,32 @@ export async function GET() {
return NextResponse.json({
dingTalkAppKey: "",
dingTalkAppSecret: "", // 即使是空的,非管理员也不应该看到这个字段名,但因为是空字符串,问题不大
dingTalkOrganizations: [],
isDingTalkEnabled: false,
isRegistrationEnabled: true,
});
}
if (isAdmin) {
return NextResponse.json(settings);
} else {
// 非管理员,脱敏返回
return NextResponse.json({
dingTalkAppKey: settings.dingTalkAppKey,
isDingTalkEnabled: settings.isDingTalkEnabled,
isRegistrationEnabled: settings.isRegistrationEnabled,
// 不返回 dingTalkAppSecret
...settings,
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
});
}
// 非管理员,脱敏返回,只暴露组织名称
const organizations =
settings.dingTalkOrganizations?.map((org) => ({
name: org.name,
})) ?? [];
return NextResponse.json({
dingTalkAppKey: settings.dingTalkAppKey,
isDingTalkEnabled: settings.isDingTalkEnabled,
isRegistrationEnabled: settings.isRegistrationEnabled,
organizations,
// 不返回 dingTalkAppSecret 以及各组织的密钥
});
} catch (error) {
console.error("[Auth Settings API] Get error:", error);
return NextResponse.json(
@ -43,9 +53,16 @@ export async function GET() {
}
}
const orgSchema = z.object({
name: z.string().min(1),
appKey: z.string().min(1),
appSecret: z.string().min(1),
});
const updateSchema = z.object({
dingTalkAppKey: z.string().optional(),
dingTalkAppSecret: z.string().optional(),
dingTalkOrganizations: z.array(orgSchema).optional(),
isDingTalkEnabled: z.boolean().optional(),
isRegistrationEnabled: z.boolean().optional(),
});
@ -68,35 +85,52 @@ export async function PUT(request: NextRequest) {
// 查找现有配置
const [existing] = await db.select().from(authSettings).limit(1);
const organizations = data.dingTalkOrganizations ?? existing?.dingTalkOrganizations ?? [];
const primaryOrg = organizations[0] as
| { name: string; appKey: string; appSecret: string }
| undefined;
const resolvedAppKey =
data.dingTalkAppKey ?? primaryOrg?.appKey ?? existing?.dingTalkAppKey ?? null;
const resolvedAppSecret =
data.dingTalkAppSecret ?? primaryOrg?.appSecret ?? existing?.dingTalkAppSecret ?? null;
if (existing) {
// 更新现有配置
const [updated] = await db
.update(authSettings)
.set({
...data,
dingTalkAppKey: resolvedAppKey,
dingTalkAppSecret: resolvedAppSecret,
dingTalkOrganizations: organizations,
isDingTalkEnabled:
data.isDingTalkEnabled ?? existing.isDingTalkEnabled,
isRegistrationEnabled:
data.isRegistrationEnabled ?? existing.isRegistrationEnabled,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.where(eq(authSettings.id, existing.id))
.returning();
return NextResponse.json(updated);
} else {
// 创建新配置
const [created] = await db
.insert(authSettings)
.values({
dingTalkAppKey: data.dingTalkAppKey,
dingTalkAppSecret: data.dingTalkAppSecret,
isDingTalkEnabled: data.isDingTalkEnabled || false,
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
updatedBy: session.user.id,
})
.returning();
return NextResponse.json(created);
}
// 创建新配置
const [created] = await db
.insert(authSettings)
.values({
dingTalkAppKey: resolvedAppKey,
dingTalkAppSecret: resolvedAppSecret,
dingTalkOrganizations: organizations,
isDingTalkEnabled: data.isDingTalkEnabled || false,
isRegistrationEnabled: data.isRegistrationEnabled ?? true,
updatedBy: session.user.id,
})
.returning();
return NextResponse.json(created);
} catch (error) {
console.error("[Auth Settings API] Update error:", error);

View File

@ -11,30 +11,56 @@ export async function GET(request: NextRequest) {
const code = searchParams.get("code");
const state = searchParams.get("state");
console.log("[DingTalk Callback] Received code:", code?.substring(0, 10) + "...", "state:", state);
console.log(
"[DingTalk Callback] Received code:",
code?.substring(0, 10) + "...",
"state:",
state,
);
if (!code) {
return NextResponse.json({ success: false, error: "Missing code" }, { status: 400 });
}
const settings = await getAuthSettings();
if (!settings.isDingTalkEnabled || !settings.dingTalkAppKey || !settings.dingTalkAppSecret) {
return NextResponse.json({ success: false, error: "DingTalk login is not configured" }, { status: 400 });
if (!settings.isDingTalkEnabled) {
return NextResponse.json(
{ success: false, error: "DingTalk login is not configured" },
{ status: 400 },
);
}
const organizations = settings.dingTalkOrganizations ?? [];
const selectedOrg = state
? organizations.find((org) => org.name === state)
: organizations[0];
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
const appSecret = selectedOrg?.appSecret ?? settings.dingTalkAppSecret;
if (!appKey || !appSecret) {
return NextResponse.json(
{ success: false, error: "DingTalk appKey/appSecret is not configured" },
{ status: 400 },
);
}
try {
// 1. 获取 userAccessToken
console.log("[DingTalk Callback] Getting user access token...");
const tokenResponse = await fetch("https://api.dingtalk.com/v1.0/oauth2/userAccessToken", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: settings.dingTalkAppKey,
clientSecret: settings.dingTalkAppSecret,
code,
grantType: "authorization_code",
}),
});
const tokenResponse = await fetch(
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: appKey,
clientSecret: appSecret,
code,
grantType: "authorization_code",
}),
},
);
const tokenData = await tokenResponse.json();
console.log("[DingTalk Callback] Token response:", tokenData.accessToken ? "success" : "failed");
@ -62,7 +88,11 @@ export async function GET(request: NextRequest) {
// 3. 获取应用访问令牌 (用于获取更详细的用户信息)
console.log("[DingTalk Callback] Getting app access token...");
const appTokenResponse = await fetch(`https://oapi.dingtalk.com/gettoken?appkey=${settings.dingTalkAppKey}&appsecret=${settings.dingTalkAppSecret}`);
const appTokenResponse = await fetch(
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(
appKey,
)}&appsecret=${encodeURIComponent(appSecret)}`,
);
const appTokenData = await appTokenResponse.json();
if (!appTokenData.access_token) {

View File

@ -3,9 +3,29 @@ import { getAuthSettings } from "@/lib/db/queries";
export async function GET(request: Request) {
const settings = await getAuthSettings();
if (!settings.isDingTalkEnabled || !settings.dingTalkAppKey) {
return NextResponse.json({ error: "DingTalk login is not enabled" }, { status: 400 });
if (!settings.isDingTalkEnabled) {
return NextResponse.json(
{ error: "DingTalk login is not enabled" },
{ status: 400 },
);
}
const urlObj = new URL(request.url);
const orgName = urlObj.searchParams.get("org");
const organizations = settings.dingTalkOrganizations ?? [];
const selectedOrg = orgName
? organizations.find((org) => org.name === orgName)
: organizations[0];
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
if (!appKey) {
return NextResponse.json(
{ error: "DingTalk appKey is not configured" },
{ status: 400 },
);
}
// 动态构建回调地址
@ -13,16 +33,18 @@ export async function GET(request: Request) {
const protocol = request.headers.get("x-forwarded-proto") || "http";
const baseUrl = `${protocol}://${host}`;
const redirectUri = `${baseUrl}/dingtalk-callback.html`;
const state = Math.random().toString(36).substring(7);
// 使用组织名称作为 state,便于回调时选择对应的配置
const state = orgName || selectedOrg?.name || "default";
// 钉钉 OAuth 2.0 授权地址
const url = `https://login.dingtalk.com/oauth2/auth?` +
const url =
`https://login.dingtalk.com/oauth2/auth?` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=code&` +
`client_id=${settings.dingTalkAppKey}&` +
`client_id=${encodeURIComponent(appKey)}&` +
`scope=openid&` +
`state=${state}&` +
`state=${encodeURIComponent(state)}&` +
`prompt=consent`;
return NextResponse.redirect(url);

View File

@ -0,0 +1,9 @@
-- 迁移文件:0025_add_dingtalk_organizations.sql
-- 功能:为 AuthSettings 添加多组织钉钉配置字段
ALTER TABLE "AuthSettings"
ADD COLUMN IF NOT EXISTS "dingTalkOrganizations" jsonb DEFAULT '[]'::jsonb;
COMMENT ON COLUMN "AuthSettings"."dingTalkOrganizations" IS '钉钉多组织配置,JSON 数组:[{ name, appKey, appSecret }]';

View File

@ -1499,9 +1499,29 @@ export async function getNavLinks() {
export async function getAuthSettings() {
try {
const [settings] = await db.select().from(authSettings).limit(1);
return settings || { dingTalkAppKey: "", dingTalkAppSecret: "", isDingTalkEnabled: false, isRegistrationEnabled: true };
if (settings) {
return {
...settings,
dingTalkOrganizations: settings.dingTalkOrganizations ?? [],
};
}
return {
dingTalkAppKey: "",
dingTalkAppSecret: "",
dingTalkOrganizations: [],
isDingTalkEnabled: false,
isRegistrationEnabled: true,
};
} catch (_error) {
return { dingTalkAppKey: "", dingTalkAppSecret: "", isDingTalkEnabled: false, isRegistrationEnabled: true };
return {
dingTalkAppKey: "",
dingTalkAppSecret: "",
dingTalkOrganizations: [],
isDingTalkEnabled: false,
isRegistrationEnabled: true,
};
}
}

View File

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