diff --git a/package.json b/package.json index 09fbf16..0689b36 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "deploy-center-client", "private": true, - "version": "2.1.0", + "version": "3.0.1", "description": "Deploy Center - Frontend Application | Comprehensive deployment automation platform with real-time monitoring, RBAC, and CI/CD integration", "type": "module", "author": "Deploy Center Team", @@ -67,4 +67,4 @@ "vite": "^7.2.4", "vitest": "^4.0.16" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index adf2dcd..2c382db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,11 +112,9 @@ const AppRoutesWithToast: React.FC = () => { // Import socket service import('@/services/socket').then(({ socketService }) => { + // The server joins the authenticated user's room on connect. const socket = socketService.connect(); - // Join user room for session management - socket.emit('join:user', User.Id); - // Listen for session revoked event const handleSessionRevoked = (payload: { SessionId: number }) => { // Only logout if the revoked session is THIS session diff --git a/src/components/Layout/MainLayout.tsx b/src/components/Layout/MainLayout.tsx index fea4266..fbf5a58 100644 --- a/src/components/Layout/MainLayout.tsx +++ b/src/components/Layout/MainLayout.tsx @@ -41,7 +41,7 @@ import { import { useAuth } from "@/contexts/AuthContext"; import { useTheme } from "@/contexts/ThemeContext"; import { useLanguage } from "@/contexts/LanguageContext"; -import { useRole, UserRole } from "@/contexts/RoleContext"; +import { useRole, UserRole, type TRoleName } from "@/contexts/RoleContext"; const DRAWER_WIDTH = 240; @@ -50,7 +50,7 @@ interface IMenuItem { TitleAr: string; Path: string; Icon: React.ReactNode; - AllowedRoles?: UserRole[]; // Optional - if not specified, all roles can access + AllowedRoles?: TRoleName[]; // Optional - if not specified, all roles can access } const MenuItems: IMenuItem[] = [ diff --git a/src/components/Projects/DeploymentModal.tsx b/src/components/Projects/DeploymentModal.tsx index 970c844..4c8ea51 100644 --- a/src/components/Projects/DeploymentModal.tsx +++ b/src/components/Projects/DeploymentModal.tsx @@ -50,6 +50,7 @@ export const DeploymentModal: React.FC = ({ // Reset form when project changes useEffect(() => { if (Project) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- resets the form when a different project is opened; pre-existing pattern, refactor tracked separately setBranch(Project.Branch || 'main'); setCommitHash(''); setCommitMessage(''); diff --git a/src/components/Projects/ProjectFormModal.tsx b/src/components/Projects/ProjectFormModal.tsx index 12c3ae7..1f4f123 100644 --- a/src/components/Projects/ProjectFormModal.tsx +++ b/src/components/Projects/ProjectFormModal.tsx @@ -75,6 +75,7 @@ export const ProjectFormModal: React.FC = ({ // Reset form when modal opens/closes or project changes useEffect(() => { if (Open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- resets the form each time the modal opens; pre-existing pattern, refactor tracked separately setFormData(Project || getDefaultFormData()); setActiveStep(0); setError(null); diff --git a/src/components/Projects/SshKeyManagement.tsx b/src/components/Projects/SshKeyManagement.tsx index 9228117..9e34118 100644 --- a/src/components/Projects/SshKeyManagement.tsx +++ b/src/components/Projects/SshKeyManagement.tsx @@ -60,13 +60,6 @@ export const SshKeyManagement: React.FC = ({ const hasSSHKey = project.UseSshKey && project.SshPublicKey; - // Load SSH key info if exists - useEffect(() => { - if (hasSSHKey) { - loadSshKeyInfo(); - } - }, [hasSSHKey, project.Id]); - const loadSshKeyInfo = async () => { try { const keyInfo = await ProjectsService.getSshPublicKey(project.Id); @@ -76,6 +69,14 @@ export const SshKeyManagement: React.FC = ({ } }; + // Load SSH key info if exists + useEffect(() => { + if (hasSSHKey) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- loads key details from the API when the project changes; pre-existing pattern, refactor tracked separately + loadSshKeyInfo(); + } + }, [hasSSHKey, project.Id]); + const handleGenerateKey = async () => { setLoading(true); setError(''); diff --git a/src/components/Projects/VariablesManager.tsx b/src/components/Projects/VariablesManager.tsx index 111bbcd..4dabc54 100644 --- a/src/components/Projects/VariablesManager.tsx +++ b/src/components/Projects/VariablesManager.tsx @@ -28,6 +28,7 @@ import { import { useUpdateProject } from '@/hooks/useProjects'; import { useToast } from '@/contexts/ToastContext'; import type { IProject } from '@/types'; +import { IsMaskedSecret, SECRET_MASK } from '@/utils/secrets'; interface IVariablesManagerProps { project: IProject; @@ -43,6 +44,8 @@ export const VariablesManager: React.FC = ({ project, on const [newKey, setNewKey] = useState(''); const [newValue, setNewValue] = useState(''); const [editingKey, setEditingKey] = useState(null); + // True while editing a write-only value: empty input means "keep current". + const [editingMasked, setEditingMasked] = useState(false); const [localVariables, setLocalVariables] = useState>( project.Config?.Variables || {} ); @@ -74,7 +77,9 @@ export const VariablesManager: React.FC = ({ project, on const handleEditVariable = (key: string) => { setEditingKey(key); setNewKey(key); - setNewValue(localVariables[key]); + const masked = IsMaskedSecret(localVariables[key]); + setEditingMasked(masked); + setNewValue(masked ? '' : localVariables[key] ?? ''); setDialogOpen(true); }; @@ -84,6 +89,13 @@ export const VariablesManager: React.FC = ({ project, on return; } + // The mask means "keep the value stored under this name", so a renamed + // write-only variable needs its value entered again. + if (editingMasked && newValue === '' && editingKey && editingKey !== newKey.trim()) { + showError('Enter the value again to rename a hidden variable'); + return; + } + setLocalVariables((prev) => { const updated = { ...prev }; @@ -92,13 +104,15 @@ export const VariablesManager: React.FC = ({ project, on delete updated[editingKey]; } - updated[newKey.trim()] = newValue; + // Leaving a write-only value empty keeps the stored secret. + updated[newKey.trim()] = editingMasked && newValue === '' ? SECRET_MASK : newValue; return updated; }); setNewKey(''); setNewValue(''); setEditingKey(null); + setEditingMasked(false); setDialogOpen(false); }; @@ -231,7 +245,11 @@ export const VariablesManager: React.FC = ({ project, on wordBreak: 'break-all', }} > - {value || (empty)} + {IsMaskedSecret(value) ? ( + (set, hidden) + ) : ( + value || (empty) + )} @@ -296,7 +314,12 @@ export const VariablesManager: React.FC = ({ project, on label="Value" value={newValue} onChange={(e) => setNewValue(e.target.value)} - placeholder="Variable value" + placeholder={editingMasked ? 'Enter a new value to replace it' : 'Variable value'} + helperText={ + editingMasked + ? 'The current value is hidden. Leave empty to keep it.' + : undefined + } multiline rows={3} /> diff --git a/src/components/RoleGuard.tsx b/src/components/RoleGuard.tsx index 5f49a82..bfeb3ff 100644 --- a/src/components/RoleGuard.tsx +++ b/src/components/RoleGuard.tsx @@ -4,11 +4,11 @@ */ import React from 'react'; -import { useRole, UserRole } from '@/contexts/RoleContext'; +import { useRole, type TRoleName } from '@/contexts/RoleContext'; import { Alert, Box } from '@mui/material'; interface IRoleGuardProps { - allowedRoles: UserRole[]; + allowedRoles: TRoleName[]; children: React.ReactNode; fallback?: React.ReactNode; showMessage?: boolean; diff --git a/src/components/Settings/SecurityTab.tsx b/src/components/Settings/SecurityTab.tsx index 554a951..8b54955 100644 --- a/src/components/Settings/SecurityTab.tsx +++ b/src/components/Settings/SecurityTab.tsx @@ -170,6 +170,11 @@ export const SecurityTab: React.FC = ({ t }) => { {errorMessage} )} + {twoFactorEnabled && twoFAStatus?.legacyBackupCodesInvalidated && ( + + {t("settings.legacyBackupCodesInvalidated")} + + )} {!twoFactorEnabled && ( diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index acc999e..53b6bea 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -6,6 +6,7 @@ import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import AuthService from '@/services/authService'; +import { socketService } from '@/services/socket'; import type { IUser, ILoginCredentials, @@ -21,7 +22,7 @@ interface IAuthContextValue { HasSession: boolean; CurrentSessionId: number | null; Login: (credentials: ILoginCredentials) => Promise; - Verify2FA: (userId: number, code: string) => Promise; + Verify2FA: (code: string) => Promise; Register: (data: IRegisterData) => Promise; Logout: () => Promise; RefreshUser: () => Promise; @@ -35,14 +36,15 @@ interface IAuthProviderProps { export const AuthProvider: React.FC = ({ children }) => { const [User, setUser] = useState(null); - const [IsLoading, setIsLoading] = useState(true); const [HasSession, setHasSession] = useState(() => !sessionStorage.getItem('dc_no_session')); + // Only "loading" when there is a session to restore; avoids a synchronous + // setState inside the effect below. + const [IsLoading, setIsLoading] = useState(HasSession); const [CurrentSessionId, setCurrentSessionId] = useState(null); // Initialize auth state by checking if user is authenticated via cookie useEffect(() => { if (!HasSession) { - setIsLoading(false); return; } @@ -86,10 +88,12 @@ export const AuthProvider: React.FC = ({ children }) => { } }; - const Verify2FA = async (userId: number, code: string): Promise => { + // Does not toggle the global IsLoading flag: the route guards render a + // loader while it is set, which unmounted the login page and lost the 2FA + // dialog (and its error) after a wrong code. LoginPage has its own spinner. + const Verify2FA = async (code: string): Promise => { try { - setIsLoading(true); - const response = await AuthService.Verify2FA(userId, code); + const response = await AuthService.Verify2FA(code); setUser(response.User); setCurrentSessionId(response.SessionId); setHasSession(true); @@ -98,8 +102,6 @@ export const AuthProvider: React.FC = ({ children }) => { } catch (error) { console.error('2FA verification error:', error); throw error; - } finally { - setIsLoading(false); } }; @@ -126,6 +128,8 @@ export const AuthProvider: React.FC = ({ children }) => { } catch (error) { console.error('Logout API error:', error); } finally { + // Close the authenticated socket so the next user starts clean. + socketService.disconnect(); // Clear user state regardless of API response setUser(null); setCurrentSessionId(null); diff --git a/src/contexts/RoleContext.tsx b/src/contexts/RoleContext.tsx index 51a7fec..55aa7ae 100644 --- a/src/contexts/RoleContext.tsx +++ b/src/contexts/RoleContext.tsx @@ -13,10 +13,10 @@ export const UserRole = { Viewer: 'viewer', } as const; -export type UserRole = typeof UserRole[keyof typeof UserRole]; +export type TRoleName = typeof UserRole[keyof typeof UserRole]; interface IRoleContext { - role: UserRole | null; + role: TRoleName | null; isAdmin: boolean; isManager: boolean; isDeveloper: boolean; @@ -26,7 +26,7 @@ interface IRoleContext { canManageUsers: boolean; canDeploy: boolean; canViewReports: boolean; - hasRole: (roles: UserRole[]) => boolean; + hasRole: (roles: TRoleName[]) => boolean; } const RoleContext = createContext(undefined); @@ -35,7 +35,7 @@ export const RoleProvider: React.FC<{ children: React.ReactNode }> = ({ children const { User } = useAuth(); const roleUtils = useMemo(() => { - const role = User?.Role as UserRole | null; + const role = User?.Role as TRoleName | null; const isAdmin = role === UserRole.Admin; const isManager = role === UserRole.Manager; const isDeveloper = role === UserRole.Developer; @@ -53,7 +53,7 @@ export const RoleProvider: React.FC<{ children: React.ReactNode }> = ({ children canManageUsers: isAdmin || isManager, canDeploy: isAdmin || isManager || isDeveloper, canViewReports: isAdmin || isManager, - hasRole: (roles: UserRole[]) => (role ? roles.includes(role) : false), + hasRole: (roles: TRoleName[]) => (role ? roles.includes(role) : false), }; }, [User?.Role]); diff --git a/src/contexts/UserSettingsContext.tsx b/src/contexts/UserSettingsContext.tsx index fc2e515..b07f7a5 100644 --- a/src/contexts/UserSettingsContext.tsx +++ b/src/contexts/UserSettingsContext.tsx @@ -68,6 +68,7 @@ export const UserSettingsProvider: React.FC = ({ chi // Fetch settings when user logs in useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- fetches settings when the signed-in user changes; pre-existing pattern, refactor tracked separately fetchSettings(); }, [IsAuthenticated, User?.Id]); diff --git a/src/hooks/useUserSettings.ts b/src/hooks/useUserSettings.ts index 2522063..ed105f4 100644 --- a/src/hooks/useUserSettings.ts +++ b/src/hooks/useUserSettings.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import UserSettingsService from '@/services/userSettingsService'; import type { + ITwoFactorStatus, IUserSettings, INotificationSettings, IUserPreferences, @@ -43,7 +44,7 @@ export const useUserSessions = () => { * Custom hook to fetch 2FA status */ export const use2FAStatus = () => { - return useQuery<{ enabled: boolean }, Error>({ + return useQuery({ queryKey: ['2faStatus'], queryFn: () => UserSettingsService.get2FAStatus(), }); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 3cea533..a9016ab 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -158,7 +158,10 @@ "regenerateSshKeyWarning": "سيتم توليد مفتاح SSH جديد وإبطال المفتاح القديم. يجب عليك تحديث مفتاح النشر في مستودع GitHub الخاص بك. لا يمكن التراجع عن هذا الإجراء.", "deleteSshKeyWarning": "سيتم حذف مفتاح SSH من هذا المشروع. ستعود عمليات النشر إلى استخدام HTTPS. لا يمكن التراجع عن هذا الإجراء.", "processing": "جاري المعالجة...", - "webhookUrl": "لينك الويبهوك" + "webhookUrl": "لينك الويبهوك", + "secretSet": "متسجّل (مخفي). اعمل توليد جديد عشان تاخد سر جديد.", + "secretNotSet": "مش متسجّل", + "webhookSecretShownOnce": "انسخ السر ده دلوقتي وحدّثه في GitHub، مش هيظهر تاني." }, "deployments": { "title": "عمليات النشر", @@ -423,7 +426,8 @@ "deleteAccountConfirm": "هل أنت متأكد من حذف حسابك؟", "deleteAccountFailed": "فشل حذف الحساب", "deleteAccountSuccess": "تم إرسال طلب حذف الحساب", - "close": "إغلاق" + "close": "إغلاق", + "legacyBackupCodesInvalidated": "أكواد الاسترداد الحالية اتعملت قبل الإصدار 3.0.1 ومبقتش مقبولة. اعمل أكواد جديدة دلوقتي واحفظها في مكان آمن." }, "notifications": { "title": "الإشعارات", @@ -613,4 +617,4 @@ "maintenance": "الخدمة في صيانة. يرجى المحاولة لاحقاً.", "rateLimit": "تم تجاوز حد التكرار. يرجى الانتظار قبل إعادة المحاولة." } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1b4ecf6..62f1bdb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -167,7 +167,10 @@ "regenerateSshKeyWarning": "This will generate a new SSH key and invalidate the old one. You must update the deploy key in your GitHub repository. This action cannot be undone.", "deleteSshKeyWarning": "This will delete the SSH key from this project. Deployments will fall back to HTTPS. This action cannot be undone.", "processing": "Processing...", - "webhookUrl": "Webhook URL" + "webhookUrl": "Webhook URL", + "secretSet": "Set (hidden). Regenerate to get a new secret.", + "secretNotSet": "Not set", + "webhookSecretShownOnce": "Copy this secret now and update it in GitHub. It will not be shown again." }, "deployments": { "title": "Deployments", @@ -424,7 +427,8 @@ "deleteAccount": "Delete Account", "deleteAccountConfirm": "Are you sure you want to delete your account?", "deleteAccountSuccess": "Account deletion requested", - "deleteAccountFailed": "Failed to delete account" + "deleteAccountFailed": "Failed to delete account", + "legacyBackupCodesInvalidated": "Your backup codes were created before v3.0.1 and are no longer accepted. Regenerate them now and store the new codes safely." }, "notifications": { "title": "Notifications", @@ -615,4 +619,4 @@ "maintenance": "Service is under maintenance. Please try again later.", "rateLimit": "Rate limit exceeded. Please wait before retrying." } -} \ No newline at end of file +} diff --git a/src/pages/Auth/LoginPage.tsx b/src/pages/Auth/LoginPage.tsx index ce08557..c61c389 100644 --- a/src/pages/Auth/LoginPage.tsx +++ b/src/pages/Auth/LoginPage.tsx @@ -3,7 +3,7 @@ * User authentication page with login form */ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Box, @@ -30,7 +30,8 @@ import { } from "@mui/material"; import { useAuth } from "@/contexts/AuthContext"; import { useLanguage } from "@/contexts/LanguageContext"; -import type { ILoginCredentials, ITwoFactorChallenge } from "@/types"; +import type { ILoginCredentials } from "@/types"; +import { AuthApiError } from "@/services/authService"; export const LoginPage: React.FC = () => { const navigate = useNavigate(); @@ -43,7 +44,6 @@ export const LoginPage: React.FC = () => { TotpCode: "", }); const [RequireTotp, setRequireTotp] = useState(false); - const [PendingUserId, setPendingUserId] = useState(null); const [ShowTotpDialog, setShowTotpDialog] = useState(false); const [TotpError, setTotpError] = useState(null); const [Info, setInfo] = useState(null); @@ -64,20 +64,12 @@ export const LoginPage: React.FC = () => { const ResetTotpState = () => { setRequireTotp(false); - setPendingUserId(null); setShowTotpDialog(false); setTotpError(null); setInfo(null); setCredentials((prev) => ({ ...prev, TotpCode: "" })); }; - // If 2FA is required at any point, always open the dialog (avoids missing it after rerenders) - useEffect(() => { - if (RequireTotp) { - setShowTotpDialog(true); - } - }, [RequireTotp]); - const HandleSubmit = async (event: React.FormEvent) => { event.preventDefault(); event.stopPropagation(); @@ -106,13 +98,10 @@ export const LoginPage: React.FC = () => { const response = await Login(Credentials); if ("TwoFactorRequired" in response && response.TwoFactorRequired) { - const challenge = response as ITwoFactorChallenge; - - // Set all states in a single batch to avoid HMR issues + // The server keeps the pending 2FA step in an httpOnly cookie. setLoading(false); // stop spinner first setError(null); setInfo(t("auth.totpPrompt")); - setPendingUserId(challenge.UserId); setRequireTotp(true); // Use setTimeout to ensure state updates are committed before showing dialog @@ -155,14 +144,9 @@ export const LoginPage: React.FC = () => { return; } - if (!PendingUserId) { - setTotpError(t("auth.loginFailed")); - return; - } - try { setLoading(true); - await Verify2FA(PendingUserId, Credentials.TotpCode); + await Verify2FA(Credentials.TotpCode); setShowTotpDialog(false); navigate("/dashboard"); } catch (error: unknown) { @@ -171,6 +155,16 @@ export const LoginPage: React.FC = () => { ? String(error.message) : t("auth.loginFailed"); + // Expired / exhausted 2FA step, or account locked: start over. + const restart = + error instanceof AuthApiError && + (error.Status === 429 || /sign in again/i.test(errorMessage)); + if (restart) { + ResetTotpState(); + setError(errorMessage); + return; + } + setTotpError(errorMessage); } finally { setLoading(false); diff --git a/src/pages/Deployments/DeploymentDetailsPage.tsx b/src/pages/Deployments/DeploymentDetailsPage.tsx index 84e4482..c2dcdfa 100644 --- a/src/pages/Deployments/DeploymentDetailsPage.tsx +++ b/src/pages/Deployments/DeploymentDetailsPage.tsx @@ -30,6 +30,28 @@ export const DeploymentDetailsPage: React.FC = () => { const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState(0); + const fetchDeployment = async () => { + try { + if (!id) return; + const data = await DeploymentsService.getById(Number(id)); + setDeployment(data); + } catch (_err) { + setError('Failed to load deployment details'); + } finally { + setLoading(false); + } + }; + + const fetchLogs = async () => { + try { + if (!id) return; + const logData = await DeploymentsService.getLogs(Number(id)); + setLogs(logData); + } catch (_err) { + console.error('Failed to load logs'); + } + }; + // Connect to socket for this deployment useDeploymentUpdates(deployment?.ProjectId); @@ -71,29 +93,8 @@ export const DeploymentDetailsPage: React.FC = () => { }; }, [socket, id]); - const fetchDeployment = async () => { - try { - if (!id) return; - const data = await DeploymentsService.getById(Number(id)); - setDeployment(data); - } catch (_err) { - setError('Failed to load deployment details'); - } finally { - setLoading(false); - } - }; - - const fetchLogs = async () => { - try { - if (!id) return; - const logData = await DeploymentsService.getLogs(Number(id)); - setLogs(logData); - } catch (_err) { - console.error('Failed to load logs'); - } - }; - useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch for the deployment in the URL; pre-existing pattern, refactor tracked separately fetchDeployment(); fetchLogs(); }, [id]); diff --git a/src/pages/Deployments/DeploymentLogsPage.tsx b/src/pages/Deployments/DeploymentLogsPage.tsx index 7428c9d..52db08e 100644 --- a/src/pages/Deployments/DeploymentLogsPage.tsx +++ b/src/pages/Deployments/DeploymentLogsPage.tsx @@ -73,6 +73,7 @@ export const DeploymentLogsPage: React.FC = () => { }, [id]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch for the deployment in the URL; pre-existing pattern, refactor tracked separately fetchDeployment(); fetchLogs(); diff --git a/src/pages/Projects/ProjectDetailsPage.tsx b/src/pages/Projects/ProjectDetailsPage.tsx index edf8cea..940e751 100644 --- a/src/pages/Projects/ProjectDetailsPage.tsx +++ b/src/pages/Projects/ProjectDetailsPage.tsx @@ -61,6 +61,9 @@ 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); @@ -139,8 +142,12 @@ export const ProjectDetailsPage: React.FC = () => { if (!project) return; if (!window.confirm(t("projects.webhookRegenerationWarning"))) return; - regenerateWebhook.mutate(project.Id, { - onSuccess: () => { + const projectId = project.Id; + regenerateWebhook.mutate(projectId, { + 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) => { @@ -228,6 +235,7 @@ export const ProjectDetailsPage: React.FC = () => { onRegenerateWebhook={handleRegenerateWebhook} onCopyToClipboard={copyToClipboard} regeneratingWebhook={regenerateWebhook.isPending} + revealedSecret={RevealedWebhookSecret} /> refetch()} /> diff --git a/src/pages/Projects/components/ProjectWebhookCard.test.tsx b/src/pages/Projects/components/ProjectWebhookCard.test.tsx new file mode 100644 index 0000000..b903a3e --- /dev/null +++ b/src/pages/Projects/components/ProjectWebhookCard.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProjectWebhookCard } from "./ProjectWebhookCard"; +import type { IProject } from "@/types"; +import { HasSecret, IsMaskedSecret, SECRET_MASK } from "@/utils/secrets"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/contexts/RoleContext", () => ({ + useRole: () => ({ canManageProjects: true }), +})); + +const project = { + Id: 1, + Name: "app", + Secrets: { WebhookSecret: { HasValue: true } }, +} as unknown as IProject; + +describe("ProjectWebhookCard (SEC-H3 write-only secret)", () => { + it("shows only whether the secret is set", () => { + render( + undefined} + onCopyToClipboard={() => undefined} + regeneratingWebhook={false} + /> + ); + expect(screen.getByDisplayValue("projects.secretSet")).toBeInTheDocument(); + expect(screen.queryByText("projects.webhookSecretShownOnce")).not.toBeInTheDocument(); + }); + + it("reveals a freshly regenerated secret once", () => { + render( + undefined} + onCopyToClipboard={() => undefined} + regeneratingWebhook={false} + revealedSecret="new-secret-value" + /> + ); + expect(screen.getByDisplayValue("new-secret-value")).toBeInTheDocument(); + expect(screen.getByText("projects.webhookSecretShownOnce")).toBeInTheDocument(); + }); +}); + +describe("secrets helpers", () => { + it("recognises the server mask and HasValue state", () => { + expect(IsMaskedSecret(SECRET_MASK)).toBe(true); + expect(IsMaskedSecret("plain")).toBe(false); + expect(HasSecret({ X: { HasValue: false } }, "X", "legacy")).toBe(false); + expect(HasSecret(undefined, "X", "legacy")).toBe(true); + expect(HasSecret(undefined, "X", "")).toBe(false); + }); +}); diff --git a/src/pages/Projects/components/ProjectWebhookCard.tsx b/src/pages/Projects/components/ProjectWebhookCard.tsx index 7ec3791..1768178 100644 --- a/src/pages/Projects/components/ProjectWebhookCard.tsx +++ b/src/pages/Projects/components/ProjectWebhookCard.tsx @@ -10,6 +10,7 @@ import { IconButton, Tooltip, CircularProgress, + Alert, } from '@mui/material'; import { Visibility as VisibilityIcon, @@ -20,12 +21,15 @@ import { import { useTranslation } from 'react-i18next'; import { useRole } from '@/contexts/RoleContext'; import type { IProject } from '@/types'; +import { HasSecret } from '@/utils/secrets'; interface IProjectWebhookCardProps { project: IProject; onRegenerateWebhook: () => Promise; onCopyToClipboard: (text: string) => void; regeneratingWebhook: boolean; + /** The new secret, available only right after regeneration (write-only). */ + revealedSecret?: string | null; } export const ProjectWebhookCard: React.FC = ({ @@ -33,10 +37,12 @@ export const ProjectWebhookCard: React.FC = ({ onRegenerateWebhook, onCopyToClipboard, regeneratingWebhook, + revealedSecret, }) => { const { t } = useTranslation(); const { canManageProjects } = useRole(); const [showWebhook, setShowWebhook] = useState(false); + const hasSecret = HasSecret(project.Secrets, 'WebhookSecret', project.WebhookSecret); // Build webhook URL from current window location const webhookUrl = `${window.location.protocol}//${window.location.host}/api/webhooks/github/${project.Name}`; @@ -75,31 +81,44 @@ export const ProjectWebhookCard: React.FC = ({ sx={{ mb: 2 }} /> - - setShowWebhook(!showWebhook)} edge="end"> - {showWebhook ? : } - - - onCopyToClipboard(project.WebhookSecret)} - edge="end" - > - - - - - ), - }} - sx={{ mb: 0.5 }} - /> + {revealedSecret ? ( + <> + + {t('projects.webhookSecretShownOnce')} + + + setShowWebhook(!showWebhook)} edge="end"> + {showWebhook ? : } + + + onCopyToClipboard(revealedSecret)} edge="end"> + + + + + ), + }} + sx={{ mb: 0.5 }} + /> + + ) : ( + + )} {canManageProjects && (