From 9c67691876681ab6f5ba4ede778ef169ececb856 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 11:38:03 +0000 Subject: [PATCH 1/3] fix(projects): show a regenerated webhook secret again (SEC-H3) The v3.1 merge dropped the reveal state from the tabbed project page, so the secret the server returns once on regeneration was never shown and was lost. Restore it, keyed by project, and add a page test that fails if the card stops receiving it or shows it on another project. Signed-off-by: Sabry Dawood --- .../Projects/ProjectDetailsPage.test.tsx | 115 ++++++++++++++++++ src/pages/Projects/ProjectDetailsPage.tsx | 10 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/pages/Projects/ProjectDetailsPage.test.tsx diff --git a/src/pages/Projects/ProjectDetailsPage.test.tsx b/src/pages/Projects/ProjectDetailsPage.test.tsx new file mode 100644 index 0000000..8ad4aca --- /dev/null +++ b/src/pages/Projects/ProjectDetailsPage.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// SEC-H3 regression guard: the webhook secret is write-only, so the value the +// server returns on regeneration is the only copy. The page must hand it to +// the webhook card, and only for the project it was generated for. + +const state = vi.hoisted(() => ({ + projectId: 1, + mutate: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ id: String(state.projectId) }), +})); + +vi.mock("@/hooks/useDateFormatter", () => ({ + useDateFormatter: () => ({ formatDateTime: (v: string) => v }), +})); + +vi.mock("@/contexts/RoleContext", () => ({ + useRole: () => ({ isViewer: false, canManageProjects: false }), +})); + +vi.mock("@/contexts/ToastContext", () => ({ + useToast: () => ({ showSuccess: vi.fn(), showError: vi.fn() }), +})); + +vi.mock("@/hooks/useDeployments", () => ({ + useDeployments: () => ({ data: [] }), +})); + +vi.mock("@/hooks/useProjects", () => ({ + useProject: (id: number) => ({ + data: { Id: id, Name: `project-${id}`, IsActive: true, Config: {} }, + isLoading: false, + error: null, + refetch: vi.fn(), + }), + useProjectStatistics: () => ({ data: null }), + useUpdateProject: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteProject: () => ({ mutate: vi.fn(), isPending: false }), + useDeployProject: () => ({ mutate: vi.fn(), isPending: false }), + useRegenerateWebhook: () => ({ mutate: state.mutate, isPending: false }), +})); + +vi.mock("@/components/Projects/DeploymentModal", () => ({ DeploymentModal: () => null })); +vi.mock("@/components/Projects/ProjectFormModal", () => ({ ProjectFormModal: () => null })); +vi.mock("@/components/Projects/SshKeyManagement", () => ({ SshKeyManagement: () => null })); +vi.mock("./components/EnvironmentVariablesCard", () => ({ default: () => null })); +vi.mock("./components/ProjectNotificationsCard", () => ({ default: () => null })); + +vi.mock("./components", () => ({ + ProjectHeader: () => null, + ProjectInfoCard: () => null, + ProjectConfigCard: () => null, + ProjectPipelineCard: () => null, + ProjectStatsCard: () => null, + ProjectDeploymentsTable: () => null, + ProjectMembersCard: () => null, + ProjectWebhookCard: (props: { revealedSecret?: string | null; onRegenerateWebhook: () => void }) => ( +
+ + {props.revealedSecret ?? "none"} +
+ ), +})); + +import { ProjectDetailsPage } from "./ProjectDetailsPage"; + +const openAccessTab = () => { + fireEvent.click(screen.getByRole("tab", { name: /Access/i })); +}; + +describe("ProjectDetailsPage webhook secret (SEC-H3)", () => { + beforeEach(() => { + state.projectId = 1; + state.mutate.mockReset(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("passes the regenerated secret to the webhook card", () => { + state.mutate.mockImplementation((_id: number, opts: { onSuccess: (s: string) => void }) => + opts.onSuccess("whsec_new_value") + ); + render(); + openAccessTab(); + expect(screen.getByTestId("revealed")).toHaveTextContent("none"); + + fireEvent.click(screen.getByText("regenerate")); + + expect(state.mutate).toHaveBeenCalledWith(1, expect.anything()); + expect(screen.getByTestId("revealed")).toHaveTextContent("whsec_new_value"); + }); + + it("does not show one project's secret on another project's page", () => { + state.mutate.mockImplementation((_id: number, opts: { onSuccess: (s: string) => void }) => + opts.onSuccess("whsec_project_one") + ); + const { rerender } = render(); + openAccessTab(); + fireEvent.click(screen.getByText("regenerate")); + expect(screen.getByTestId("revealed")).toHaveTextContent("whsec_project_one"); + + state.projectId = 2; + rerender(); + + expect(screen.getByTestId("revealed")).toHaveTextContent("none"); + }); +}); diff --git a/src/pages/Projects/ProjectDetailsPage.tsx b/src/pages/Projects/ProjectDetailsPage.tsx index c52623d..62148cc 100644 --- a/src/pages/Projects/ProjectDetailsPage.tsx +++ b/src/pages/Projects/ProjectDetailsPage.tsx @@ -104,6 +104,10 @@ export const ProjectDetailsPage: React.FC = () => { const deleteProject = useDeleteProject(); const deployProject = useDeployProject(); const regenerateWebhook = useRegenerateWebhook(); + const [RevealedWebhook, setRevealedWebhook] = useState<{ ProjectId: number; Secret: string } | null>(null); + const RevealedWebhookSecret = + RevealedWebhook && project && RevealedWebhook.ProjectId === project.Id ? RevealedWebhook.Secret : null; + // Local UI state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deployDialogOpen, setDeployDialogOpen] = useState(false); @@ -179,7 +183,10 @@ export const ProjectDetailsPage: React.FC = () => { const projectId = project.Id; regenerateWebhook.mutate(projectId, { - onSuccess: () => { + onSuccess: (secret) => { + // Write-only: this is the only time the new secret is available. + // Keyed by project so another project's page never shows it. + setRevealedWebhook(secret ? { ProjectId: projectId, Secret: secret } : null); showSuccess(t("projects.webhookRegenerated")); }, onError: (error: Error) => { @@ -313,6 +320,7 @@ export const ProjectDetailsPage: React.FC = () => { onRegenerateWebhook={handleRegenerateWebhook} onCopyToClipboard={copyToClipboard} regeneratingWebhook={regenerateWebhook.isPending} + revealedSecret={RevealedWebhookSecret} /> From 4112f61a460761ac4f9a68fce20b1ca923bb70f5 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 11:41:58 +0000 Subject: [PATCH 2/3] fix(lint): clear the eslint errors that came with the v3.1 merge Type aliases take the T prefix (TStatus, TDeliveryConfigInput, TProviderConfigInput, TProjectTemplateCategory), and the notification card's server merge keeps its setState in the effect with a reasoned exception, as the project form already does. Signed-off-by: Sabry Dawood --- src/components/Settings/NotificationChannelsTab.tsx | 6 +++--- src/components/Settings/NotificationProvidersTab.tsx | 6 +++--- src/pages/Deployments/components/CopyLogButton.tsx | 4 ++-- .../Projects/components/ProjectNotificationsCard.tsx | 1 + .../Projects/components/ProjectTemplateWizard.tsx | 6 +++--- src/services/notificationChannelService.ts | 6 +++--- src/services/notificationProviderService.ts | 6 +++--- src/services/projectTemplateService.ts | 10 +++++----- 8 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/components/Settings/NotificationChannelsTab.tsx b/src/components/Settings/NotificationChannelsTab.tsx index 012113e..5ef1ff0 100644 --- a/src/components/Settings/NotificationChannelsTab.tsx +++ b/src/components/Settings/NotificationChannelsTab.tsx @@ -17,7 +17,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { NotificationChannelService, type IChannelListItem, - type IDeliveryConfigInput, + type TDeliveryConfigInput, } from '@/services/notificationChannelService'; import { NotificationProviderService, type IProviderListItem } from '@/services/notificationProviderService'; @@ -66,7 +66,7 @@ export const NotificationChannelsTab: React.FC<{ t: (key: string) => string }> = const selectedProvider = dialog.ProviderId === '' ? undefined : providerById.get(dialog.ProviderId); - const buildDelivery = (): IDeliveryConfigInput | null => { + const buildDelivery = (): TDeliveryConfigInput | null => { if (!selectedProvider) return null; if (selectedProvider.Type === 'discord') { return dialog.discordOverride @@ -98,7 +98,7 @@ export const NotificationChannelsTab: React.FC<{ t: (key: string) => string }> = const updateMut = useMutation({ mutationFn: () => { if (!dialog.editingId) throw new Error('Missing id'); - const payload: { Name?: string; DeliveryConfig?: IDeliveryConfigInput; IsActive?: boolean } = { + const payload: { Name?: string; DeliveryConfig?: TDeliveryConfigInput; IsActive?: boolean } = { Name: dialog.Name, IsActive: dialog.IsActive, }; const delivery = buildDelivery(); diff --git a/src/components/Settings/NotificationProvidersTab.tsx b/src/components/Settings/NotificationProvidersTab.tsx index 56d4c62..1fe0333 100644 --- a/src/components/Settings/NotificationProvidersTab.tsx +++ b/src/components/Settings/NotificationProvidersTab.tsx @@ -19,7 +19,7 @@ import { NotificationProviderService, type IProviderListItem, type TProviderType, - type IProviderConfigInput, + type TProviderConfigInput, } from '@/services/notificationProviderService'; import SmtpPresetPicker, { SMTP_PRESETS, type TSmtpPreset } from './SmtpPresetPicker'; @@ -78,7 +78,7 @@ export const NotificationProvidersTab: React.FC<{ t: (key: string) => string }> const [error, setError] = useState(null); const refetch = () => queryClient.invalidateQueries({ queryKey }); - const buildConfig = (): IProviderConfigInput => { + const buildConfig = (): TProviderConfigInput => { if (dialog.Type === 'discord') return { webhookRoot: dialog.webhookRoot }; if (dialog.Type === 'slack') return { webhookUrl: dialog.webhookUrl || undefined, botToken: dialog.botToken || undefined }; return { @@ -100,7 +100,7 @@ export const NotificationProvidersTab: React.FC<{ t: (key: string) => string }> const updateMut = useMutation({ mutationFn: () => { if (!dialog.editingId) throw new Error('Missing id'); - const payload: { Name?: string; Config?: IProviderConfigInput; IsActive?: boolean } = { + const payload: { Name?: string; Config?: TProviderConfigInput; IsActive?: boolean } = { Name: dialog.Name, IsActive: dialog.IsActive, }; diff --git a/src/pages/Deployments/components/CopyLogButton.tsx b/src/pages/Deployments/components/CopyLogButton.tsx index f12cc54..72cc3ef 100644 --- a/src/pages/Deployments/components/CopyLogButton.tsx +++ b/src/pages/Deployments/components/CopyLogButton.tsx @@ -20,7 +20,7 @@ interface IProps { resetMs?: number; } -type Status = 'idle' | 'success' | 'error'; +type TStatus = 'idle' | 'success' | 'error'; const fallbackCopy = (value: string): boolean => { // Last-resort fallback for non-secure-context browsers. @@ -39,7 +39,7 @@ const fallbackCopy = (value: string): boolean => { }; export const CopyLogButton: React.FC = ({ text, size = 'small', resetMs = 1500 }) => { - const [status, setStatus] = useState('idle'); + const [status, setStatus] = useState('idle'); const handleClick = async () => { let ok = false; diff --git a/src/pages/Projects/components/ProjectNotificationsCard.tsx b/src/pages/Projects/components/ProjectNotificationsCard.tsx index e83e5f4..abb419c 100644 --- a/src/pages/Projects/components/ProjectNotificationsCard.tsx +++ b/src/pages/Projects/components/ProjectNotificationsCard.tsx @@ -58,6 +58,7 @@ export const ProjectNotificationsCard: React.FC = ({ projectId }) => { if (seededRef.current) { // Merge: for non-dirty rows, take server values; for dirty rows, keep // the user's in-progress edits intact. + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs server data into local edit state without dropping dirty rows; see the note above setRows((prev) => { const next = new Map(prev); for (const c of channels) { diff --git a/src/pages/Projects/components/ProjectTemplateWizard.tsx b/src/pages/Projects/components/ProjectTemplateWizard.tsx index 23eefc5..3d2e354 100644 --- a/src/pages/Projects/components/ProjectTemplateWizard.tsx +++ b/src/pages/Projects/components/ProjectTemplateWizard.tsx @@ -33,7 +33,7 @@ import { useQuery } from '@tanstack/react-query'; import { ProjectTemplateService, type IProjectTemplate, - type EProjectTemplateCategory, + type TProjectTemplateCategory, } from '@/services/projectTemplateService'; interface IProps { @@ -41,7 +41,7 @@ interface IProps { onClose: (chosen: IProjectTemplate | null | 'cancel') => void; } -const CATEGORY_LABELS: Record = { +const CATEGORY_LABELS: Record = { backend: 'Backend', frontend: 'Frontend', static: 'Static', @@ -59,7 +59,7 @@ export const ProjectTemplateWizard: React.FC = ({ open, onClose }) => { }); const grouped = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const cat of ['backend', 'frontend', 'static', 'other'] as const) { map.set(cat, []); } diff --git a/src/services/notificationChannelService.ts b/src/services/notificationChannelService.ts index 110af53..180db1c 100644 --- a/src/services/notificationChannelService.ts +++ b/src/services/notificationChannelService.ts @@ -19,17 +19,17 @@ export interface IChannelListItem { export interface IDiscordDelivery { webhookSuffix?: string; overrideWebhook?: string; } export interface ISlackDelivery { channel: string; } export interface IEmailDelivery { recipients: string[]; } -export type IDeliveryConfigInput = IDiscordDelivery | ISlackDelivery | IEmailDelivery; +export type TDeliveryConfigInput = IDiscordDelivery | ISlackDelivery | IEmailDelivery; export interface IChannelCreate { ProviderId: number; Name: string; - DeliveryConfig: IDeliveryConfigInput; + DeliveryConfig: TDeliveryConfigInput; } export interface IChannelUpdate { Name?: string; - DeliveryConfig?: IDeliveryConfigInput; + DeliveryConfig?: TDeliveryConfigInput; IsActive?: boolean; } diff --git a/src/services/notificationProviderService.ts b/src/services/notificationProviderService.ts index 53ffb70..ff72a9b 100644 --- a/src/services/notificationProviderService.ts +++ b/src/services/notificationProviderService.ts @@ -29,17 +29,17 @@ export interface IEmailConfigInput { presetName?: 'gmail' | 'sendgrid' | 'mailgun' | 'custom'; } -export type IProviderConfigInput = IDiscordConfigInput | ISlackConfigInput | IEmailConfigInput; +export type TProviderConfigInput = IDiscordConfigInput | ISlackConfigInput | IEmailConfigInput; export interface IProviderCreate { Name: string; Type: TProviderType; - Config: IProviderConfigInput; + Config: TProviderConfigInput; } export interface IProviderUpdate { Name?: string; - Config?: IProviderConfigInput; + Config?: TProviderConfigInput; IsActive?: boolean; } diff --git a/src/services/projectTemplateService.ts b/src/services/projectTemplateService.ts index 5327d9f..750eb67 100644 --- a/src/services/projectTemplateService.ts +++ b/src/services/projectTemplateService.ts @@ -7,14 +7,14 @@ import ApiInstance from './api'; -export type EProjectTemplateCategory = 'backend' | 'frontend' | 'static' | 'other'; +export type TProjectTemplateCategory = 'backend' | 'frontend' | 'static' | 'other'; export interface IProjectTemplate { Id: number; Name: string; Description: string | null; Icon: string | null; - Category: EProjectTemplateCategory; + Category: TProjectTemplateCategory; DefaultConfig: Record; IsBuiltIn: boolean; CreatedBy: number | null; @@ -26,7 +26,7 @@ export interface IProjectTemplateCreate { Name: string; Description?: string | null; Icon?: string | null; - Category: EProjectTemplateCategory; + Category: TProjectTemplateCategory; DefaultConfig: Record; } @@ -34,12 +34,12 @@ export interface IProjectTemplateUpdate { Name?: string; Description?: string | null; Icon?: string | null; - Category?: EProjectTemplateCategory; + Category?: TProjectTemplateCategory; DefaultConfig?: Record; } export const ProjectTemplateService = { - list: async (category?: EProjectTemplateCategory): Promise => { + list: async (category?: TProjectTemplateCategory): Promise => { const params = category ? { category } : undefined; const res = await ApiInstance.get('/project-templates', { params }); const data = res.data?.Data; From f9fe5c1426e3a97e6db010845dc527f8e5981294 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 11:41:58 +0000 Subject: [PATCH 3/3] build: use bun as the only package manager and add CI bun.lock is the single lockfile and package.json pins bun@1.4.4. The README installs and runs with bun. A CI workflow installs with a frozen lockfile, then lints, tests and builds on every pull request and push to master. Signed-off-by: Sabry Dawood --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ README.md | 16 ++++++---------- package.json | 1 + 3 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c9ab4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + pull_request: + branches: [master, "version/**"] + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build, lint and test + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2.1.3 + with: + bun-version: 1.4.4 + - name: Install (frozen lockfile) + run: bun install --frozen-lockfile + - name: Lint + run: bun run lint + - name: Test + run: bun run test -- --run + - name: Build + run: bun run build diff --git a/README.md b/README.md index ce806aa..099c9d7 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,14 @@ Modern React-based web interface for Deploy Center CI/CD platform. ## 📋 Prerequisites -- Node.js 18+ -- npm or yarn +- [Bun](https://bun.sh) 1.4+ (the project's package manager and script runner) - Deploy Center Server running ## 🛠️ Installation ```bash -# Install dependencies -npm install - -# or with yarn -yarn install +# Install dependencies (bun.lock is the only lockfile) +bun install ``` ## ⚙️ Configuration @@ -49,7 +45,7 @@ VITE_API_TIMEOUT=30000 ```bash # Start development server -npm run dev +bun run dev # Server will start at http://localhost:5173 ``` @@ -58,10 +54,10 @@ npm run dev ```bash # Build for production -npm run build +bun run build # Preview production build -npm run preview +bun run preview ``` Build output will be in `dist/` directory. diff --git a/package.json b/package.json index 1f0d8f2..780e083 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "3.0.1", "description": "Deploy Center - Frontend Application | Comprehensive deployment automation platform with real-time monitoring, RBAC, and CI/CD integration", "type": "module", + "packageManager": "bun@1.4.4", "author": "Deploy Center Team", "license": "SEE LICENSE IN LICENSE", "keywords": [