feat: manage existing notification project secrets
This commit is contained in:
parent
f50b8db719
commit
9f1006c3f8
@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
import { revealNotificationCenterProjectSecret } from '@/lib/notification-center-admin'
|
||||||
|
import {
|
||||||
|
requireNotificationCenterSuperAdmin,
|
||||||
|
toNotificationCenterAdminErrorResponse,
|
||||||
|
} from '../../../_shared'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const NO_STORE_HEADERS = {
|
||||||
|
'Cache-Control': 'no-store, max-age=0',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
}
|
||||||
|
|
||||||
|
function withNoStore(response: NextResponse) {
|
||||||
|
for (const [name, value] of Object.entries(NO_STORE_HEADERS)) {
|
||||||
|
response.headers.set(name, value)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { code: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await requireNotificationCenterSuperAdmin()
|
||||||
|
if ('response' in auth) return withNoStore(auth.response)
|
||||||
|
|
||||||
|
const code = String(params?.code || '').trim()
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '通知中心项目不存在' },
|
||||||
|
{ status: 404, headers: NO_STORE_HEADERS },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await revealNotificationCenterProjectSecret(auth.actor, code)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, data: result.data },
|
||||||
|
{ status: result.status, headers: NO_STORE_HEADERS },
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return withNoStore(toNotificationCenterAdminErrorResponse(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,6 +74,20 @@ describe('NotificationCenterPanel', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (url.includes('/reveal-secret') && init?.method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
projectCode: 'material_library',
|
||||||
|
secret: 'current-project-secret',
|
||||||
|
env: {
|
||||||
|
YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center',
|
||||||
|
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'material_library',
|
||||||
|
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
if (url.endsWith('/test') && init?.method === 'POST') {
|
if (url.endsWith('/test') && init?.method === 'POST') {
|
||||||
return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202)
|
return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202)
|
||||||
}
|
}
|
||||||
@ -134,12 +148,45 @@ describe('NotificationCenterPanel', () => {
|
|||||||
render(<NotificationCenterPanel showToast={showToast} />)
|
render(<NotificationCenterPanel showToast={showToast} />)
|
||||||
await screen.findByText('素材平台')
|
await screen.findByText('素材平台')
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '管理项目 素材平台' }))
|
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看当前密钥' }))
|
||||||
|
expect(await screen.findByText('current-project-secret')).toBeInTheDocument()
|
||||||
await user.click(screen.getByRole('button', { name: '轮换项目密钥' }))
|
await user.click(screen.getByRole('button', { name: '轮换项目密钥' }))
|
||||||
expect(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument()
|
expect(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument()
|
||||||
await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' }))
|
await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' }))
|
||||||
|
|
||||||
expect(await screen.findByText('rotated-one-time-secret')).toBeInTheDocument()
|
expect(await screen.findByText('rotated-one-time-secret')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '关闭' }))
|
||||||
|
expect(screen.getByRole('button', { name: '查看当前密钥' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('已接入项目可查看资料并按需查看、复制和隐藏当前密钥', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<NotificationCenterPanel showToast={showToast} />)
|
||||||
|
await screen.findByText('素材平台')
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })).toHaveTextContent('查看配置 / 编辑资料')
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: '查看配置 / 编辑资料' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('项目中文名称')).toHaveValue('素材平台')
|
||||||
|
expect(screen.getByLabelText('projectCode')).toHaveValue('material_library')
|
||||||
|
expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看当前密钥' }))
|
||||||
|
expect(await screen.findByText('current-project-secret')).toBeInTheDocument()
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/notification-center/projects/material_library/reveal-secret',
|
||||||
|
expect.objectContaining({ method: 'POST', cache: 'no-store' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '复制 素材平台 的当前项目密钥' }))
|
||||||
|
expect(await navigator.clipboard.readText()).toBe('current-project-secret')
|
||||||
|
expect(showToast).toHaveBeenCalledWith('项目密钥已复制', 'success')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '隐藏密钥' }))
|
||||||
|
expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('测试通知在确认前不发送,确认后才调用 mock 接口', async () => {
|
test('测试通知在确认前不发送,确认后才调用 mock 接口', async () => {
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@ -75,6 +77,12 @@ interface OneTimeSecret {
|
|||||||
reason: 'created' | 'rotated'
|
reason: 'created' | 'rotated'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RevealedSecret {
|
||||||
|
projectCode: string
|
||||||
|
secret: string
|
||||||
|
env: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
interface NotificationCenterPanelProps {
|
interface NotificationCenterPanelProps {
|
||||||
showToast: (message: string, type?: 'success' | 'error') => void
|
showToast: (message: string, type?: 'success' | 'error') => void
|
||||||
}
|
}
|
||||||
@ -259,6 +267,7 @@ function useDialogKeyboard(open: boolean, close: () => void) {
|
|||||||
|
|
||||||
export default function NotificationCenterPanel({ showToast }: NotificationCenterPanelProps) {
|
export default function NotificationCenterPanel({ showToast }: NotificationCenterPanelProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const revealRequestRef = useRef(0)
|
||||||
const [activeView, setActiveView] = useState<PanelView>('projects')
|
const [activeView, setActiveView] = useState<PanelView>('projects')
|
||||||
const [projects, setProjects] = useState<NotificationProject[]>([])
|
const [projects, setProjects] = useState<NotificationProject[]>([])
|
||||||
const [projectsLoading, setProjectsLoading] = useState(true)
|
const [projectsLoading, setProjectsLoading] = useState(true)
|
||||||
@ -271,6 +280,8 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
const [oneTimeSecret, setOneTimeSecret] = useState<OneTimeSecret | null>(null)
|
const [oneTimeSecret, setOneTimeSecret] = useState<OneTimeSecret | null>(null)
|
||||||
const [rotateProject, setRotateProject] = useState<NotificationProject | null>(null)
|
const [rotateProject, setRotateProject] = useState<NotificationProject | null>(null)
|
||||||
const [rotatingSecret, setRotatingSecret] = useState(false)
|
const [rotatingSecret, setRotatingSecret] = useState(false)
|
||||||
|
const [revealedSecret, setRevealedSecret] = useState<RevealedSecret | null>(null)
|
||||||
|
const [revealingSecretCode, setRevealingSecretCode] = useState<string | null>(null)
|
||||||
const [recipientProjectCode, setRecipientProjectCode] = useState('')
|
const [recipientProjectCode, setRecipientProjectCode] = useState('')
|
||||||
const [recipientRows, setRecipientRows] = useState<NotificationRecipient[]>([])
|
const [recipientRows, setRecipientRows] = useState<NotificationRecipient[]>([])
|
||||||
const [savingRecipients, setSavingRecipients] = useState(false)
|
const [savingRecipients, setSavingRecipients] = useState(false)
|
||||||
@ -332,6 +343,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
void loadProjects()
|
void loadProjects()
|
||||||
}, [loadProjects])
|
}, [loadProjects])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
revealRequestRef.current += 1
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView === 'deliveries') void loadDeliveries()
|
if (activeView === 'deliveries') void loadDeliveries()
|
||||||
}, [activeView, loadDeliveries])
|
}, [activeView, loadDeliveries])
|
||||||
@ -425,23 +440,39 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
)
|
)
|
||||||
|
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
|
revealRequestRef.current += 1
|
||||||
|
setRevealedSecret(null)
|
||||||
|
setRevealingSecretCode(null)
|
||||||
setDrawerOpen(false)
|
setDrawerOpen(false)
|
||||||
setEditingCode(null)
|
setEditingCode(null)
|
||||||
setProjectForm(EMPTY_PROJECT_FORM)
|
setProjectForm(EMPTY_PROJECT_FORM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changeView = (view: PanelView) => {
|
||||||
|
if (view !== 'projects') {
|
||||||
|
closeDrawer()
|
||||||
|
}
|
||||||
|
setActiveView(view)
|
||||||
|
}
|
||||||
|
|
||||||
useDialogKeyboard(drawerOpen, closeDrawer)
|
useDialogKeyboard(drawerOpen, closeDrawer)
|
||||||
useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null))
|
useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null))
|
||||||
useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null))
|
useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null))
|
||||||
useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false))
|
useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false))
|
||||||
|
|
||||||
const openCreateProject = () => {
|
const openCreateProject = () => {
|
||||||
|
revealRequestRef.current += 1
|
||||||
|
setRevealedSecret(null)
|
||||||
|
setRevealingSecretCode(null)
|
||||||
setEditingCode(null)
|
setEditingCode(null)
|
||||||
setProjectForm(EMPTY_PROJECT_FORM)
|
setProjectForm(EMPTY_PROJECT_FORM)
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditProject = (project: NotificationProject) => {
|
const openEditProject = (project: NotificationProject) => {
|
||||||
|
revealRequestRef.current += 1
|
||||||
|
setRevealedSecret(null)
|
||||||
|
setRevealingSecretCode(null)
|
||||||
setEditingCode(project.projectCode)
|
setEditingCode(project.projectCode)
|
||||||
setProjectForm({
|
setProjectForm({
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@ -454,6 +485,45 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revealProjectSecret = async (projectCode: string) => {
|
||||||
|
if (revealingSecretCode) return
|
||||||
|
const requestId = revealRequestRef.current + 1
|
||||||
|
revealRequestRef.current = requestId
|
||||||
|
setRevealingSecretCode(projectCode)
|
||||||
|
try {
|
||||||
|
const data = await readJsonResponse(
|
||||||
|
await fetch(
|
||||||
|
`/api/admin/notification-center/projects/${encodeURIComponent(projectCode)}/reveal-secret`,
|
||||||
|
{ method: 'POST', cache: 'no-store' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (revealRequestRef.current !== requestId) return
|
||||||
|
const payload = unwrapProxyData(data)
|
||||||
|
const secret = String(payload.secret || payload.projectSecret || '').trim()
|
||||||
|
if (!secret) throw new Error('通知中心没有返回当前项目密钥')
|
||||||
|
const env = payload.env && typeof payload.env === 'object'
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(payload.env as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => [key, String(value)]),
|
||||||
|
)
|
||||||
|
: {}
|
||||||
|
setRevealedSecret({ projectCode, secret, env })
|
||||||
|
} catch (error) {
|
||||||
|
if (revealRequestRef.current !== requestId) return
|
||||||
|
showToast(error instanceof Error ? error.message : '当前项目密钥读取失败', 'error')
|
||||||
|
} finally {
|
||||||
|
if (revealRequestRef.current === requestId) {
|
||||||
|
setRevealingSecretCode(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideProjectSecret = () => {
|
||||||
|
revealRequestRef.current += 1
|
||||||
|
setRevealedSecret(null)
|
||||||
|
setRevealingSecretCode(null)
|
||||||
|
}
|
||||||
|
|
||||||
const addProjectRecipient = () => {
|
const addProjectRecipient = () => {
|
||||||
setProjectForm((current) => ({
|
setProjectForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -552,6 +622,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
)
|
)
|
||||||
const responsePayload = unwrapProxyData(data)
|
const responsePayload = unwrapProxyData(data)
|
||||||
setRotateProject(null)
|
setRotateProject(null)
|
||||||
|
hideProjectSecret()
|
||||||
setOneTimeSecret({
|
setOneTimeSecret({
|
||||||
projectCode: rotateProject.projectCode,
|
projectCode: rotateProject.projectCode,
|
||||||
secret: String(responsePayload.secret || ''),
|
secret: String(responsePayload.secret || ''),
|
||||||
@ -718,7 +789,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
onClick={() => setActiveView(item.id)}
|
onClick={() => changeView(item.id)}
|
||||||
className={`min-h-[58px] rounded-[8px] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${
|
className={`min-h-[58px] rounded-[8px] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${
|
||||||
active
|
active
|
||||||
? 'bg-[#e6f4ff] text-[#0050cb]'
|
? 'bg-[#e6f4ff] text-[#0050cb]'
|
||||||
@ -756,7 +827,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="notification-projects-heading" className="text-[16px] font-semibold text-[#101828]">接入项目</h3>
|
<h3 id="notification-projects-heading" className="text-[16px] font-semibold text-[#101828]">接入项目</h3>
|
||||||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">新增项目后只会展示一次项目密钥,请立即保存到调用方环境变量。</p>
|
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">项目密钥默认隐藏,超级管理员可按需查看、复制;查看不会轮换或使旧密钥失效。</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -783,15 +854,15 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
|
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
|
||||||
<div className="hidden grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(170px,1fr)_110px_130px_44px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5 text-[12px] font-semibold text-[#66758b] md:grid">
|
<div className="hidden grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(150px,0.9fr)_80px_130px_168px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5 text-[12px] font-semibold text-[#66758b] md:grid">
|
||||||
<span>项目</span><span>允许跳转域名</span><span>最近投递</span><span>花名</span><span>更新时间</span><span className="sr-only">操作</span>
|
<span>项目</span><span>允许跳转域名</span><span>最近投递</span><span>花名</span><span>更新时间</span><span>项目资料</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-[#e2e8f0] bg-white">
|
<div className="divide-y divide-[#e2e8f0] bg-white">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<div
|
<div
|
||||||
key={project.projectCode}
|
key={project.projectCode}
|
||||||
data-project-code={project.projectCode}
|
data-project-code={project.projectCode}
|
||||||
className="grid min-w-0 gap-3 px-4 py-4 md:grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(170px,1fr)_110px_130px_44px] md:items-center"
|
className="grid min-w-0 gap-3 px-4 py-4 md:grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(150px,0.9fr)_80px_130px_168px] md:items-center"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@ -820,9 +891,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEditProject(project)}
|
onClick={() => openEditProject(project)}
|
||||||
aria-label={`管理项目 ${project.name}`}
|
aria-label={`查看配置并编辑资料 ${project.name}`}
|
||||||
className="inline-flex h-10 w-10 items-center justify-center justify-self-end rounded-[8px] border border-[#d9e0e7] text-[#4f6077] transition-colors hover:bg-[#f8fafc] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
className="inline-flex min-h-10 items-center justify-center gap-1.5 justify-self-start rounded-[8px] border border-[#d9e0e7] px-3 text-[13px] font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 md:justify-self-end"
|
||||||
>
|
>
|
||||||
|
查看配置 / 编辑资料
|
||||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -936,7 +1008,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
<div className="fixed inset-0 z-[70] flex justify-end bg-[#06192d]/35" onMouseDown={(event) => { if (event.currentTarget === event.target) closeDrawer() }}>
|
<div className="fixed inset-0 z-[70] flex justify-end bg-[#06192d]/35" onMouseDown={(event) => { if (event.currentTarget === event.target) closeDrawer() }}>
|
||||||
<section data-nc-drawer role="dialog" aria-modal="true" aria-labelledby="project-drawer-title" className="h-full w-full max-w-[560px] overflow-y-auto bg-white shadow-[0_18px_48px_rgba(6,25,45,0.2)]">
|
<section data-nc-drawer role="dialog" aria-modal="true" aria-labelledby="project-drawer-title" className="h-full w-full max-w-[560px] overflow-y-auto bg-white shadow-[0_18px_48px_rgba(6,25,45,0.2)]">
|
||||||
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-[#e2e8f0] bg-white px-5 py-4">
|
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-[#e2e8f0] bg-white px-5 py-4">
|
||||||
<div><h3 id="project-drawer-title" className="text-[18px] font-semibold text-[#101828]">{editingCode ? '管理接入项目' : '新增接入项目'}</h3><p className="mt-1 text-[12px] text-[#66758b]">{editingCode ? '修改名称、域名、状态和收件人方式。' : '创建后会生成只展示一次的项目密钥。'}</p></div>
|
<div><h3 id="project-drawer-title" className="text-[18px] font-semibold text-[#101828]">{editingCode ? '查看配置 / 编辑资料' : '新增接入项目'}</h3><p className="mt-1 text-[12px] text-[#66758b]">{editingCode ? '查看或修改名称、域名、状态和收件人方式。' : '创建后生成长期有效、默认隐藏的项目密钥。'}</p></div>
|
||||||
<button type="button" onClick={closeDrawer} aria-label="关闭项目编辑" className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#4f6077] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-5 w-5" aria-hidden="true" /></button>
|
<button type="button" onClick={closeDrawer} aria-label="关闭项目编辑" className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#4f6077] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-5 w-5" aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-5 px-5 py-5">
|
<div className="space-y-5 px-5 py-5">
|
||||||
@ -946,7 +1018,82 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
<div><label htmlFor="project-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">默认收件人方式</label><select id="project-recipient-mode" value={projectForm.recipientMode} onChange={(event) => setProjectForm((current) => ({ ...current, recipientMode: event.target.value as RecipientMode }))} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="userId">精确钉钉 userId</option><option value="alias">已登记花名</option></select></div>
|
<div><label htmlFor="project-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">默认收件人方式</label><select id="project-recipient-mode" value={projectForm.recipientMode} onChange={(event) => setProjectForm((current) => ({ ...current, recipientMode: event.target.value as RecipientMode }))} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="userId">精确钉钉 userId</option><option value="alias">已登记花名</option></select></div>
|
||||||
<label className="flex items-center gap-3 rounded-[8px] border border-[#e2e8f0] px-3 py-3 text-[13px] font-medium text-[#344054]"><input type="checkbox" checked={projectForm.isActive} onChange={(event) => setProjectForm((current) => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 rounded border-[#b8c2ce] text-[#1677ff] focus:ring-[#1677ff]" />保存后启用该项目</label>
|
<label className="flex items-center gap-3 rounded-[8px] border border-[#e2e8f0] px-3 py-3 text-[13px] font-medium text-[#344054]"><input type="checkbox" checked={projectForm.isActive} onChange={(event) => setProjectForm((current) => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 rounded border-[#b8c2ce] text-[#1677ff] focus:ring-[#1677ff]" />保存后启用该项目</label>
|
||||||
{!editingCode && projectForm.recipientMode === 'alias' && <div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">初始花名清单</span><button type="button" onClick={addProjectRecipient} className="inline-flex min-h-10 items-center gap-1 rounded-[8px] px-2 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />添加花名</button></div><div className="mt-2 space-y-2">{projectForm.recipients.map((recipient, index) => <div key={index} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2"><input aria-label={`第 ${index + 1} 条花名`} value={recipient.alias} onChange={(event) => updateProjectRecipient(index, 'alias', event.target.value)} placeholder="花名" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><input aria-label={`第 ${index + 1} 条钉钉 userId`} value={recipient.userId} onChange={(event) => updateProjectRecipient(index, 'userId', event.target.value)} placeholder="钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><button type="button" aria-label={`删除第 ${index + 1} 条花名`} onClick={() => setProjectForm((current) => ({ ...current, recipients: current.recipients.filter((_, rowIndex) => rowIndex !== index) }))} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button></div>)}</div></div>}
|
{!editingCode && projectForm.recipientMode === 'alias' && <div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">初始花名清单</span><button type="button" onClick={addProjectRecipient} className="inline-flex min-h-10 items-center gap-1 rounded-[8px] px-2 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />添加花名</button></div><div className="mt-2 space-y-2">{projectForm.recipients.map((recipient, index) => <div key={index} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2"><input aria-label={`第 ${index + 1} 条花名`} value={recipient.alias} onChange={(event) => updateProjectRecipient(index, 'alias', event.target.value)} placeholder="花名" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><input aria-label={`第 ${index + 1} 条钉钉 userId`} value={recipient.userId} onChange={(event) => updateProjectRecipient(index, 'userId', event.target.value)} placeholder="钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><button type="button" aria-label={`删除第 ${index + 1} 条花名`} onClick={() => setProjectForm((current) => ({ ...current, recipients: current.recipients.filter((_, rowIndex) => rowIndex !== index) }))} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button></div>)}</div></div>}
|
||||||
{editingCode && <div className="rounded-[8px] border border-[#e2e8f0] p-4"><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[13px] font-semibold text-[#344054]">项目密钥</p><p className="mt-1 text-[12px] leading-5 text-[#66758b]">现有密钥不会再次显示。需要更换时执行轮换。</p></div><button type="button" onClick={() => { const project = projects.find((item) => item.projectCode === editingCode); if (project) setRotateProject(project) }} className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><KeyRound className="h-4 w-4" aria-hidden="true" />轮换项目密钥</button></div></div>}
|
{editingCode && (
|
||||||
|
<div className="rounded-[8px] border border-[#e2e8f0] p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[13px] font-semibold text-[#344054]">当前项目密钥</p>
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-[#66758b]">
|
||||||
|
默认隐藏。查看只会读取当前密钥,不会轮换,也不会影响调用方继续使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{revealedSecret?.projectCode === editingCode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={hideProjectSecret}
|
||||||
|
className="inline-flex min-h-10 flex-none items-center justify-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
隐藏密钥
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void revealProjectSecret(editingCode)}
|
||||||
|
disabled={Boolean(revealingSecretCode)}
|
||||||
|
className="inline-flex min-h-10 flex-none items-center justify-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{revealingSecretCode === editingCode ? '读取中…' : '查看当前密钥'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex min-h-11 items-center gap-2 rounded-[8px] bg-[#f8fafc] px-3 py-2">
|
||||||
|
<code className="min-w-0 flex-1 break-all text-[12px] leading-5 text-[#344054]">
|
||||||
|
{revealedSecret?.projectCode === editingCode
|
||||||
|
? revealedSecret.secret
|
||||||
|
: '••••••••••••••••••••••••••••••••'}
|
||||||
|
</code>
|
||||||
|
{revealedSecret?.projectCode === editingCode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(revealedSecret.secret, '项目密钥')}
|
||||||
|
aria-label={`复制 ${projectForm.name} 的当前项目密钥`}
|
||||||
|
className="inline-flex h-10 w-10 flex-none items-center justify-center rounded-[8px] text-[#1677ff] hover:bg-[#e6f4ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||||
|
>
|
||||||
|
<Clipboard className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{revealedSecret?.projectCode === editingCode && Object.keys(revealedSecret.env).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(
|
||||||
|
Object.entries(revealedSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'),
|
||||||
|
'调用方环境变量',
|
||||||
|
)}
|
||||||
|
className="mt-2 inline-flex min-h-10 items-center gap-2 rounded-[8px] px-2 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||||
|
>
|
||||||
|
<Clipboard className="h-4 w-4" aria-hidden="true" />
|
||||||
|
复制完整环境变量
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 border-t border-[#e2e8f0] pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const project = projects.find((item) => item.projectCode === editingCode)
|
||||||
|
if (project) setRotateProject(project)
|
||||||
|
}}
|
||||||
|
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#f3c7c1] px-3 text-[13px] font-medium text-[#b42318] hover:bg-[#fef6f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" aria-hidden="true" />
|
||||||
|
轮换项目密钥
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-[11px] leading-5 text-[#8f2119]">轮换会使旧密钥立即失效,仅在确实需要更换时使用。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-[#e2e8f0] bg-white px-5 py-4"><button type="button" onClick={closeDrawer} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">取消</button><button type="button" onClick={() => void saveProject()} disabled={savingProject} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:opacity-50">{savingProject ? '保存中…' : editingCode ? '保存项目' : '创建并生成密钥'}</button></div>
|
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-[#e2e8f0] bg-white px-5 py-4"><button type="button" onClick={closeDrawer} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">取消</button><button type="button" onClick={() => void saveProject()} disabled={savingProject} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:opacity-50">{savingProject ? '保存中…' : editingCode ? '保存项目' : '创建并生成密钥'}</button></div>
|
||||||
</section>
|
</section>
|
||||||
@ -957,7 +1104,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
|
|||||||
|
|
||||||
{confirmTestOpen && resolvedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{resolvedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
|
{confirmTestOpen && resolvedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{resolvedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
|
||||||
|
|
||||||
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#b42318]">下面的项目密钥只完整展示这一次。关闭后无法再次查看,请现在复制并保存到调用方安全环境变量。</p><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">我已安全保存,关闭</button></div></section></div>}
|
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">这是当前有效的项目密钥。关闭后仍可在“查看配置 / 编辑资料”中按需查看;请只复制到调用方后端的安全环境变量。</p><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">关闭</button></div></section></div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
listNotificationCenterDeliveries,
|
listNotificationCenterDeliveries,
|
||||||
listNotificationCenterProjectRecipients,
|
listNotificationCenterProjectRecipients,
|
||||||
listNotificationCenterProjects,
|
listNotificationCenterProjects,
|
||||||
|
revealNotificationCenterProjectSecret,
|
||||||
rotateNotificationCenterProjectSecret,
|
rotateNotificationCenterProjectSecret,
|
||||||
sendNotificationCenterProjectTest,
|
sendNotificationCenterProjectTest,
|
||||||
updateNotificationCenterProject,
|
updateNotificationCenterProject,
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
import { GET as getProjectsRoute, POST as postProjectsRoute } from '@/app/api/admin/notification-center/projects/route'
|
import { GET as getProjectsRoute, POST as postProjectsRoute } from '@/app/api/admin/notification-center/projects/route'
|
||||||
import { GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route'
|
import { GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route'
|
||||||
import { POST as rotateProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/rotate-secret/route'
|
import { POST as rotateProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/rotate-secret/route'
|
||||||
|
import { POST as revealProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/reveal-secret/route'
|
||||||
import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-center/projects/[code]/recipients/route'
|
import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-center/projects/[code]/recipients/route'
|
||||||
import { POST as postProjectTestRoute } from '@/app/api/admin/notification-center/projects/[code]/test/route'
|
import { POST as postProjectTestRoute } from '@/app/api/admin/notification-center/projects/[code]/test/route'
|
||||||
import { GET as getDeliveriesRoute } from '@/app/api/admin/notification-center/deliveries/route'
|
import { GET as getDeliveriesRoute } from '@/app/api/admin/notification-center/deliveries/route'
|
||||||
@ -49,6 +51,7 @@ jest.mock('@/lib/notification-center-admin', () => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
listNotificationCenterProjects: jest.fn(),
|
listNotificationCenterProjects: jest.fn(),
|
||||||
|
revealNotificationCenterProjectSecret: jest.fn(),
|
||||||
createNotificationCenterProject: jest.fn(),
|
createNotificationCenterProject: jest.fn(),
|
||||||
getNotificationCenterProject: jest.fn(),
|
getNotificationCenterProject: jest.fn(),
|
||||||
updateNotificationCenterProject: jest.fn(),
|
updateNotificationCenterProject: jest.fn(),
|
||||||
@ -170,6 +173,40 @@ describe('notification-center admin routes', () => {
|
|||||||
expect(body.data.projectSecret).toBe('rotated-secret')
|
expect(body.data.projectSecret).toBe('rotated-secret')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /projects/[code]/reveal-secret 仅超管可读取当前密钥且禁止缓存', async () => {
|
||||||
|
;(revealNotificationCenterProjectSecret as jest.Mock).mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
projectCode: 'script_monitor',
|
||||||
|
secret: 'current-project-secret',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await revealProjectSecretRoute(
|
||||||
|
{} as any,
|
||||||
|
{ params: { code: 'script_monitor' } } as any,
|
||||||
|
)
|
||||||
|
const body = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.headers.get('cache-control')).toContain('no-store')
|
||||||
|
expect(response.headers.get('pragma')).toBe('no-cache')
|
||||||
|
expect(body.data.secret).toBe('current-project-secret')
|
||||||
|
expect(revealNotificationCenterProjectSecret).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ userId: 'admin-1' }),
|
||||||
|
'script_monitor',
|
||||||
|
)
|
||||||
|
|
||||||
|
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||||
|
const forbidden = await revealProjectSecretRoute(
|
||||||
|
{} as any,
|
||||||
|
{ params: { code: 'script_monitor' } } as any,
|
||||||
|
)
|
||||||
|
expect(forbidden.status).toBe(403)
|
||||||
|
expect(forbidden.headers.get('cache-control')).toContain('no-store')
|
||||||
|
expect(revealNotificationCenterProjectSecret).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /projects/[code]/recipients、GET /deliveries、GET /audits、POST /projects/[code]/test 可用', async () => {
|
test('GET /projects/[code]/recipients、GET /deliveries、GET /audits、POST /projects/[code]/test 可用', async () => {
|
||||||
;(listNotificationCenterProjectRecipients as jest.Mock).mockResolvedValue({ status: 200, data: [{ userId: 'ding-1' }] })
|
;(listNotificationCenterProjectRecipients as jest.Mock).mockResolvedValue({ status: 200, data: [{ userId: 'ding-1' }] })
|
||||||
;(listNotificationCenterDeliveries as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'delivery-1' }] })
|
;(listNotificationCenterDeliveries as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'delivery-1' }] })
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
listNotificationCenterProjectRecipients,
|
listNotificationCenterProjectRecipients,
|
||||||
listNotificationCenterProjects,
|
listNotificationCenterProjects,
|
||||||
NotificationCenterAdminError,
|
NotificationCenterAdminError,
|
||||||
|
revealNotificationCenterProjectSecret,
|
||||||
rotateNotificationCenterProjectSecret,
|
rotateNotificationCenterProjectSecret,
|
||||||
sendNotificationCenterProjectTest,
|
sendNotificationCenterProjectTest,
|
||||||
updateNotificationCenterProject,
|
updateNotificationCenterProject,
|
||||||
@ -104,6 +105,44 @@ describe('notification-center-admin client', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('查看当前密钥命中专属接口,保留 project secret 且剥离 admin secret', async () => {
|
||||||
|
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify({
|
||||||
|
projectCode: 'script_monitor',
|
||||||
|
secret: 'current-project-secret',
|
||||||
|
env: {
|
||||||
|
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
|
||||||
|
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
|
||||||
|
YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET: 'server-admin-secret',
|
||||||
|
},
|
||||||
|
project: { recipients: [{ userId: 'ding-private-user' }] },
|
||||||
|
adminSecret: 'server-admin-secret',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await revealNotificationCenterProjectSecret(actor, 'script_monitor')
|
||||||
|
|
||||||
|
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
|
||||||
|
'https://notify-admin.example.com/projects/script_monitor/reveal-secret',
|
||||||
|
)
|
||||||
|
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
projectCode: 'script_monitor',
|
||||||
|
secret: 'current-project-secret',
|
||||||
|
env: {
|
||||||
|
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
|
||||||
|
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(result.data)).not.toContain('server-admin-secret')
|
||||||
|
expect(JSON.stringify(result.data)).not.toContain('ding-private-user')
|
||||||
|
})
|
||||||
|
|
||||||
test('PATCH 项目更新与项目级 recipients/test 会命中嵌套路径', async () => {
|
test('PATCH 项目更新与项目级 recipients/test 会命中嵌套路径', async () => {
|
||||||
;(global.fetch as jest.Mock)
|
;(global.fetch as jest.Mock)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@ -384,6 +384,39 @@ export async function rotateNotificationCenterProjectSecret(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revealNotificationCenterProjectSecret(
|
||||||
|
actor: NotificationCenterAdminActor,
|
||||||
|
code: string,
|
||||||
|
) {
|
||||||
|
const projectCode = requireProjectCode(code)
|
||||||
|
const result = await requestNotificationCenterAdmin<Record<string, unknown>>({
|
||||||
|
method: 'POST',
|
||||||
|
path: buildProjectScopedPath(projectCode, '/reveal-secret'),
|
||||||
|
actor,
|
||||||
|
allowProjectSecrets: true,
|
||||||
|
})
|
||||||
|
const sourceEnv = result.data.env && typeof result.data.env === 'object' && !Array.isArray(result.data.env)
|
||||||
|
? result.data.env as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
[
|
||||||
|
'YYD_NOTIFICATION_CENTER_URL',
|
||||||
|
'YYD_NOTIFICATION_CENTER_PROJECT_CODE',
|
||||||
|
'YYD_NOTIFICATION_CENTER_PROJECT_SECRET',
|
||||||
|
]
|
||||||
|
.filter((key) => sourceEnv[key] != null)
|
||||||
|
.map((key) => [key, String(sourceEnv[key])]),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: {
|
||||||
|
projectCode: clean(result.data.projectCode) || projectCode,
|
||||||
|
secret: clean(result.data.secret ?? result.data.projectSecret) || '',
|
||||||
|
env,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listNotificationCenterProjectRecipients(
|
export async function listNotificationCenterProjectRecipients(
|
||||||
actor: NotificationCenterAdminActor,
|
actor: NotificationCenterAdminActor,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user