feat: manage existing notification project secrets

This commit is contained in:
chenpei 2026-08-29 11:57:01 +08:00
parent f50b8db719
commit 9f1006c3f8
6 changed files with 362 additions and 11 deletions

View File

@ -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))
}
}

View File

@ -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(<NotificationCenterPanel showToast={showToast} />)
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(<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 () => {

View File

@ -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<string, string>
}
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<HTMLDivElement>(null)
const revealRequestRef = useRef(0)
const [activeView, setActiveView] = useState<PanelView>('projects')
const [projects, setProjects] = useState<NotificationProject[]>([])
const [projectsLoading, setProjectsLoading] = useState(true)
@ -271,6 +280,8 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
const [oneTimeSecret, setOneTimeSecret] = useState<OneTimeSecret | null>(null)
const [rotateProject, setRotateProject] = useState<NotificationProject | null>(null)
const [rotatingSecret, setRotatingSecret] = useState(false)
const [revealedSecret, setRevealedSecret] = useState<RevealedSecret | null>(null)
const [revealingSecretCode, setRevealingSecretCode] = useState<string | null>(null)
const [recipientProjectCode, setRecipientProjectCode] = useState('')
const [recipientRows, setRecipientRows] = useState<NotificationRecipient[]>([])
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<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 = () => {
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
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<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>
<button
type="button"
@ -783,15 +854,15 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
</div>
) : (
<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">
<span>项目</span><span>允许跳转域名</span><span>最近投递</span><span>花名</span><span>更新时间</span><span className="sr-only">操作</span>
<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>项目资料</span>
</div>
<div className="divide-y divide-[#e2e8f0] bg-white">
{projects.map((project) => (
<div
key={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="flex flex-wrap items-center gap-2">
@ -820,9 +891,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
<button
type="button"
onClick={() => openEditProject(project)}
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"
aria-label={`查看配置并编辑资料 ${project.name}`}
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" />
</button>
</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() }}>
<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><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>
</div>
<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>
<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 && <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 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>
@ -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>}
{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>
)
}

View File

@ -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' }] })

View File

@ -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({

View File

@ -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(
actor: NotificationCenterAdminActor,
code: string,