feat: add enterprise identity and channel integrations
This commit is contained in:
@@ -33,6 +33,7 @@ const TicketDetailPage = React.lazy(() => import('@/features/tickets/TicketDetai
|
||||
const KnowledgeBaseListPage = React.lazy(() => import('@/features/knowledge/KnowledgeBaseListPage').then((module) => ({ default: module.KnowledgeBaseListPage })))
|
||||
const KnowledgeSearchPage = React.lazy(() => import('@/features/knowledge/KnowledgeSearchPage').then((module) => ({ default: module.KnowledgeSearchPage })))
|
||||
const KnowledgeItemEditorPage = React.lazy(() => import('@/features/knowledge/KnowledgeItemEditorPage').then((module) => ({ default: module.KnowledgeItemEditorPage })))
|
||||
const KnowledgeGovernancePage = React.lazy(() => import('@/features/knowledge/KnowledgeGovernancePage').then((module) => ({ default: module.KnowledgeGovernancePage })))
|
||||
const SolutionListPage = React.lazy(() => import('@/features/solutions/SolutionListPage').then((module) => ({ default: module.SolutionListPage })))
|
||||
const SolutionCreatePage = React.lazy(() => import('@/features/solutions/SolutionCreatePage').then((module) => ({ default: module.SolutionCreatePage })))
|
||||
const ModelProviderPage = React.lazy(() => import('@/features/modelhub/ModelProviderPage').then((module) => ({ default: module.ModelProviderPage })))
|
||||
@@ -42,10 +43,13 @@ const AgentListPage = React.lazy(() => import('@/features/agents/AgentListPage')
|
||||
const AgentDetailPage = React.lazy(() => import('@/features/agents/AgentDetailPage').then((module) => ({ default: module.AgentDetailPage })))
|
||||
const AgentRunListPage = React.lazy(() => import('@/features/agents/AgentRunListPage').then((module) => ({ default: module.AgentRunListPage })))
|
||||
const AgentRunDetailPage = React.lazy(() => import('@/features/agents/AgentRunDetailPage').then((module) => ({ default: module.AgentRunDetailPage })))
|
||||
const ToolManagementPage = React.lazy(() => import('@/features/tools/ToolManagementPage').then((module) => ({ default: module.ToolManagementPage })))
|
||||
const ChannelManagementPage = React.lazy(() => import('@/features/channels/ChannelManagementPage').then((module) => ({ default: module.ChannelManagementPage })))
|
||||
const SystemUsersPage = React.lazy(() => import('@/features/system/SystemUsersPage').then((module) => ({ default: module.SystemUsersPage })))
|
||||
const SystemUserGroupsPage = React.lazy(() => import('@/features/system/SystemUserGroupsPage').then((module) => ({ default: module.SystemUserGroupsPage })))
|
||||
const SystemRolesPage = React.lazy(() => import('@/features/system/SystemRolesPage').then((module) => ({ default: module.SystemRolesPage })))
|
||||
const SystemMenusPage = React.lazy(() => import('@/features/system/SystemMenusPage').then((module) => ({ default: module.SystemMenusPage })))
|
||||
const SystemIdentityPage = React.lazy(() => import('@/features/system/SystemIdentityPage').then((module) => ({ default: module.SystemIdentityPage })))
|
||||
const AuditLogPage = React.lazy(() => import('@/features/system/AuditLogPage').then((module) => ({ default: module.AuditLogPage })))
|
||||
const DesignSystemPreview = React.lazy(() => import('@/features/design-system/DesignSystemPreview').then((module) => ({ default: module.DesignSystemPreview })))
|
||||
const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').then((module) => ({ default: module.ProfilePage })))
|
||||
@@ -53,11 +57,12 @@ const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').th
|
||||
export const routeMeta: RouteMeta[] = [
|
||||
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
|
||||
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
|
||||
{ key: 'ticket-create', pattern: /^\/tickets\/new$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
|
||||
{ key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
|
||||
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
|
||||
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
|
||||
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
|
||||
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
|
||||
{ key: 'knowledge-governance', pattern: /^\/knowledge\/governance$/, title: '知识治理', description: '审核资产、追踪版本与查看影响范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeGovernancePage /> },
|
||||
{ key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeBaseListPage /> },
|
||||
{ key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => <SolutionCreatePage /> },
|
||||
{ key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <SolutionListPage /> },
|
||||
@@ -68,10 +73,13 @@ export const routeMeta: RouteMeta[] = [
|
||||
{ key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentListPage /> },
|
||||
{ key: 'agent-run-detail', pattern: /^\/agent-runs\/([0-9a-f-]+)$/i, title: '运行记录详情', description: '查看一次 Agent 运行的输入、输出与状态', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'Agent 运行记录', path: '/agent-runs' }], backPath: '/agent-runs', agent: 'on-demand', render: (match) => <AgentRunDetailPage publicId={match[1]} /> },
|
||||
{ key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentRunListPage /> },
|
||||
{ key: 'tools', pattern: /^\/tools$/, title: '工具治理', description: '统一管理 Tool、MCP、授权和调用风险', section: 'AI 平台', navPath: '/tools', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ToolManagementPage /> },
|
||||
{ key: 'channels', pattern: /^\/channels$/, title: '渠道接入', description: '管理服务群消息的幂等收发与回执', section: 'AI 平台', navPath: '/channels', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ChannelManagementPage /> },
|
||||
{ key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUsersPage /> },
|
||||
{ key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUserGroupsPage /> },
|
||||
{ key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemRolesPage /> },
|
||||
{ key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemMenusPage /> },
|
||||
{ key: 'system-identity', pattern: /^\/system\/identity$/, title: '企业身份', description: '管理单点登录、外部身份与组织同步', section: '系统治理', navPath: '/system/identity', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemIdentityPage /> },
|
||||
{ key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <AuditLogPage /> },
|
||||
{ key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <DesignSystemPreview /> },
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ type AppCreateDialogProps = {
|
||||
onSubmit: React.FormEventHandler<HTMLFormElement>
|
||||
children: React.ReactNode
|
||||
pending?: boolean
|
||||
pendingLabel?: string
|
||||
submitDisabled?: boolean
|
||||
error?: string
|
||||
className?: string
|
||||
@@ -37,6 +38,7 @@ export function AppCreateDialog({
|
||||
onSubmit,
|
||||
children,
|
||||
pending = false,
|
||||
pendingLabel = '正在创建…',
|
||||
submitDisabled = false,
|
||||
error,
|
||||
className,
|
||||
@@ -69,7 +71,7 @@ export function AppCreateDialog({
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending || submitDisabled}>
|
||||
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
|
||||
{pending ? '正在创建…' : submitLabel}
|
||||
{pending ? pendingLabel : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -12,11 +12,19 @@ function navigate(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
window.sessionStorage.clear()
|
||||
window.location.hash = '#/tickets/42'
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('已打开页面标签', () => {
|
||||
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
|
||||
@@ -64,6 +72,47 @@ describe('已打开页面标签', () => {
|
||||
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('右键标签可以关闭其左侧和右侧标签', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppRouteTabs />)
|
||||
navigate('/knowledge')
|
||||
navigate('/solutions')
|
||||
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: '关闭左侧标签' }))
|
||||
|
||||
expect(screen.queryByRole('tab', { name: '工单详情' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: '关闭右侧标签' }))
|
||||
|
||||
expect(screen.queryByRole('tab', { name: '解决方案库' })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(1)
|
||||
expect(window.location.hash).toBe('#/knowledge')
|
||||
})
|
||||
|
||||
it('批量菜单可以关闭其他标签和关闭全部标签', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppRouteTabs />)
|
||||
navigate('/knowledge')
|
||||
navigate('/solutions')
|
||||
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 3 个' }))
|
||||
await user.click(screen.getByRole('menuitem', { name: '关闭其他标签' }))
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(1)
|
||||
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 1 个' }))
|
||||
await user.click(screen.getByRole('menuitem', { name: '关闭全部标签' }))
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(1)
|
||||
expect(screen.getByRole('tab', { name: '工作台' })).toBeInTheDocument()
|
||||
expect(window.location.hash).toBe('#/')
|
||||
})
|
||||
|
||||
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<AppRouteTabs />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { Check, ChevronLeft, ChevronRight, Ellipsis, X } from 'lucide-react'
|
||||
import { Check, ChevronLeft, ChevronRight, Ellipsis, Layers2, PanelLeftClose, PanelRightClose, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,14 +9,26 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2'
|
||||
const MAX_OPEN_TABS = 12
|
||||
const SCROLL_EPSILON = 2
|
||||
const WORKBENCH_TAB: RouteNavigationTab = { label: '工作台', path: '/' }
|
||||
|
||||
type CloseScope = 'left' | 'right' | 'others' | 'all'
|
||||
|
||||
type TabScrollState = {
|
||||
overflow: boolean
|
||||
@@ -60,6 +72,7 @@ export function AppRouteTabs() {
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false
|
||||
})
|
||||
const activeTabIndex = openedTabs.findIndex((tab) => tab.path === route)
|
||||
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const element = scrollAreaRef.current
|
||||
@@ -124,13 +137,33 @@ export function AppRouteTabs() {
|
||||
|
||||
const closeTab = (path: string) => {
|
||||
const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
|
||||
const next = openedTabs.filter((tab) => tab.path !== path)
|
||||
const remaining = openedTabs.filter((tab) => tab.path !== path)
|
||||
const next = remaining.length ? remaining : [WORKBENCH_TAB]
|
||||
saveOpenedTabs(next)
|
||||
setOpenedTabs(next)
|
||||
|
||||
if (path !== route) return
|
||||
const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0]
|
||||
window.location.hash = fallback?.path ?? '/'
|
||||
const fallback = remaining[Math.max(0, closedIndex - 1)] ?? remaining[0] ?? WORKBENCH_TAB
|
||||
window.location.hash = fallback.path
|
||||
}
|
||||
|
||||
const closeTabs = (anchorPath: string, scope: CloseScope) => {
|
||||
const anchorIndex = openedTabs.findIndex((tab) => tab.path === anchorPath)
|
||||
if (anchorIndex < 0 && scope !== 'all') return
|
||||
|
||||
let remaining: RouteNavigationTab[]
|
||||
if (scope === 'left') remaining = openedTabs.slice(anchorIndex)
|
||||
else if (scope === 'right') remaining = openedTabs.slice(0, anchorIndex + 1)
|
||||
else if (scope === 'others') remaining = [openedTabs[anchorIndex]]
|
||||
else remaining = []
|
||||
|
||||
const next = remaining.length ? remaining : [WORKBENCH_TAB]
|
||||
saveOpenedTabs(next)
|
||||
setOpenedTabs(next)
|
||||
|
||||
if (next.some((tab) => tab.path === route)) return
|
||||
const anchor = next.find((tab) => tab.path === anchorPath)
|
||||
window.location.hash = anchor?.path ?? WORKBENCH_TAB.path
|
||||
}
|
||||
|
||||
const scrollTabs = (direction: -1 | 1) => {
|
||||
@@ -175,39 +208,53 @@ export function AppRouteTabs() {
|
||||
event.currentTarget.scrollLeft += event.deltaY
|
||||
}}
|
||||
>
|
||||
{openedTabs.map((tab) => (
|
||||
<div
|
||||
key={tab.path}
|
||||
ref={(node) => {
|
||||
if (node) tabRefs.current.set(tab.path, node)
|
||||
else tabRefs.current.delete(tab.path)
|
||||
}}
|
||||
className="group/tab relative flex flex-none items-center"
|
||||
>
|
||||
<TabsTrigger value={tab.path} className="max-w-52 flex-none px-3.5 pr-8">
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-1 opacity-60 group-hover/tab:opacity-100 focus-visible:opacity-100"
|
||||
aria-label={`关闭${tab.label}(${tab.path})`}
|
||||
title={`关闭${tab.label}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
closeTab(tab.path)
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{openedTabs.map((tab, tabIndex) => (
|
||||
<ContextMenu key={tab.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (node) tabRefs.current.set(tab.path, node)
|
||||
else tabRefs.current.delete(tab.path)
|
||||
}}
|
||||
className="group/tab relative flex flex-none items-center"
|
||||
>
|
||||
<TabsTrigger value={tab.path} className="max-w-52 flex-none px-3.5 pr-8">
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-1 opacity-60 group-hover/tab:opacity-100 focus-visible:opacity-100"
|
||||
aria-label={`关闭${tab.label}(${tab.path})`}
|
||||
title={`关闭${tab.label}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
closeTab(tab.path)
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem onSelect={() => closeTab(tab.path)}><X />关闭当前标签</ContextMenuItem>
|
||||
<ContextMenuItem disabled={tabIndex === 0} onSelect={() => closeTabs(tab.path, 'left')}><PanelLeftClose />关闭左侧标签</ContextMenuItem>
|
||||
<ContextMenuItem disabled={tabIndex === openedTabs.length - 1} onSelect={() => closeTabs(tab.path, 'right')}><PanelRightClose />关闭右侧标签</ContextMenuItem>
|
||||
<ContextMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(tab.path, 'others')}><Layers2 />关闭其他标签</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem onSelect={() => closeTabs(tab.path, 'all')}><Trash2 />关闭全部标签</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{scrollState.overflow ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -218,33 +265,40 @@ export function AppRouteTabs() {
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`}>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
|
||||
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{openedTabs.map((tab) => (
|
||||
<DropdownMenuItem
|
||||
key={tab.path}
|
||||
aria-current={tab.path === route ? 'page' : undefined}
|
||||
onSelect={() => {
|
||||
if (tab.path !== route) window.location.hash = tab.path
|
||||
}}
|
||||
>
|
||||
{tab.path === route ? <Check /> : <span aria-hidden="true" className="size-4" />}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
{tab.path === route ? <DropdownMenuShortcut>当前</DropdownMenuShortcut> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`} title="管理已打开页面">
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
|
||||
{openedTabs.map((tab) => (
|
||||
<DropdownMenuItem
|
||||
key={tab.path}
|
||||
aria-current={tab.path === route ? 'page' : undefined}
|
||||
onSelect={() => {
|
||||
if (tab.path !== route) window.location.hash = tab.path
|
||||
}}
|
||||
>
|
||||
{tab.path === route ? <Check /> : <span aria-hidden="true" className="size-4" />}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
{tab.path === route ? <DropdownMenuShortcut>当前</DropdownMenuShortcut> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>批量关闭</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled={activeTabIndex <= 0} onSelect={() => closeTabs(route, 'left')}><PanelLeftClose />关闭左侧标签</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={activeTabIndex < 0 || activeTabIndex === openedTabs.length - 1} onSelect={() => closeTabs(route, 'right')}><PanelRightClose />关闭右侧标签</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(route, 'others')}><Layers2 />关闭其他标签</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => closeTabs(route, 'all')}><Trash2 />关闭全部标签</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Library,
|
||||
LogOut,
|
||||
Menu,
|
||||
MessageSquareMore,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -56,6 +57,7 @@ const iconMap = {
|
||||
home: Home,
|
||||
library: Library,
|
||||
menu: Menu,
|
||||
message: MessageSquareMore,
|
||||
roles: UserCog,
|
||||
search: Search,
|
||||
settings: Settings,
|
||||
|
||||
55
frontend/src/features/auth/LoginPage.test.tsx
Normal file
55
frontend/src/features/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LoginPage } from './LoginPage'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
login: vi.fn(),
|
||||
listIdentityLoginProviders: vi.fn(),
|
||||
startIdentityLogin: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./AuthProvider', () => ({
|
||||
useAuth: () => ({ login: mocks.login })
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
listIdentityLoginProviders: mocks.listIdentityLoginProviders,
|
||||
startIdentityLogin: mocks.startIdentityLogin
|
||||
}))
|
||||
|
||||
describe('LoginPage enterprise identity', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.location.hash = ''
|
||||
mocks.listIdentityLoginProviders.mockResolvedValue([
|
||||
{
|
||||
slug: 'corp',
|
||||
name: '企业统一身份',
|
||||
button_label: '使用企业账号登录',
|
||||
provider_type: 'oidc'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('shows enabled OIDC providers and starts enterprise login', async () => {
|
||||
render(<LoginPage />)
|
||||
|
||||
const ssoButton = await screen.findByRole('button', { name: '使用企业账号登录' })
|
||||
fireEvent.click(ssoButton)
|
||||
|
||||
expect(mocks.startIdentityLogin).toHaveBeenCalledWith('corp')
|
||||
expect(screen.getByText('或使用本地账号')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces callback error without hiding local login', async () => {
|
||||
window.location.hash = '#/login?auth_error=企业登录失败'
|
||||
render(<LoginPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('企业登录失败')).toBeInTheDocument())
|
||||
expect(screen.getByPlaceholderText('请输入账号')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import { AlertCircle, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
|
||||
import { AlertCircle, Building2, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
|
||||
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FieldSeparator } from '@/components/ui/field'
|
||||
|
||||
import { useAuth } from './AuthProvider'
|
||||
import { listIdentityLoginProviders, startIdentityLogin, type IdentityLoginProvider } from './api'
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
@@ -15,6 +17,14 @@ export function LoginPage() {
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [providers, setProviders] = React.useState<IdentityLoginProvider[]>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
void listIdentityLoginProviders().then(setProviders).catch(() => setProviders([]))
|
||||
const query = window.location.hash.split('?')[1]
|
||||
const authError = query ? new URLSearchParams(query).get('auth_error') : ''
|
||||
if (authError) setError(authError)
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
@@ -43,6 +53,24 @@ export function LoginPage() {
|
||||
<CardContent className="px-6">
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
{error ? <Alert variant="destructive"><AlertCircle className="size-4" /><AlertTitle>无法登录</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
|
||||
{providers.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.slug}
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => startIdentityLogin(provider.slug)}
|
||||
>
|
||||
<Building2 data-icon="inline-start" />
|
||||
{provider.button_label}
|
||||
</Button>
|
||||
))}
|
||||
<FieldSeparator>或使用本地账号</FieldSeparator>
|
||||
</div>
|
||||
) : null}
|
||||
<AppField label="账号" htmlFor="username" invalid={Boolean(error)} required><div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="username" autoComplete="username" aria-invalid={Boolean(error)} className="pl-9" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></div></AppField>
|
||||
<AppField label="密码" htmlFor="password" invalid={Boolean(error)} required><div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="password" type="password" autoComplete="current-password" aria-invalid={Boolean(error)} className="pl-9" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></div></AppField>
|
||||
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting || !username || !password}>{submitting ? '登录中...' : '登录'}</Button>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { apiRequest, apiUrl } from '@/lib/api'
|
||||
|
||||
import type { CurrentUser } from './types'
|
||||
|
||||
export type IdentityLoginProvider = {
|
||||
slug: string
|
||||
name: string
|
||||
button_label: string
|
||||
provider_type: 'oidc'
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
return apiRequest<CurrentUser>('/accounts/auth/me')
|
||||
}
|
||||
@@ -32,3 +39,12 @@ export async function logout() {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function listIdentityLoginProviders() {
|
||||
return apiRequest<IdentityLoginProvider[]>('/identity/auth/providers')
|
||||
}
|
||||
|
||||
export function startIdentityLogin(slug: string, returnTo = '/') {
|
||||
const query = new URLSearchParams({ return_to: returnTo })
|
||||
window.location.assign(apiUrl(`/identity/auth/oidc/${encodeURIComponent(slug)}/start?${query}`))
|
||||
}
|
||||
|
||||
109
frontend/src/features/channels/ChannelManagementPage.test.tsx
Normal file
109
frontend/src/features/channels/ChannelManagementPage.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ChannelManagementPage } from './ChannelManagementPage'
|
||||
import {
|
||||
listChannelEndpoints,
|
||||
listChannelMessages,
|
||||
retryChannelMessage
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/features/auth/AuthProvider', () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
is_superuser: true,
|
||||
permissions: ['channel.view', 'channel.send', 'channel.manage']
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
listChannelEndpoints: vi.fn(),
|
||||
listChannelMessages: vi.fn(),
|
||||
createChannelEndpoint: vi.fn(),
|
||||
updateChannelEndpoint: vi.fn(),
|
||||
sendChannelMessage: vi.fn(),
|
||||
retryChannelMessage: vi.fn()
|
||||
}))
|
||||
|
||||
const endpoint = {
|
||||
id: 1,
|
||||
public_id: 'endpoint-public-id',
|
||||
name: '一线服务群',
|
||||
adapter_type: 'generic_webhook' as const,
|
||||
outbound_url: 'https://channel.example/messages',
|
||||
webhook_secret_set: true,
|
||||
outbound_token_set: true,
|
||||
enabled: true,
|
||||
created_at: '2026-07-27T08:00:00Z',
|
||||
updated_at: '2026-07-27T08:00:00Z'
|
||||
}
|
||||
|
||||
const failedMessage = {
|
||||
public_id: 'message-public-id',
|
||||
endpoint_id: 1,
|
||||
endpoint_name: '一线服务群',
|
||||
conversation_public_id: 'conversation-public-id',
|
||||
external_conversation_id: 'group-001',
|
||||
ticket_id: 42,
|
||||
customer_reply_task_id: 15,
|
||||
reply_revision: 2,
|
||||
direction: 'outbound' as const,
|
||||
external_message_id: '',
|
||||
idempotency_key: 'reply-001',
|
||||
sender: '支持工程师',
|
||||
message_type: 'text',
|
||||
text: '正在处理中,请稍候。',
|
||||
payload: {},
|
||||
status: 'failed' as const,
|
||||
error_message: '上游连接超时',
|
||||
occurred_at: null,
|
||||
sent_at: null,
|
||||
delivered_at: null,
|
||||
created_at: '2026-07-27T08:10:00Z',
|
||||
updated_at: '2026-07-27T08:10:00Z'
|
||||
}
|
||||
|
||||
describe('ChannelManagementPage', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
|
||||
vi.mocked(listChannelMessages).mockResolvedValue([failedMessage])
|
||||
vi.mocked(retryChannelMessage).mockResolvedValue({
|
||||
...failedMessage,
|
||||
status: 'queued',
|
||||
error_message: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('展示渠道消息并允许失败出站重试', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChannelManagementPage />)
|
||||
|
||||
expect(await screen.findByText('正在处理中,请稍候。')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('一线服务群').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('上游连接超时')).toBeInTheDocument()
|
||||
expect(screen.getByText(/回复任务 #15 v2/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '发送消息' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(retryChannelMessage).toHaveBeenCalledWith('message-public-id')
|
||||
})
|
||||
})
|
||||
|
||||
it('展示适配器凭证状态和 Webhook 地址', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChannelManagementPage />)
|
||||
|
||||
await screen.findByText('正在处理中,请稍候。')
|
||||
await user.click(screen.getByRole('tab', { name: '适配器' }))
|
||||
|
||||
expect(screen.getByText('签名密钥:已配置')).toBeInTheDocument()
|
||||
expect(screen.getByText('出站凭证:已配置')).toBeInTheDocument()
|
||||
expect(screen.getByText(/endpoint-public-id/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
414
frontend/src/features/channels/ChannelManagementPage.tsx
Normal file
414
frontend/src/features/channels/ChannelManagementPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
CircleAlert,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Webhook
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
|
||||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPage } from '@/components/app/AppPage'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { AppSection } from '@/components/app/AppSection'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useAuth } from '@/features/auth/AuthProvider'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
import {
|
||||
createChannelEndpoint,
|
||||
listChannelEndpoints,
|
||||
listChannelMessages,
|
||||
retryChannelMessage,
|
||||
updateChannelEndpoint
|
||||
} from './api'
|
||||
import type { ChannelEndpoint, ChannelMessage, ChannelMessageStatus } from './types'
|
||||
|
||||
const endpointInitial = {
|
||||
name: '',
|
||||
outbound_url: '',
|
||||
webhook_secret: '',
|
||||
outbound_token: ''
|
||||
}
|
||||
|
||||
const STATUS_META: Record<ChannelMessageStatus, {
|
||||
label: string
|
||||
variant: 'secondary' | 'info' | 'success' | 'danger' | 'warning'
|
||||
}> = {
|
||||
received: { label: '已接收', variant: 'info' },
|
||||
queued: { label: '待投递', variant: 'warning' },
|
||||
sent: { label: '已发送', variant: 'success' },
|
||||
delivered: { label: '已送达', variant: 'success' },
|
||||
failed: { label: '失败', variant: 'danger' }
|
||||
}
|
||||
|
||||
export function ChannelManagementPage() {
|
||||
const { user } = useAuth()
|
||||
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
|
||||
const [messages, setMessages] = React.useState<ChannelMessage[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [error, setError] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [endpointFilter, setEndpointFilter] = React.useState('all')
|
||||
const [directionFilter, setDirectionFilter] = React.useState('all')
|
||||
const [createOpen, setCreateOpen] = React.useState(false)
|
||||
const [endpointForm, setEndpointForm] = React.useState(endpointInitial)
|
||||
|
||||
const hasPermission = React.useCallback((permission: string) => Boolean(
|
||||
user?.is_superuser || user?.permissions.includes(permission)
|
||||
), [user])
|
||||
const canManage = hasPermission('channel.manage')
|
||||
const canSend = hasPermission('channel.send') && hasPermission('ticket.reply')
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const endpointId = endpointFilter === 'all' ? undefined : Number(endpointFilter)
|
||||
const direction = directionFilter === 'all' ? undefined : directionFilter
|
||||
const [nextEndpoints, nextMessages] = await Promise.all([
|
||||
listChannelEndpoints(),
|
||||
listChannelMessages({ endpoint_id: endpointId, direction })
|
||||
])
|
||||
setEndpoints(nextEndpoints)
|
||||
setMessages(nextMessages)
|
||||
setError('')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '渠道数据加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [directionFilter, endpointFilter])
|
||||
|
||||
React.useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function runAction(key: string, action: () => Promise<unknown>, success: string) {
|
||||
setBusy(key)
|
||||
try {
|
||||
await action()
|
||||
toast.success(success)
|
||||
await load()
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '操作未完成')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setBusy('create')
|
||||
try {
|
||||
await createChannelEndpoint({ ...endpointForm, enabled: true })
|
||||
setEndpointForm(endpointInitial)
|
||||
setCreateOpen(false)
|
||||
toast.success('渠道适配器已创建')
|
||||
await load()
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '渠道创建失败')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const messageColumns: AppDataTableColumn<ChannelMessage>[] = [
|
||||
{
|
||||
key: 'direction',
|
||||
header: '方向',
|
||||
cell: (message) => (
|
||||
<Badge variant={message.direction === 'inbound' ? 'info' : 'ai'}>
|
||||
{message.direction === 'inbound' ? <ArrowDownToLine /> : <ArrowUpFromLine />}
|
||||
{message.direction === 'inbound' ? '入站' : '出站'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'channel',
|
||||
header: '渠道 / 会话',
|
||||
cell: (message) => (
|
||||
<div>
|
||||
<p className="font-medium">{message.endpoint_name}</p>
|
||||
<p className="max-w-48 truncate text-xs text-muted-foreground">
|
||||
{message.external_conversation_id}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
header: '消息',
|
||||
cell: (message) => (
|
||||
<div className="max-w-xl">
|
||||
<p className="line-clamp-2 text-sm">{message.text}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{message.sender || '平台'}{message.ticket_id ? ` · 工单 #${message.ticket_id}` : ''}
|
||||
{message.customer_reply_task_id
|
||||
? ` · 回复任务 #${message.customer_reply_task_id} v${message.reply_revision}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: '状态',
|
||||
cell: (message) => (
|
||||
<div>
|
||||
<Badge variant={STATUS_META[message.status].variant}>{STATUS_META[message.status].label}</Badge>
|
||||
{message.error_message ? (
|
||||
<p className="mt-1 max-w-44 truncate text-xs text-danger" title={message.error_message}>
|
||||
{message.error_message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
header: '时间',
|
||||
cell: (message) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatDateTime(message.occurred_at || message.created_at)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: '',
|
||||
cell: (message) => message.direction === 'outbound' && message.status === 'failed' && canSend ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy === `retry-${message.public_id}`}
|
||||
onClick={() => void runAction(
|
||||
`retry-${message.public_id}`,
|
||||
() => retryChannelMessage(message.public_id),
|
||||
'消息已重新进入投递队列'
|
||||
)}
|
||||
>
|
||||
<RotateCcw data-icon="inline-start" />重试
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
]
|
||||
|
||||
if (!hasPermission('channel.view')) {
|
||||
return (
|
||||
<AppPage header={<AppPageHeader title="渠道接入" description="管理服务群消息的幂等收发和回执。" />}>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>没有查看权限</AlertTitle>
|
||||
<AlertDescription>请联系管理员授予“查看渠道消息”权限。</AlertDescription>
|
||||
</Alert>
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
|
||||
const inboundCount = messages.filter((message) => message.direction === 'inbound').length
|
||||
const outboundCount = messages.filter((message) => message.direction === 'outbound').length
|
||||
const failedCount = messages.filter((message) => message.status === 'failed').length
|
||||
const metricCards: Array<{ label: string; value: number; icon: typeof KeyRound }> = [
|
||||
{ label: '已启用渠道', value: endpoints.filter((endpoint) => endpoint.enabled).length, icon: KeyRound },
|
||||
{ label: '入站消息', value: inboundCount, icon: ArrowDownToLine },
|
||||
{ label: '出站消息', value: outboundCount, icon: ArrowUpFromLine },
|
||||
{ label: '失败待处理', value: failedCount, icon: CircleAlert }
|
||||
]
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
maxWidth="7xl"
|
||||
contentClassName="gap-5"
|
||||
header={(
|
||||
<AppPageHeader
|
||||
title="渠道接入"
|
||||
description="用统一 Webhook 契约接入服务群,保证入站去重、出站幂等和回执可追溯。"
|
||||
meta={<Badge variant="ai"><Webhook />Generic Webhook</Badge>}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw data-icon="inline-start" />刷新
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />接入渠道
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{metricCards.map(({ label, value, icon: Icon }) => (
|
||||
<Card key={label} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>渠道数据加载失败</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3"><Skeleton className="h-10 w-72" /><Skeleton className="h-96" /></div>
|
||||
) : (
|
||||
<Tabs defaultValue="messages">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="messages">消息时间线</TabsTrigger>
|
||||
<TabsTrigger value="endpoints">适配器</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="messages" className="mt-4">
|
||||
<AppSection title="最近收发" description="最多展示最近 100 条消息;失败出站可沿用原幂等键重试。">
|
||||
<AppDataTable
|
||||
columns={messageColumns}
|
||||
rows={messages}
|
||||
getRowKey={(message) => message.public_id}
|
||||
emptyTitle="暂无渠道消息"
|
||||
emptyDescription="签名 Webhook 收到消息后会显示在这里。"
|
||||
toolbar={(
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={endpointFilter} onValueChange={setEndpointFilter}>
|
||||
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>
|
||||
<SelectItem value="all">全部渠道</SelectItem>
|
||||
{endpoints.map((endpoint) => (
|
||||
<SelectItem key={endpoint.id} value={String(endpoint.id)}>{endpoint.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
<Select value={directionFilter} onValueChange={setDirectionFilter}>
|
||||
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>
|
||||
<SelectItem value="all">全部方向</SelectItem>
|
||||
<SelectItem value="inbound">仅入站</SelectItem>
|
||||
<SelectItem value="outbound">仅出站</SelectItem>
|
||||
</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</AppSection>
|
||||
</TabsContent>
|
||||
<TabsContent value="endpoints" className="mt-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{endpoints.length === 0 ? (
|
||||
<div className="lg:col-span-2">
|
||||
<AppEmptyState title="还没有渠道适配器" description="创建适配器后,将签名 Webhook 地址配置到服务群机器人。" />
|
||||
</div>
|
||||
) : endpoints.map((endpoint) => {
|
||||
const webhookUrl = `${window.location.origin}/api/channels/webhooks/${endpoint.public_id}/messages`
|
||||
return (
|
||||
<Card key={endpoint.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{endpoint.name}</CardTitle>
|
||||
<CardDescription>{endpoint.adapter_type}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={endpoint.enabled ? 'success' : 'secondary'}>
|
||||
{endpoint.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="rounded-lg bg-muted/45 p-3">
|
||||
<p className="text-xs text-muted-foreground">入站 Webhook</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate text-xs">{webhookUrl}</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`复制 ${endpoint.name} Webhook`}
|
||||
onClick={() => void navigator.clipboard.writeText(webhookUrl).then(() => toast.success('Webhook 已复制'))}
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<span>签名密钥:{endpoint.webhook_secret_set ? '已配置' : '未配置'}</span>
|
||||
<span>出站凭证:{endpoint.outbound_token_set ? '已配置' : '未配置'}</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
出站地址:{endpoint.outbound_url || '未配置'}
|
||||
</p>
|
||||
{canManage ? (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<span className="text-sm">接收与发送</span>
|
||||
<Switch
|
||||
checked={endpoint.enabled}
|
||||
disabled={busy === `endpoint-${endpoint.id}`}
|
||||
onCheckedChange={(enabled) => void runAction(
|
||||
`endpoint-${endpoint.id}`,
|
||||
() => updateChannelEndpoint(endpoint.id, { enabled }),
|
||||
enabled ? '渠道已启用' : '渠道已停用'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<AppCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
title="接入 Generic Webhook"
|
||||
description="入站请求使用 HMAC-SHA256 签名;访问凭证保存后不再回显。"
|
||||
submitLabel="创建适配器"
|
||||
pending={busy === 'create'}
|
||||
onSubmit={handleCreate}
|
||||
>
|
||||
<FieldGroup>
|
||||
<AppField label="渠道名称" htmlFor="channel-name" required>
|
||||
<Input id="channel-name" required autoFocus value={endpointForm.name} onChange={(event) => setEndpointForm({ ...endpointForm, name: event.target.value })} placeholder="例如:一线服务群" />
|
||||
</AppField>
|
||||
<AppField label="出站地址" htmlFor="channel-url" required>
|
||||
<Input id="channel-url" required type="url" value={endpointForm.outbound_url} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_url: event.target.value })} placeholder="https://channel.example/messages" />
|
||||
</AppField>
|
||||
<AppField label="Webhook 签名密钥" htmlFor="channel-secret" required>
|
||||
<Input id="channel-secret" required type="password" autoComplete="new-password" value={endpointForm.webhook_secret} onChange={(event) => setEndpointForm({ ...endpointForm, webhook_secret: event.target.value })} />
|
||||
</AppField>
|
||||
<AppField label="出站 Bearer Token" htmlFor="channel-token">
|
||||
<Input id="channel-token" type="password" autoComplete="new-password" value={endpointForm.outbound_token} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_token: event.target.value })} />
|
||||
</AppField>
|
||||
</FieldGroup>
|
||||
</AppCreateDialog>
|
||||
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
115
frontend/src/features/channels/ChannelReplyDialog.test.tsx
Normal file
115
frontend/src/features/channels/ChannelReplyDialog.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CustomerReplyTask } from '@/features/tickets/types'
|
||||
|
||||
import { ChannelReplyDialog } from './ChannelReplyDialog'
|
||||
import { listChannelEndpoints, sendChannelMessage } from './api'
|
||||
import type { ChannelEndpoint, ChannelMessage } from './types'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
listChannelEndpoints: vi.fn(),
|
||||
sendChannelMessage: vi.fn()
|
||||
}))
|
||||
|
||||
const endpoint: ChannelEndpoint = {
|
||||
id: 8,
|
||||
public_id: 'endpoint-8',
|
||||
name: '客户服务群',
|
||||
adapter_type: 'generic_webhook',
|
||||
outbound_url: 'https://channel.example/messages',
|
||||
webhook_secret_set: true,
|
||||
outbound_token_set: true,
|
||||
enabled: true,
|
||||
created_at: '2026-07-27T08:00:00Z',
|
||||
updated_at: '2026-07-27T08:00:00Z'
|
||||
}
|
||||
|
||||
const task: CustomerReplyTask = {
|
||||
id: 15,
|
||||
ticket_id: 7,
|
||||
support_analysis_id: 9,
|
||||
status: 'draft',
|
||||
draft_text: '我们正在检查订单服务连接池状态。',
|
||||
final_text: '连接池已恢复,请重新尝试。',
|
||||
revision: 3,
|
||||
created_by_id: 3,
|
||||
updated_by_id: 3,
|
||||
approved_by_id: null,
|
||||
approved_at: null,
|
||||
sent_by_id: null,
|
||||
sent_at: null,
|
||||
created_at: '2026-07-27T08:00:05Z',
|
||||
updated_at: '2026-07-27T08:03:00Z',
|
||||
revisions: []
|
||||
}
|
||||
|
||||
const queuedMessage = {
|
||||
public_id: 'message-15-v3',
|
||||
endpoint_id: endpoint.id,
|
||||
endpoint_name: endpoint.name,
|
||||
conversation_public_id: 'conversation-1',
|
||||
external_conversation_id: 'group-001',
|
||||
ticket_id: task.ticket_id,
|
||||
customer_reply_task_id: task.id,
|
||||
reply_revision: task.revision,
|
||||
direction: 'outbound',
|
||||
external_message_id: '',
|
||||
idempotency_key: 'customer-reply-15-v3-test',
|
||||
sender: '支持工程师',
|
||||
message_type: 'text',
|
||||
text: task.final_text,
|
||||
payload: {},
|
||||
status: 'queued',
|
||||
error_message: '',
|
||||
occurred_at: null,
|
||||
sent_at: null,
|
||||
delivered_at: null,
|
||||
created_at: '2026-07-27T08:04:00Z',
|
||||
updated_at: '2026-07-27T08:04:00Z'
|
||||
} satisfies ChannelMessage
|
||||
|
||||
describe('ChannelReplyDialog', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
|
||||
vi.mocked(sendChannelMessage).mockResolvedValue(queuedMessage)
|
||||
})
|
||||
|
||||
it('只提交冻结回复任务与当前版本,不接受任意文本', async () => {
|
||||
const onQueued = vi.fn().mockResolvedValue(undefined)
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<ChannelReplyDialog
|
||||
task={task}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
)
|
||||
|
||||
expect((await screen.findAllByText('客户服务群')).length).toBeGreaterThan(0)
|
||||
fireEvent.change(screen.getByLabelText('外部会话 ID'), {
|
||||
target: { value: 'group-001' }
|
||||
})
|
||||
const submit = screen.getByRole('button', { name: '冻结版本并发送' })
|
||||
await waitFor(() => expect(submit).toBeEnabled())
|
||||
fireEvent.click(submit)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendChannelMessage).toHaveBeenCalledWith(
|
||||
endpoint.id,
|
||||
expect.objectContaining({
|
||||
external_conversation_id: 'group-001',
|
||||
customer_reply_task_id: task.id,
|
||||
expected_revision: task.revision
|
||||
})
|
||||
)
|
||||
})
|
||||
const payload = vi.mocked(sendChannelMessage).mock.calls[0][1]
|
||||
expect(payload).not.toHaveProperty('text')
|
||||
expect(onQueued).toHaveBeenCalledWith(queuedMessage)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
198
frontend/src/features/channels/ChannelReplyDialog.tsx
Normal file
198
frontend/src/features/channels/ChannelReplyDialog.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from 'react'
|
||||
import { CircleAlert, Send } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import type { CustomerReplyTask } from '@/features/tickets/types'
|
||||
|
||||
import { listChannelEndpoints, sendChannelMessage } from './api'
|
||||
import type { ChannelEndpoint, ChannelMessage } from './types'
|
||||
|
||||
function stableHash(value: string) {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
export function ChannelReplyDialog({
|
||||
task,
|
||||
open,
|
||||
onOpenChange,
|
||||
onQueued
|
||||
}: {
|
||||
task: CustomerReplyTask | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onQueued: (message: ChannelMessage) => Promise<void> | void
|
||||
}) {
|
||||
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
|
||||
const [endpointId, setEndpointId] = React.useState('')
|
||||
const [conversationId, setConversationId] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [sending, setSending] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
const selectedEndpointId = endpointId || String(endpoints[0]?.id ?? '')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
listChannelEndpoints()
|
||||
.then((items) => {
|
||||
const enabled = items.filter((item) => item.enabled)
|
||||
setEndpoints(enabled)
|
||||
setEndpointId((current) => current || String(enabled[0]?.id ?? ''))
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setEndpoints([])
|
||||
setError(reason instanceof Error ? reason.message : '无法加载可用渠道')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!task) return
|
||||
const normalizedConversation = conversationId.trim()
|
||||
const numericEndpointId = Number(selectedEndpointId)
|
||||
if (!numericEndpointId || !normalizedConversation) return
|
||||
setSending(true)
|
||||
setError('')
|
||||
try {
|
||||
const message = await sendChannelMessage(numericEndpointId, {
|
||||
external_conversation_id: normalizedConversation,
|
||||
customer_reply_task_id: task.id,
|
||||
expected_revision: task.revision,
|
||||
idempotency_key: [
|
||||
'customer-reply',
|
||||
task.id,
|
||||
`v${task.revision}`,
|
||||
stableHash(`${numericEndpointId}:${normalizedConversation}`)
|
||||
].join('-')
|
||||
})
|
||||
await onQueued(message)
|
||||
toast.success('客户回复已进入渠道投递队列')
|
||||
setConversationId('')
|
||||
onOpenChange(false)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '客户回复未能进入渠道队列')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>通过渠道发送客户回复</DialogTitle>
|
||||
<DialogDescription>
|
||||
发送后将冻结当前回复版本;只有渠道送达回执会自动确认客户已收到。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{task ? (
|
||||
<form id="channel-reply-form" onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Alert>
|
||||
<Send />
|
||||
<AlertTitle>即将发送回复任务 #{task.id}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<Badge variant="outline" className="w-fit">冻结版本 v{task.revision}</Badge>
|
||||
<span className="line-clamp-4 whitespace-pre-wrap">{task.final_text}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<AppField label="发送渠道" required>
|
||||
<Select
|
||||
value={selectedEndpointId}
|
||||
disabled={loading || sending}
|
||||
onValueChange={setEndpointId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={loading ? '正在加载渠道' : '选择已启用渠道'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{endpoints.map((endpoint) => (
|
||||
<SelectItem key={endpoint.id} value={String(endpoint.id)}>
|
||||
{endpoint.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField
|
||||
label="外部会话 ID"
|
||||
htmlFor="channel-reply-conversation"
|
||||
required
|
||||
description="会话首次关联后不能改绑到其他工单。"
|
||||
>
|
||||
<Input
|
||||
id="channel-reply-conversation"
|
||||
aria-label="外部会话 ID"
|
||||
required
|
||||
autoFocus
|
||||
disabled={sending}
|
||||
value={conversationId}
|
||||
onChange={(event) => setConversationId(event.target.value)}
|
||||
placeholder="例如:group-20260727-001"
|
||||
/>
|
||||
</AppField>
|
||||
{!loading && endpoints.length === 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>没有可用渠道</AlertTitle>
|
||||
<AlertDescription>请先在“渠道接入”中创建并启用适配器。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>渠道发送未完成</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="channel-reply-form"
|
||||
disabled={!task || !selectedEndpointId || !conversationId.trim() || sending}
|
||||
>
|
||||
<Send data-icon="inline-start" />
|
||||
{sending ? '正在排队' : '冻结版本并发送'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
68
frontend/src/features/channels/api.ts
Normal file
68
frontend/src/features/channels/api.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
|
||||
import type { ChannelEndpoint, ChannelMessage } from './types'
|
||||
|
||||
export function listChannelEndpoints() {
|
||||
return apiRequest<ChannelEndpoint[]>('/channels/endpoints')
|
||||
}
|
||||
|
||||
export function createChannelEndpoint(payload: {
|
||||
name: string
|
||||
outbound_url: string
|
||||
webhook_secret: string
|
||||
outbound_token: string
|
||||
enabled: boolean
|
||||
}) {
|
||||
return apiRequest<ChannelEndpoint>('/channels/endpoints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, adapter_type: 'generic_webhook' })
|
||||
})
|
||||
}
|
||||
|
||||
export function updateChannelEndpoint(
|
||||
endpointId: number,
|
||||
payload: Partial<Pick<ChannelEndpoint, 'name' | 'outbound_url' | 'enabled'>> & {
|
||||
webhook_secret?: string
|
||||
outbound_token?: string
|
||||
}
|
||||
) {
|
||||
return apiRequest<ChannelEndpoint>(`/channels/endpoints/${endpointId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function listChannelMessages(filters?: {
|
||||
endpoint_id?: number
|
||||
direction?: string
|
||||
status?: string
|
||||
}) {
|
||||
const search = new URLSearchParams()
|
||||
if (filters?.endpoint_id) search.set('endpoint_id', String(filters.endpoint_id))
|
||||
if (filters?.direction) search.set('direction', filters.direction)
|
||||
if (filters?.status) search.set('status', filters.status)
|
||||
const suffix = search.size ? `?${search.toString()}` : ''
|
||||
return apiRequest<ChannelMessage[]>(`/channels/messages${suffix}`)
|
||||
}
|
||||
|
||||
export function sendChannelMessage(
|
||||
endpointId: number,
|
||||
payload: {
|
||||
external_conversation_id: string
|
||||
idempotency_key: string
|
||||
customer_reply_task_id: number
|
||||
expected_revision: number
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
) {
|
||||
return apiRequest<ChannelMessage>(`/channels/endpoints/${endpointId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function retryChannelMessage(publicId: string) {
|
||||
return apiRequest<ChannelMessage>(`/channels/messages/${publicId}/retry`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
40
frontend/src/features/channels/types.ts
Normal file
40
frontend/src/features/channels/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type ChannelEndpoint = {
|
||||
id: number
|
||||
public_id: string
|
||||
name: string
|
||||
adapter_type: 'generic_webhook'
|
||||
outbound_url: string
|
||||
webhook_secret_set: boolean
|
||||
outbound_token_set: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ChannelMessageDirection = 'inbound' | 'outbound'
|
||||
export type ChannelMessageStatus = 'received' | 'queued' | 'sent' | 'delivered' | 'failed'
|
||||
|
||||
export type ChannelMessage = {
|
||||
public_id: string
|
||||
endpoint_id: number
|
||||
endpoint_name: string
|
||||
conversation_public_id: string | null
|
||||
external_conversation_id: string
|
||||
ticket_id: number | null
|
||||
customer_reply_task_id: number | null
|
||||
reply_revision: number | null
|
||||
direction: ChannelMessageDirection
|
||||
external_message_id: string
|
||||
idempotency_key: string
|
||||
sender: string
|
||||
message_type: string
|
||||
text: string
|
||||
payload: Record<string, unknown>
|
||||
status: ChannelMessageStatus
|
||||
error_message: string
|
||||
occurred_at: string | null
|
||||
sent_at: string | null
|
||||
delivered_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
396
frontend/src/features/system/IdentityProviderDialog.tsx
Normal file
396
frontend/src/features/system/IdentityProviderDialog.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import * as React from 'react'
|
||||
import { CircleAlert, LoaderCircle, Save } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
import { createIdentityProvider, updateIdentityProvider } from './api'
|
||||
import type { IdentityProvider, IdentityProviderInput } from './types'
|
||||
|
||||
type ProviderForm = IdentityProviderInput & {
|
||||
slug: string
|
||||
provider_type: 'oidc' | 'ldap'
|
||||
server_uri: string
|
||||
base_dn: string
|
||||
bind_dn: string
|
||||
user_filter: string
|
||||
}
|
||||
|
||||
const emptyForm: ProviderForm = {
|
||||
slug: '',
|
||||
name: '',
|
||||
provider_type: 'oidc',
|
||||
enabled: false,
|
||||
button_label: '',
|
||||
issuer: '',
|
||||
metadata_url: '',
|
||||
client_id: '',
|
||||
credential: '',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
claim_mappings: {
|
||||
subject: 'sub',
|
||||
username: 'preferred_username',
|
||||
email: 'email',
|
||||
display_name: 'name',
|
||||
groups: 'groups'
|
||||
},
|
||||
configuration: {},
|
||||
auto_create_users: true,
|
||||
sync_profile: true,
|
||||
sync_groups: true,
|
||||
server_uri: '',
|
||||
base_dn: '',
|
||||
bind_dn: '',
|
||||
user_filter: ''
|
||||
}
|
||||
|
||||
type IdentityProviderDialogProps = {
|
||||
open: boolean
|
||||
provider: IdentityProvider | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => Promise<void>
|
||||
}
|
||||
|
||||
export function IdentityProviderDialog({
|
||||
open,
|
||||
provider,
|
||||
onOpenChange,
|
||||
onSaved
|
||||
}: IdentityProviderDialogProps) {
|
||||
const [form, setForm] = React.useState<ProviderForm>(emptyForm)
|
||||
const [pending, setPending] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
if (!provider) {
|
||||
setForm(emptyForm)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
setForm({
|
||||
...emptyForm,
|
||||
slug: provider.slug,
|
||||
name: provider.name,
|
||||
provider_type: provider.provider_type,
|
||||
enabled: provider.enabled,
|
||||
button_label: provider.button_label,
|
||||
issuer: provider.issuer,
|
||||
metadata_url: provider.metadata_url,
|
||||
client_id: provider.client_id,
|
||||
scopes: provider.scopes,
|
||||
claim_mappings: {
|
||||
...emptyForm.claim_mappings,
|
||||
...provider.claim_mappings
|
||||
},
|
||||
configuration: provider.configuration,
|
||||
auto_create_users: provider.auto_create_users,
|
||||
sync_profile: provider.sync_profile,
|
||||
sync_groups: provider.sync_groups,
|
||||
server_uri: String(provider.configuration.server_uri ?? ''),
|
||||
base_dn: String(provider.configuration.base_dn ?? ''),
|
||||
bind_dn: String(provider.configuration.bind_dn ?? ''),
|
||||
user_filter: String(provider.configuration.user_filter ?? '')
|
||||
})
|
||||
setError('')
|
||||
}, [open, provider])
|
||||
|
||||
function setClaim(key: string, value: string) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
claim_mappings: { ...current.claim_mappings, [key]: value }
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setPending(true)
|
||||
setError('')
|
||||
const configuration = form.provider_type === 'ldap'
|
||||
? {
|
||||
...form.configuration,
|
||||
server_uri: form.server_uri.trim(),
|
||||
base_dn: form.base_dn.trim(),
|
||||
bind_dn: form.bind_dn.trim(),
|
||||
user_filter: form.user_filter.trim()
|
||||
}
|
||||
: form.configuration
|
||||
const payload: IdentityProviderInput = {
|
||||
name: form.name.trim(),
|
||||
enabled: form.enabled,
|
||||
button_label: form.button_label.trim(),
|
||||
issuer: form.issuer.trim(),
|
||||
metadata_url: form.metadata_url.trim(),
|
||||
client_id: form.client_id.trim(),
|
||||
credential: form.credential?.trim() || undefined,
|
||||
scopes: form.scopes,
|
||||
claim_mappings: form.claim_mappings,
|
||||
configuration,
|
||||
auto_create_users: form.auto_create_users,
|
||||
sync_profile: form.sync_profile,
|
||||
sync_groups: form.sync_groups
|
||||
}
|
||||
try {
|
||||
if (provider) {
|
||||
await updateIdentityProvider(provider.id, payload)
|
||||
} else {
|
||||
await createIdentityProvider({
|
||||
...payload,
|
||||
slug: form.slug.trim(),
|
||||
provider_type: form.provider_type
|
||||
})
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSaved()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '身份提供方保存失败')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const oidc = form.provider_type === 'oidc'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="border-b bg-muted/35 px-6 py-5 pr-12">
|
||||
<DialogTitle>{provider ? '编辑身份提供方' : '新增身份提供方'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
密钥只在保存时提交,页面与审计日志不会返回明文。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="scrollbar-panel max-h-[68vh] overflow-y-auto px-6 py-5">
|
||||
<FieldGroup>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-name">名称</FieldLabel>
|
||||
<Input
|
||||
id="identity-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
placeholder="例如:公司统一身份"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field data-disabled={Boolean(provider)}>
|
||||
<FieldLabel htmlFor="identity-slug">稳定标识</FieldLabel>
|
||||
<Input
|
||||
id="identity-slug"
|
||||
value={form.slug}
|
||||
onChange={(event) => setForm({ ...form, slug: event.target.value })}
|
||||
placeholder="corp-identity"
|
||||
disabled={Boolean(provider)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-disabled={Boolean(provider)}>
|
||||
<FieldLabel>协议类型</FieldLabel>
|
||||
<Select
|
||||
value={form.provider_type}
|
||||
onValueChange={(value: 'oidc' | 'ldap') => setForm({ ...form, provider_type: value })}
|
||||
disabled={Boolean(provider)}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="oidc">OpenID Connect</SelectItem>
|
||||
<SelectItem value="ldap">LDAP 目录适配器</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>
|
||||
LDAP 首批提供配置与目录同步适配边界;浏览器单点登录仅由 OIDC 提供。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{oidc ? (
|
||||
<>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-issuer">Issuer</FieldLabel>
|
||||
<Input
|
||||
id="identity-issuer"
|
||||
type="url"
|
||||
value={form.issuer}
|
||||
onChange={(event) => setForm({ ...form, issuer: event.target.value })}
|
||||
placeholder="https://idp.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-metadata">发现文档地址</FieldLabel>
|
||||
<Input
|
||||
id="identity-metadata"
|
||||
type="url"
|
||||
value={form.metadata_url}
|
||||
onChange={(event) => setForm({ ...form, metadata_url: event.target.value })}
|
||||
placeholder="留空时由 Issuer 推导"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-client-id">Client ID</FieldLabel>
|
||||
<Input
|
||||
id="identity-client-id"
|
||||
value={form.client_id}
|
||||
onChange={(event) => setForm({ ...form, client_id: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-credential">
|
||||
Client secret {provider?.credential_set ? `(${provider.credential_masked})` : ''}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="identity-credential"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.credential}
|
||||
onChange={(event) => setForm({ ...form, credential: event.target.value })}
|
||||
placeholder={provider?.credential_set ? '留空保留原密钥' : '输入密钥'}
|
||||
required={!provider?.credential_set && form.enabled}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-button-label">登录按钮文案</FieldLabel>
|
||||
<Input
|
||||
id="identity-button-label"
|
||||
value={form.button_label}
|
||||
onChange={(event) => setForm({ ...form, button_label: event.target.value })}
|
||||
placeholder={`使用 ${form.name || '企业账号'} 登录`}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{[
|
||||
['subject', 'Subject 声明'],
|
||||
['username', '账号声明'],
|
||||
['email', '邮箱声明'],
|
||||
['display_name', '显示名称声明'],
|
||||
['groups', '组织声明']
|
||||
].map(([key, label]) => (
|
||||
<Field key={key}>
|
||||
<FieldLabel htmlFor={`identity-claim-${key}`}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={`identity-claim-${key}`}
|
||||
value={form.claim_mappings[key] ?? ''}
|
||||
onChange={(event) => setClaim(key, event.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-server-uri">LDAP 服务地址</FieldLabel>
|
||||
<Input
|
||||
id="identity-server-uri"
|
||||
value={form.server_uri}
|
||||
onChange={(event) => setForm({ ...form, server_uri: event.target.value })}
|
||||
placeholder="ldaps://directory.example.com"
|
||||
required={form.enabled}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-base-dn">Base DN</FieldLabel>
|
||||
<Input id="identity-base-dn" value={form.base_dn} onChange={(event) => setForm({ ...form, base_dn: event.target.value })} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-bind-dn">Bind DN</FieldLabel>
|
||||
<Input id="identity-bind-dn" value={form.bind_dn} onChange={(event) => setForm({ ...form, bind_dn: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-user-filter">用户过滤器</FieldLabel>
|
||||
<Input id="identity-user-filter" value={form.user_filter} onChange={(event) => setForm({ ...form, user_filter: event.target.value })} placeholder="(objectClass=person)" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identity-bind-password">
|
||||
Bind password {provider?.credential_set ? `(${provider.credential_masked})` : ''}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="identity-bind-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.credential}
|
||||
onChange={(event) => setForm({ ...form, credential: event.target.value })}
|
||||
placeholder={provider?.credential_set ? '留空保留原密码' : '支持匿名绑定时可留空'}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<div className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="identity-auto-create">自动创建本地账号</FieldLabel>
|
||||
<FieldDescription>首次识别到稳定外部身份时创建无本地密码的账号。</FieldDescription>
|
||||
</div>
|
||||
<Switch id="identity-auto-create" checked={form.auto_create_users} onCheckedChange={(checked) => setForm({ ...form, auto_create_users: checked })} />
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<div className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="identity-sync-profile">同步姓名与邮箱</FieldLabel>
|
||||
<FieldDescription>企业目录中的最新资料会覆盖对应本地字段。</FieldDescription>
|
||||
</div>
|
||||
<Switch id="identity-sync-profile" checked={form.sync_profile} onCheckedChange={(checked) => setForm({ ...form, sync_profile: checked })} />
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<div className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="identity-sync-groups">同步组织映射</FieldLabel>
|
||||
<FieldDescription>只处理本提供方已登记的外部组织映射。</FieldDescription>
|
||||
</div>
|
||||
<Switch id="identity-sync-groups" checked={form.sync_groups} onCheckedChange={(checked) => setForm({ ...form, sync_groups: checked })} />
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<div className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="identity-enabled">启用提供方</FieldLabel>
|
||||
<FieldDescription>{oidc ? '启用后会出现在登录页。' : '启用后允许目录适配器使用该配置。'}</FieldDescription>
|
||||
</div>
|
||||
<Switch id="identity-enabled" checked={form.enabled} onCheckedChange={(checked) => setForm({ ...form, enabled: checked })} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mt-5">
|
||||
<CircleAlert />
|
||||
<AlertTitle>保存未完成</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="m-0 rounded-none px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>取消</Button>
|
||||
<Button type="submit" disabled={pending || !form.name.trim() || !form.slug.trim()}>
|
||||
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
|
||||
{pending ? '保存中' : '保存提供方'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
107
frontend/src/features/system/SystemIdentityPage.test.tsx
Normal file
107
frontend/src/features/system/SystemIdentityPage.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SystemIdentityPage } from './SystemIdentityPage'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listIdentityProviders: vi.fn(),
|
||||
listUserGroups: vi.fn(),
|
||||
listEnterpriseGroupMappings: vi.fn(),
|
||||
createEnterpriseGroupMapping: vi.fn(),
|
||||
deleteEnterpriseGroupMapping: vi.fn(),
|
||||
syncEnterpriseDirectory: vi.fn(),
|
||||
updateIdentityProvider: vi.fn(),
|
||||
createIdentityProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
...mocks
|
||||
}))
|
||||
|
||||
const provider = {
|
||||
id: 1,
|
||||
slug: 'corp',
|
||||
name: '企业统一身份',
|
||||
provider_type: 'oidc' as const,
|
||||
enabled: true,
|
||||
button_label: '使用企业账号登录',
|
||||
issuer: 'https://idp.example.com',
|
||||
metadata_url: '',
|
||||
client_id: 'ticket-platform',
|
||||
credential_set: true,
|
||||
credential_masked: '****cret',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
claim_mappings: {},
|
||||
configuration: {},
|
||||
auto_create_users: true,
|
||||
sync_profile: true,
|
||||
sync_groups: true,
|
||||
identity_count: 12,
|
||||
group_mapping_count: 1,
|
||||
created_at: '2026-07-27T00:00:00Z',
|
||||
updated_at: '2026-07-27T00:00:00Z'
|
||||
}
|
||||
|
||||
describe('SystemIdentityPage', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.listIdentityProviders.mockResolvedValue([provider])
|
||||
mocks.listUserGroups.mockResolvedValue([
|
||||
{
|
||||
id: 10,
|
||||
name: '一线支持',
|
||||
code: 'support_l1',
|
||||
description: '',
|
||||
is_active: true
|
||||
}
|
||||
])
|
||||
mocks.listEnterpriseGroupMappings.mockResolvedValue([
|
||||
{
|
||||
id: 20,
|
||||
provider_id: 1,
|
||||
external_group: 'IT-Support',
|
||||
user_group_id: 10,
|
||||
user_group_name: '一线支持',
|
||||
user_group_code: 'support_l1',
|
||||
created_at: '2026-07-27T00:00:00Z'
|
||||
}
|
||||
])
|
||||
mocks.syncEnterpriseDirectory.mockResolvedValue({
|
||||
id: 30,
|
||||
provider_id: 1,
|
||||
status: 'completed',
|
||||
total_count: 1,
|
||||
created_count: 1,
|
||||
updated_count: 0,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
started_at: '2026-07-27T00:00:00Z',
|
||||
finished_at: '2026-07-27T00:00:01Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders provider status and controlled group mapping', async () => {
|
||||
render(<SystemIdentityPage />)
|
||||
|
||||
expect((await screen.findAllByText('企业统一身份')).length).toBeGreaterThan(0)
|
||||
expect(await screen.findByText('IT-Support')).toBeInTheDocument()
|
||||
expect(screen.getByText('12 个身份 · 1 个映射')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits normalized directory records and reports the run', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SystemIdentityPage />)
|
||||
await screen.findByText('IT-Support')
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /目录同步/ }))
|
||||
await user.click(await screen.findByRole('button', { name: '开始目录同步' }))
|
||||
|
||||
await waitFor(() => expect(mocks.syncEnterpriseDirectory).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.syncEnterpriseDirectory.mock.calls[0][0]).toBe(1)
|
||||
expect(mocks.syncEnterpriseDirectory.mock.calls[0][1][0].subject).toBe('employee-1001')
|
||||
expect(await screen.findByText(/同步运行 #30/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
432
frontend/src/features/system/SystemIdentityPage.tsx
Normal file
432
frontend/src/features/system/SystemIdentityPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import * as React from 'react'
|
||||
import { Building2, Link2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppPage } from '@/components/app/AppPage'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
import {
|
||||
createEnterpriseGroupMapping,
|
||||
deleteEnterpriseGroupMapping,
|
||||
listEnterpriseGroupMappings,
|
||||
listIdentityProviders,
|
||||
listUserGroups,
|
||||
syncEnterpriseDirectory,
|
||||
updateIdentityProvider
|
||||
} from './api'
|
||||
import { IdentityProviderDialog } from './IdentityProviderDialog'
|
||||
import type {
|
||||
DirectorySyncRun,
|
||||
EnterpriseGroupMapping,
|
||||
IdentityProvider,
|
||||
UserGroup
|
||||
} from './types'
|
||||
|
||||
const directorySample = JSON.stringify([
|
||||
{
|
||||
subject: 'employee-1001',
|
||||
username: 'zhangsan',
|
||||
email: 'zhangsan@example.com',
|
||||
display_name: '张三',
|
||||
active: true,
|
||||
groups: ['IT-Support']
|
||||
}
|
||||
], null, 2)
|
||||
|
||||
export function SystemIdentityPage() {
|
||||
const [providers, setProviders] = React.useState<IdentityProvider[]>([])
|
||||
const [userGroups, setUserGroups] = React.useState<UserGroup[]>([])
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null)
|
||||
const [mappings, setMappings] = React.useState<EnterpriseGroupMapping[]>([])
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [editingProvider, setEditingProvider] = React.useState<IdentityProvider | null>(null)
|
||||
const [externalGroup, setExternalGroup] = React.useState('')
|
||||
const [userGroupId, setUserGroupId] = React.useState('')
|
||||
const [directoryJson, setDirectoryJson] = React.useState(directorySample)
|
||||
const [syncRun, setSyncRun] = React.useState<DirectorySyncRun | null>(null)
|
||||
const [mappingToDelete, setMappingToDelete] = React.useState<EnterpriseGroupMapping | null>(null)
|
||||
const [pending, setPending] = React.useState<string | null>(null)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const selected = providers.find((provider) => provider.id === selectedId) ?? null
|
||||
|
||||
const loadProviders = React.useCallback(async () => {
|
||||
const next = await listIdentityProviders()
|
||||
setProviders(next)
|
||||
setSelectedId((current) => current && next.some((item) => item.id === current) ? current : next[0]?.id ?? null)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([loadProviders(), listUserGroups().then(setUserGroups)])
|
||||
.then(() => setError(''))
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : '身份治理配置加载失败'))
|
||||
}, [loadProviders])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setMappings([])
|
||||
return
|
||||
}
|
||||
void listEnterpriseGroupMappings(selectedId)
|
||||
.then(setMappings)
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : '组织映射加载失败'))
|
||||
}, [selectedId])
|
||||
|
||||
async function toggleProvider(provider: IdentityProvider, enabled: boolean) {
|
||||
setPending(`provider-${provider.id}`)
|
||||
try {
|
||||
await updateIdentityProvider(provider.id, { enabled })
|
||||
await loadProviders()
|
||||
toast.success(enabled ? '身份提供方已启用' : '身份提供方已停用')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '身份提供方状态更新失败')
|
||||
} finally {
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function addMapping(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!selected || !userGroupId) return
|
||||
setPending('mapping')
|
||||
try {
|
||||
await createEnterpriseGroupMapping(selected.id, externalGroup.trim(), Number(userGroupId))
|
||||
setExternalGroup('')
|
||||
setUserGroupId('')
|
||||
setMappings(await listEnterpriseGroupMappings(selected.id))
|
||||
await loadProviders()
|
||||
toast.success('组织映射已创建')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '组织映射创建失败')
|
||||
} finally {
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMapping(mapping: EnterpriseGroupMapping) {
|
||||
setPending(`mapping-${mapping.id}`)
|
||||
try {
|
||||
await deleteEnterpriseGroupMapping(mapping.id)
|
||||
setMappings(await listEnterpriseGroupMappings(mapping.provider_id))
|
||||
await loadProviders()
|
||||
setMappingToDelete(null)
|
||||
toast.success('组织映射已删除')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '组织映射删除失败')
|
||||
} finally {
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!selected) return
|
||||
setPending('sync')
|
||||
setSyncRun(null)
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(directoryJson)
|
||||
if (!Array.isArray(parsed)) throw new Error('目录数据必须是 JSON 数组')
|
||||
const run = await syncEnterpriseDirectory(selected.id, parsed)
|
||||
setSyncRun(run)
|
||||
await loadProviders()
|
||||
toast.success(run.failed_count ? '目录同步已完成,部分记录需处理' : '目录同步已完成')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '目录同步失败')
|
||||
} finally {
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const providerColumns: AppDataTableColumn<IdentityProvider>[] = [
|
||||
{
|
||||
key: 'provider',
|
||||
header: '身份提供方',
|
||||
cell: (provider) => (
|
||||
<div>
|
||||
<p className="font-medium">{provider.name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{provider.slug}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: '协议',
|
||||
cell: (provider) => <Badge variant="outline">{provider.provider_type === 'oidc' ? 'OIDC' : 'LDAP'}</Badge>
|
||||
},
|
||||
{
|
||||
key: 'counts',
|
||||
header: '同步范围',
|
||||
cell: (provider) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{provider.identity_count} 个身份 · {provider.group_mapping_count} 个映射
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: '状态',
|
||||
cell: (provider) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
disabled={pending === `provider-${provider.id}`}
|
||||
onCheckedChange={(checked) => void toggleProvider(provider, checked)}
|
||||
aria-label={`${provider.name}启用状态`}
|
||||
/>
|
||||
<Badge variant={provider.enabled ? 'success' : 'secondary'}>{provider.enabled ? '已启用' : '未启用'}</Badge>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'text-right',
|
||||
cell: (provider) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingProvider(provider)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil data-icon="inline-start" />
|
||||
编辑
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const mappingColumns: AppDataTableColumn<EnterpriseGroupMapping>[] = [
|
||||
{
|
||||
key: 'external',
|
||||
header: '企业组织标识',
|
||||
cell: (mapping) => <span className="font-mono text-sm">{mapping.external_group}</span>
|
||||
},
|
||||
{
|
||||
key: 'local',
|
||||
header: '平台用户组',
|
||||
cell: (mapping) => (
|
||||
<div>
|
||||
<p className="font-medium">{mapping.user_group_name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{mapping.user_group_code}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
header: '创建时间',
|
||||
cell: (mapping) => <span className="text-muted-foreground">{formatDateTime(mapping.created_at)}</span>
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'text-right',
|
||||
cell: (mapping) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`删除 ${mapping.external_group} 映射`}
|
||||
disabled={pending === `mapping-${mapping.id}`}
|
||||
onClick={() => setMappingToDelete(mapping)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
header={(
|
||||
<AppPageHeader
|
||||
title="企业身份"
|
||||
description="统一管理单点登录、外部身份绑定与企业组织映射。"
|
||||
actions={(
|
||||
<Button onClick={() => { setEditingProvider(null); setDialogOpen(true) }}>
|
||||
<Plus data-icon="inline-start" />
|
||||
新增提供方
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>操作未完成</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AppDataTable
|
||||
columns={providerColumns}
|
||||
rows={providers}
|
||||
getRowKey={(provider) => provider.id}
|
||||
emptyTitle="尚未配置企业身份"
|
||||
emptyDescription="新增 OIDC 提供方后,启用的企业登录入口会出现在登录页。"
|
||||
footer={<p className="text-sm text-muted-foreground">共 {providers.length} 个身份提供方</p>}
|
||||
/>
|
||||
|
||||
{selected ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Building2 />组织同步工作区</CardTitle>
|
||||
<CardDescription className="mt-1">选择提供方,维护受控映射或通过目录适配器导入成员。</CardDescription>
|
||||
</div>
|
||||
<Select value={String(selected.id)} onValueChange={(value) => setSelectedId(Number(value))}>
|
||||
<SelectTrigger className="w-full sm:w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{providers.map((provider) => <SelectItem key={provider.id} value={String(provider.id)}>{provider.name}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="mappings">
|
||||
<TabsList>
|
||||
<TabsTrigger value="mappings"><Link2 />组织映射</TabsTrigger>
|
||||
<TabsTrigger value="sync"><RefreshCw />目录同步</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mappings" className="mt-4 flex flex-col gap-4">
|
||||
<form onSubmit={addMapping}>
|
||||
<FieldGroup className="grid items-end gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="external-group">企业组织标识</FieldLabel>
|
||||
<Input id="external-group" value={externalGroup} onChange={(event) => setExternalGroup(event.target.value)} placeholder="例如:IT-Support" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>平台用户组</FieldLabel>
|
||||
<Select value={userGroupId} onValueChange={setUserGroupId}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="选择用户组" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{userGroups.filter((group) => group.is_active).map((group) => <SelectItem key={group.id} value={String(group.id)}>{group.name}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button type="submit" disabled={pending === 'mapping' || !externalGroup.trim() || !userGroupId}>
|
||||
<Plus data-icon="inline-start" />
|
||||
创建映射
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<AppDataTable
|
||||
columns={mappingColumns}
|
||||
rows={mappings}
|
||||
getRowKey={(mapping) => mapping.id}
|
||||
emptyTitle="暂无组织映射"
|
||||
emptyDescription="没有映射的企业组织不会改变平台中的用户组归属。"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sync" className="mt-4">
|
||||
<form className="flex flex-col gap-4" onSubmit={runSync}>
|
||||
<Alert>
|
||||
<ShieldCheck />
|
||||
<AlertTitle>目录适配器输入</AlertTitle>
|
||||
<AlertDescription>
|
||||
该入口接收已由可信连接器读取并标准化的成员记录;不会在浏览器中保存 LDAP 密码。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="directory-records">标准化成员 JSON</FieldLabel>
|
||||
<Textarea
|
||||
id="directory-records"
|
||||
className="min-h-64 font-mono text-xs"
|
||||
value={directoryJson}
|
||||
onChange={(event) => setDirectoryJson(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>单次最多 1000 条;同一 subject 重复同步只会更新原身份。</FieldDescription>
|
||||
</Field>
|
||||
<Button type="submit" className="self-end" disabled={pending === 'sync'}>
|
||||
<RefreshCw data-icon="inline-start" />
|
||||
{pending === 'sync' ? '同步中' : '开始目录同步'}
|
||||
</Button>
|
||||
{syncRun ? (
|
||||
<Alert variant={syncRun.failed_count ? 'destructive' : 'default'}>
|
||||
<AlertTitle>同步运行 #{syncRun.id} · {syncRun.status}</AlertTitle>
|
||||
<AlertDescription>
|
||||
共 {syncRun.total_count} 条,新建 {syncRun.created_count},更新 {syncRun.updated_count},失败 {syncRun.failed_count}。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : providers.length === 0 ? (
|
||||
<AppEmptyState title="等待身份提供方" description="配置提供方后即可维护组织映射与目录同步。" />
|
||||
) : null}
|
||||
|
||||
<IdentityProviderDialog
|
||||
open={dialogOpen}
|
||||
provider={editingProvider}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSaved={loadProviders}
|
||||
/>
|
||||
<AlertDialog
|
||||
open={Boolean(mappingToDelete)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pending?.startsWith('mapping-')) setMappingToDelete(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除组织映射?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后,企业组织“{mappingToDelete?.external_group}”将不再同步到平台用户组。已有用户不会在本次操作中被修改。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={Boolean(pending?.startsWith('mapping-'))}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={!mappingToDelete || Boolean(pending?.startsWith('mapping-'))}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (mappingToDelete) void removeMapping(mappingToDelete)
|
||||
}}
|
||||
>
|
||||
{pending?.startsWith('mapping-') ? '删除中' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
|
||||
import type { AuditLog, Permission, RoleDetail, SystemMenu, SystemUser, UserGroup } from './types'
|
||||
import type {
|
||||
AuditLog,
|
||||
DirectorySyncRun,
|
||||
EnterpriseGroupMapping,
|
||||
IdentityProvider,
|
||||
IdentityProviderInput,
|
||||
Permission,
|
||||
RoleDetail,
|
||||
SystemMenu,
|
||||
SystemUser,
|
||||
UserGroup
|
||||
} from './types'
|
||||
|
||||
export function listUsers() {
|
||||
return apiRequest<SystemUser[]>('/accounts/users')
|
||||
@@ -138,3 +149,55 @@ export function updateMenu(
|
||||
export function listAuditLogs() {
|
||||
return apiRequest<AuditLog[]>('/audit/operation-logs')
|
||||
}
|
||||
|
||||
export function listIdentityProviders() {
|
||||
return apiRequest<IdentityProvider[]>('/identity/providers')
|
||||
}
|
||||
|
||||
export function createIdentityProvider(payload: IdentityProviderInput & { slug: string; provider_type: 'oidc' | 'ldap' }) {
|
||||
return apiRequest<IdentityProvider>('/identity/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function updateIdentityProvider(providerId: number, payload: Partial<IdentityProviderInput>) {
|
||||
return apiRequest<IdentityProvider>(`/identity/providers/${providerId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function listEnterpriseGroupMappings(providerId: number) {
|
||||
return apiRequest<EnterpriseGroupMapping[]>(`/identity/providers/${providerId}/group-mappings`)
|
||||
}
|
||||
|
||||
export function createEnterpriseGroupMapping(providerId: number, externalGroup: string, userGroupId: number) {
|
||||
return apiRequest<EnterpriseGroupMapping>(`/identity/providers/${providerId}/group-mappings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ external_group: externalGroup, user_group_id: userGroupId })
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteEnterpriseGroupMapping(mappingId: number) {
|
||||
return apiRequest<{ status: string }>(`/identity/group-mappings/${mappingId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function syncEnterpriseDirectory(
|
||||
providerId: number,
|
||||
records: Array<{
|
||||
subject: string
|
||||
username?: string
|
||||
email?: string
|
||||
display_name?: string
|
||||
active?: boolean
|
||||
groups?: string[]
|
||||
}>
|
||||
) {
|
||||
return apiRequest<DirectorySyncRun>(`/identity/providers/${providerId}/directory-sync`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ records })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,3 +61,68 @@ export type AuditLog = {
|
||||
user_agent: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type IdentityProvider = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
provider_type: 'oidc' | 'ldap'
|
||||
enabled: boolean
|
||||
button_label: string
|
||||
issuer: string
|
||||
metadata_url: string
|
||||
client_id: string
|
||||
credential_set: boolean
|
||||
credential_masked: string
|
||||
scopes: string[]
|
||||
claim_mappings: Record<string, string>
|
||||
configuration: Record<string, unknown>
|
||||
auto_create_users: boolean
|
||||
sync_profile: boolean
|
||||
sync_groups: boolean
|
||||
identity_count: number
|
||||
group_mapping_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type IdentityProviderInput = {
|
||||
slug?: string
|
||||
name: string
|
||||
provider_type?: 'oidc' | 'ldap'
|
||||
enabled: boolean
|
||||
button_label: string
|
||||
issuer: string
|
||||
metadata_url: string
|
||||
client_id: string
|
||||
credential?: string
|
||||
scopes: string[]
|
||||
claim_mappings: Record<string, string>
|
||||
configuration: Record<string, unknown>
|
||||
auto_create_users: boolean
|
||||
sync_profile: boolean
|
||||
sync_groups: boolean
|
||||
}
|
||||
|
||||
export type EnterpriseGroupMapping = {
|
||||
id: number
|
||||
provider_id: number
|
||||
external_group: string
|
||||
user_group_id: number
|
||||
user_group_name: string
|
||||
user_group_code: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type DirectorySyncRun = {
|
||||
id: number
|
||||
provider_id: number
|
||||
status: 'completed' | 'partial' | 'failed'
|
||||
total_count: number
|
||||
created_count: number
|
||||
updated_count: number
|
||||
failed_count: number
|
||||
errors: Array<{ index: string; subject: string; error: string }>
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
|
||||
|
||||
export function apiUrl(path: string) {
|
||||
return `${API_BASE_URL}${path}`
|
||||
}
|
||||
|
||||
let csrfToken: string | null = null
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -29,7 +33,7 @@ async function readError(response: Response) {
|
||||
|
||||
async function getCsrfToken() {
|
||||
if (!csrfToken) {
|
||||
const response = await fetch(`${API_BASE_URL}/accounts/auth/csrf`, {
|
||||
const response = await fetch(apiUrl('/accounts/auth/csrf'), {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!response.ok) {
|
||||
@@ -51,7 +55,7 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
|
||||
headers.set('x-csrftoken', await getCsrfToken())
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers
|
||||
|
||||
Reference in New Issue
Block a user