Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 6 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
6 changes: 3 additions & 3 deletions src/components/Settings/NotificationChannelsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions src/components/Settings/NotificationProvidersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -78,7 +78,7 @@ export const NotificationProvidersTab: React.FC<{ t: (key: string) => string }>
const [error, setError] = useState<string | null>(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 {
Expand All @@ -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,
};
Expand Down
4 changes: 2 additions & 2 deletions src/pages/Deployments/components/CopyLogButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,7 +39,7 @@ const fallbackCopy = (value: string): boolean => {
};

export const CopyLogButton: React.FC<IProps> = ({ text, size = 'small', resetMs = 1500 }) => {
const [status, setStatus] = useState<Status>('idle');
const [status, setStatus] = useState<TStatus>('idle');

const handleClick = async () => {
let ok = false;
Expand Down
115 changes: 115 additions & 0 deletions src/pages/Projects/ProjectDetailsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div>
<button onClick={props.onRegenerateWebhook}>regenerate</button>
<output data-testid="revealed">{props.revealedSecret ?? "none"}</output>
</div>
),
}));

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(<ProjectDetailsPage />);
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(<ProjectDetailsPage />);
openAccessTab();
fireEvent.click(screen.getByText("regenerate"));
expect(screen.getByTestId("revealed")).toHaveTextContent("whsec_project_one");

state.projectId = 2;
rerender(<ProjectDetailsPage />);

expect(screen.getByTestId("revealed")).toHaveTextContent("none");
});
});
10 changes: 9 additions & 1 deletion src/pages/Projects/ProjectDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -313,6 +320,7 @@ export const ProjectDetailsPage: React.FC = () => {
onRegenerateWebhook={handleRegenerateWebhook}
onCopyToClipboard={copyToClipboard}
regeneratingWebhook={regenerateWebhook.isPending}
revealedSecret={RevealedWebhookSecret}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
Expand Down
1 change: 1 addition & 0 deletions src/pages/Projects/components/ProjectNotificationsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const ProjectNotificationsCard: React.FC<IProps> = ({ 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) {
Expand Down
6 changes: 3 additions & 3 deletions src/pages/Projects/components/ProjectTemplateWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ import { useQuery } from '@tanstack/react-query';
import {
ProjectTemplateService,
type IProjectTemplate,
type EProjectTemplateCategory,
type TProjectTemplateCategory,
} from '@/services/projectTemplateService';

interface IProps {
open: boolean;
onClose: (chosen: IProjectTemplate | null | 'cancel') => void;
}

const CATEGORY_LABELS: Record<EProjectTemplateCategory, string> = {
const CATEGORY_LABELS: Record<TProjectTemplateCategory, string> = {
backend: 'Backend',
frontend: 'Frontend',
static: 'Static',
Expand All @@ -59,7 +59,7 @@ export const ProjectTemplateWizard: React.FC<IProps> = ({ open, onClose }) => {
});

const grouped = useMemo(() => {
const map = new Map<EProjectTemplateCategory, IProjectTemplate[]>();
const map = new Map<TProjectTemplateCategory, IProjectTemplate[]>();
for (const cat of ['backend', 'frontend', 'static', 'other'] as const) {
map.set(cat, []);
}
Expand Down
6 changes: 3 additions & 3 deletions src/services/notificationChannelService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 3 additions & 3 deletions src/services/notificationProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading