diff --git a/app/api/admin/notification-center/projects/[code]/reveal-secret/route.ts b/app/api/admin/notification-center/projects/[code]/reveal-secret/route.ts new file mode 100644 index 00000000..596d8c8b --- /dev/null +++ b/app/api/admin/notification-center/projects/[code]/reveal-secret/route.ts @@ -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)) + } +} diff --git a/components/admin/NotificationCenterPanel.test.tsx b/components/admin/NotificationCenterPanel.test.tsx index 65d87eea..eb7bb7d2 100644 --- a/components/admin/NotificationCenterPanel.test.tsx +++ b/components/admin/NotificationCenterPanel.test.tsx @@ -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') { return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202) } @@ -134,12 +148,45 @@ describe('NotificationCenterPanel', () => { render() 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: '轮换项目密钥' })) expect(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' })) 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() + 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 () => { diff --git a/components/admin/NotificationCenterPanel.tsx b/components/admin/NotificationCenterPanel.tsx index 243dbc30..c2df2b90 100644 --- a/components/admin/NotificationCenterPanel.tsx +++ b/components/admin/NotificationCenterPanel.tsx @@ -9,6 +9,8 @@ import { Check, ChevronRight, Clipboard, + Eye, + EyeOff, KeyRound, Plus, RefreshCw, @@ -75,6 +77,12 @@ interface OneTimeSecret { reason: 'created' | 'rotated' } +interface RevealedSecret { + projectCode: string + secret: string + env: Record +} + interface NotificationCenterPanelProps { showToast: (message: string, type?: 'success' | 'error') => void } @@ -259,6 +267,7 @@ function useDialogKeyboard(open: boolean, close: () => void) { export default function NotificationCenterPanel({ showToast }: NotificationCenterPanelProps) { const rootRef = useRef(null) + const revealRequestRef = useRef(0) const [activeView, setActiveView] = useState('projects') const [projects, setProjects] = useState([]) const [projectsLoading, setProjectsLoading] = useState(true) @@ -271,6 +280,8 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente const [oneTimeSecret, setOneTimeSecret] = useState(null) const [rotateProject, setRotateProject] = useState(null) const [rotatingSecret, setRotatingSecret] = useState(false) + const [revealedSecret, setRevealedSecret] = useState(null) + const [revealingSecretCode, setRevealingSecretCode] = useState(null) const [recipientProjectCode, setRecipientProjectCode] = useState('') const [recipientRows, setRecipientRows] = useState([]) const [savingRecipients, setSavingRecipients] = useState(false) @@ -332,6 +343,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente void loadProjects() }, [loadProjects]) + useEffect(() => () => { + revealRequestRef.current += 1 + }, []) + useEffect(() => { if (activeView === 'deliveries') void loadDeliveries() }, [activeView, loadDeliveries]) @@ -425,23 +440,39 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente ) const closeDrawer = () => { + revealRequestRef.current += 1 + setRevealedSecret(null) + setRevealingSecretCode(null) setDrawerOpen(false) setEditingCode(null) setProjectForm(EMPTY_PROJECT_FORM) } + const changeView = (view: PanelView) => { + if (view !== 'projects') { + closeDrawer() + } + setActiveView(view) + } + useDialogKeyboard(drawerOpen, closeDrawer) useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null)) useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null)) useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false)) const openCreateProject = () => { + revealRequestRef.current += 1 + setRevealedSecret(null) + setRevealingSecretCode(null) setEditingCode(null) setProjectForm(EMPTY_PROJECT_FORM) setDrawerOpen(true) } const openEditProject = (project: NotificationProject) => { + revealRequestRef.current += 1 + setRevealedSecret(null) + setRevealingSecretCode(null) setEditingCode(project.projectCode) setProjectForm({ name: project.name, @@ -454,6 +485,45 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente 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) + .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 = () => { setProjectForm((current) => ({ ...current, @@ -552,6 +622,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente ) const responsePayload = unwrapProxyData(data) setRotateProject(null) + hideProjectSecret() setOneTimeSecret({ projectCode: rotateProject.projectCode, secret: String(responsePayload.secret || ''), @@ -718,7 +789,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente key={item.id} type="button" 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] ${ active ? 'bg-[#e6f4ff] text-[#0050cb]' @@ -756,7 +827,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente

接入项目

-

新增项目后只会展示一次项目密钥,请立即保存到调用方环境变量。

+

项目密钥默认隐藏,超级管理员可按需查看、复制;查看不会轮换或使旧密钥失效。

@@ -936,7 +1008,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{ if (event.currentTarget === event.target) closeDrawer() }}>
-

{editingCode ? '管理接入项目' : '新增接入项目'}

{editingCode ? '修改名称、域名、状态和收件人方式。' : '创建后会生成只展示一次的项目密钥。'}

+

{editingCode ? '查看配置 / 编辑资料' : '新增接入项目'}

{editingCode ? '查看或修改名称、域名、状态和收件人方式。' : '创建后生成长期有效、默认隐藏的项目密钥。'}

@@ -946,7 +1018,82 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{!editingCode && projectForm.recipientMode === 'alias' &&
初始花名清单
{projectForm.recipients.map((recipient, index) =>
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" /> 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" />
)}
} - {editingCode &&

项目密钥

现有密钥不会再次显示。需要更换时执行轮换。

} + {editingCode && ( +
+
+
+

当前项目密钥

+

+ 默认隐藏。查看只会读取当前密钥,不会轮换,也不会影响调用方继续使用。 +

+
+ {revealedSecret?.projectCode === editingCode ? ( + + ) : ( + + )} +
+
+ + {revealedSecret?.projectCode === editingCode + ? revealedSecret.secret + : '••••••••••••••••••••••••••••••••'} + + {revealedSecret?.projectCode === editingCode && ( + + )} +
+ {revealedSecret?.projectCode === editingCode && Object.keys(revealedSecret.env).length > 0 && ( + + )} +
+ +

轮换会使旧密钥立即失效,仅在确实需要更换时使用。

+
+
+ )}
@@ -957,7 +1104,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente {confirmTestOpen && resolvedTestRecipient && selectedTestProject &&

确认发送真实测试通知

项目
{selectedTestProject.name} · {selectedTestProject.projectCode}
真实收件人
{resolvedTestRecipient.label}
标题
{testTitle}

确认后会真实调用钉钉,不是页面预览。

} - {oneTimeSecret &&

{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}

下面的项目密钥只完整展示这一次。关闭后无法再次查看,请现在复制并保存到调用方安全环境变量。

项目密钥
{oneTimeSecret.secret}
调用方环境变量
{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}
} + {oneTimeSecret &&

{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}

这是当前有效的项目密钥。关闭后仍可在“查看配置 / 编辑资料”中按需查看;请只复制到调用方后端的安全环境变量。

项目密钥
{oneTimeSecret.secret}
调用方环境变量
{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}
}
) } diff --git a/lib/__tests__/notification-center-admin-routes.test.ts b/lib/__tests__/notification-center-admin-routes.test.ts index 4eccee39..53fb8b83 100644 --- a/lib/__tests__/notification-center-admin-routes.test.ts +++ b/lib/__tests__/notification-center-admin-routes.test.ts @@ -10,6 +10,7 @@ import { listNotificationCenterDeliveries, listNotificationCenterProjectRecipients, listNotificationCenterProjects, + revealNotificationCenterProjectSecret, rotateNotificationCenterProjectSecret, sendNotificationCenterProjectTest, updateNotificationCenterProject, @@ -18,6 +19,7 @@ import { 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 { 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 { 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' @@ -49,6 +51,7 @@ jest.mock('@/lib/notification-center-admin', () => ({ } }, listNotificationCenterProjects: jest.fn(), + revealNotificationCenterProjectSecret: jest.fn(), createNotificationCenterProject: jest.fn(), getNotificationCenterProject: jest.fn(), updateNotificationCenterProject: jest.fn(), @@ -170,6 +173,40 @@ describe('notification-center admin routes', () => { 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 () => { ;(listNotificationCenterProjectRecipients as jest.Mock).mockResolvedValue({ status: 200, data: [{ userId: 'ding-1' }] }) ;(listNotificationCenterDeliveries as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'delivery-1' }] }) diff --git a/lib/__tests__/notification-center-admin.test.ts b/lib/__tests__/notification-center-admin.test.ts index 8e24db8b..67201a0c 100644 --- a/lib/__tests__/notification-center-admin.test.ts +++ b/lib/__tests__/notification-center-admin.test.ts @@ -5,6 +5,7 @@ import { listNotificationCenterProjectRecipients, listNotificationCenterProjects, NotificationCenterAdminError, + revealNotificationCenterProjectSecret, rotateNotificationCenterProjectSecret, sendNotificationCenterProjectTest, 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 () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ diff --git a/lib/notification-center-admin.ts b/lib/notification-center-admin.ts index 2c1683af..23d40c65 100644 --- a/lib/notification-center-admin.ts +++ b/lib/notification-center-admin.ts @@ -384,6 +384,39 @@ export async function rotateNotificationCenterProjectSecret( }) } +export async function revealNotificationCenterProjectSecret( + actor: NotificationCenterAdminActor, + code: string, +) { + const projectCode = requireProjectCode(code) + const result = await requestNotificationCenterAdmin>({ + 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 + : {} + 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( actor: NotificationCenterAdminActor, code: string,