钉钉登录支持配置多组织
This commit is contained in:
parent
12a383cceb
commit
9285fc1822
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
9
lib/db/migrations/0025_add_dingtalk_organizations.sql
Normal file
9
lib/db/migrations/0025_add_dingtalk_organizations.sql
Normal 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 }]';
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user