R18-1/R18-3: 接入企业微信入口绑定与免登回退
这个提交包含在:
@@ -20,6 +20,32 @@ export async function changePassword(payload: { oldPassword: string; newPassword
|
||||
|
||||
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
||||
|
||||
export type WecomEntryConfig = {
|
||||
enabled: boolean;
|
||||
corpId: string;
|
||||
agentId: string;
|
||||
oauthEnabled: boolean;
|
||||
loginUrl: string | null;
|
||||
};
|
||||
|
||||
export async function fetchSsoProviders() {
|
||||
return apiRequestData<SsoProviderInfo[]>("/api/auth/sso/providers", { auth: false });
|
||||
}
|
||||
|
||||
export async function fetchWecomConfig() {
|
||||
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
||||
}
|
||||
|
||||
export async function startWecomBind(returnTo = "/settings/profile") {
|
||||
return apiRequestData<{ loginUrl: string }>("/api/auth/wecom/bind", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ returnTo }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unbindWecom() {
|
||||
return apiRequestData<{ ok: boolean }>("/api/auth/wecom/unbind", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
+33
-12
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { fetchSsoProviders } from "../api/auth";
|
||||
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
@@ -39,6 +39,12 @@ export function LoginPage() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
|
||||
const wecomConfigQuery = useQuery({
|
||||
queryKey: ["auth", "wecom-config"],
|
||||
queryFn: () => fetchWecomConfig(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const wecomEntry = wecomConfigQuery.data?.enabled ? wecomConfigQuery.data : null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -114,19 +120,34 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{oidcProvider ? (
|
||||
{oidcProvider || wecomEntry ? (
|
||||
<div className="space-y-3 border-t border-zinc-200 pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">企业统一入口</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
{wecomEntry?.loginUrl ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const loginUrl = wecomEntry.loginUrl ?? "/api/auth/wecom/login";
|
||||
window.location.assign(loginUrl.replace(/returnTo=%2Fportal$/, `returnTo=${encodeURIComponent(redirectTo)}`));
|
||||
}}
|
||||
>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
) : null}
|
||||
{oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { fetchWecomConfig, startWecomBind, unbindWecom } from "../api/auth";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
export function ProfilePage() {
|
||||
const { me, permissions } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const wecomConfigQuery = useQuery({ queryKey: ["auth", "wecom-config"], queryFn: () => fetchWecomConfig(), staleTime: 60_000 });
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: async () => startWecomBind("/settings/profile"),
|
||||
onSuccess: (payload) => { window.location.assign(payload.loginUrl); },
|
||||
});
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: () => unbindWecom(),
|
||||
onSuccess: () => { window.location.reload(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -38,6 +52,21 @@ export function ProfilePage() {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="card-std mt-4">
|
||||
<h3 className="card-title">企业微信绑定</h3>
|
||||
<div className="space-y-3 text-sm text-zinc-700">
|
||||
<div>接入状态:<span className="font-medium text-zinc-900">{wecomConfigQuery.data?.enabled ? "已启用" : "未启用"}</span></div>
|
||||
<div>绑定状态:<span className="font-medium text-zinc-900">{me?.wecomBinding?.externalUserId ?? "未绑定"}</span></div>
|
||||
{searchParams.get("wecomBind") ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">绑定结果:{searchParams.get("wecomBind")}</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" disabled={!wecomConfigQuery.data?.enabled || bindMutation.isPending} onClick={() => bindMutation.mutate()}>绑定企业微信</Button>
|
||||
<Button type="button" variant="secondary" disabled={!me?.wecomBinding || unbindMutation.isPending} onClick={() => unbindMutation.mutate()}>解绑企业微信</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card-std mt-4">
|
||||
<h3 className="card-title">权限列表</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
PeoplePage,
|
||||
PortalAnnouncementsPage,
|
||||
PortalHomePage,
|
||||
ProjectsPage,
|
||||
ProjectDetailPage,
|
||||
ContractsRegistryPage,
|
||||
SystemSettingsPage,
|
||||
TeamPresencePage,
|
||||
TodosPage,
|
||||
@@ -92,6 +95,30 @@ export function AppRouter() {
|
||||
<Route path="portal" element={withRouteFallback(<PortalHomePage />)} />
|
||||
<Route path="announcements" element={withRouteFallback(<AnnouncementsPage />)} />
|
||||
<Route path="team" element={withRouteFallback(<TeamPresencePage />)} />
|
||||
<Route
|
||||
path="projects"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ProjectsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="projects/:projectId"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ProjectDetailPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts-registry"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ContractsRegistryPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="weekly-reports" element={withRouteFallback(<WeeklyReportsPage />)} />
|
||||
<Route path="todos" element={withRouteFallback(<TodosPage />)} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
在新工单中引用
屏蔽一个用户