From 6c0c2fcd3d8d2b9f97ea1df59cf095580c0d928c Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:46:43 +0000 Subject: [PATCH 1/8] fix(socket): work with the authenticated socket server (SEC-C2) - Re-join remembered project/deployment rooms on every reconnect so live logs keep flowing after a network blip; forget rooms the server refuses. - When the handshake is rejected (expired access token), refresh the session once and reconnect. - The server joins the user room itself; stop emitting join:user. - Disconnect the socket on logout. --- src/App.tsx | 4 +- src/contexts/AuthContext.tsx | 3 ++ src/services/socket.ts | 73 +++++++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 12 deletions(-) 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/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index acc999e..063f3f6 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, @@ -126,6 +127,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/services/socket.ts b/src/services/socket.ts index 49aaea0..584b3d5 100644 --- a/src/services/socket.ts +++ b/src/services/socket.ts @@ -1,9 +1,31 @@ import { io, Socket } from "socket.io-client"; import { Config } from "@/utils/config"; +import AuthService from "@/services/authService"; +type TJoinEvent = "join:project" | "join:deployment"; + +interface IJoinAck { + Ok: boolean; + Error?: string; +} + +/** + * Socket.IO client. + * + * Since v3.0.1 the server authenticates the handshake with the httpOnly + * `access_token` cookie (sent because of `withCredentials`) and authorizes + * every room join. The user room is joined by the server automatically. + * + * - Rooms joined through this service are remembered and re-joined after every + * reconnect, so live logs keep flowing after a network blip. + * - If the handshake is rejected (expired access token), the service refreshes + * the session once and reconnects. + */ class SocketService { private static instance: SocketService; private socket: Socket | null = null; + private readonly rooms = new Map(); + private lastRefreshAttempt = 0; private constructor() {} @@ -16,6 +38,9 @@ class SocketService { public connect(): Socket { if (this.socket) { + if (!this.socket.connected && !this.socket.active) { + this.socket.connect(); + } return this.socket; } @@ -27,22 +52,24 @@ class SocketService { }); this.socket.on("connect", () => { - // Connected + this.rejoinRooms(); }); - this.socket.on("disconnect", () => { - // Disconnected - }); - - this.socket.on("connect_error", (err) => { - console.error("Socket connection error:", err); + this.socket.on("connect_error", () => { + // `active` is false when the server middleware refused the handshake; + // socket.io will not retry on its own in that case. + if (this.socket && !this.socket.active) { + void this.refreshAndReconnect(); + } }); return this.socket; } public disconnect(): void { + this.rooms.clear(); if (this.socket) { + this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; } @@ -53,11 +80,39 @@ class SocketService { } public joinProject(projectId: number): void { - this.socket?.emit("join:project", projectId); + this.join("join:project", projectId); } public joinDeployment(deploymentId: number): void { - this.socket?.emit("join:deployment", deploymentId); + this.join("join:deployment", deploymentId); + } + + private join(event: TJoinEvent, id: number): void { + const key = `${event}:${id}`; + this.rooms.set(key, { event, id }); + this.socket?.emit(event, id, (ack?: IJoinAck) => { + if (ack && !ack.Ok) { + this.rooms.delete(key); + } + }); + } + + private rejoinRooms(): void { + this.rooms.forEach(({ event, id }) => { + this.socket?.emit(event, id); + }); + } + + private async refreshAndReconnect(): Promise { + const now = Date.now(); + if (now - this.lastRefreshAttempt < 30000) return; + this.lastRefreshAttempt = now; + try { + await AuthService.RefreshToken(); + this.socket?.connect(); + } catch { + // Session is gone; the HTTP layer will redirect to login. + } } } From f223a6c4c0c79f67c1d312e720b961258041963c Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:56:14 +0000 Subject: [PATCH 2/8] fix(auth): adopt the cookie-bound 2FA flow (SEC-C3) - verify-2fa sends only the code; the server identifies the user from the httpOnly mfa_pending cookie set at login. The login challenge type no longer carries a user id. - A wrong 2FA code no longer triggers the token-refresh interceptor and a full-page redirect; the dialog shows the server's message instead. - An expired or exhausted 2FA step, or an account lockout (429), returns the user to the password form. - Security settings warn when pre-3.0.1 backup codes were invalidated. - Unit tests for the 2FA service calls. --- src/components/Settings/SecurityTab.tsx | 5 +++ src/contexts/AuthContext.tsx | 6 +-- src/hooks/useUserSettings.ts | 3 +- src/i18n/locales/ar.json | 5 ++- src/i18n/locales/en.json | 5 ++- src/pages/Auth/LoginPage.tsx | 27 ++++++------- src/services/api.ts | 1 + src/services/authService.test.ts | 45 +++++++++++++++++++++ src/services/authService.ts | 52 ++++++++++++++++++------- src/services/userSettingsService.ts | 3 +- src/types/auth.ts | 13 ++++++- 11 files changed, 129 insertions(+), 36 deletions(-) create mode 100644 src/services/authService.test.ts 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 063f3f6..7518bd7 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -22,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; @@ -87,10 +87,10 @@ export const AuthProvider: React.FC = ({ children }) => { } }; - const Verify2FA = async (userId: number, code: string): Promise => { + 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); 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..aabb9ad 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -423,7 +423,8 @@ "deleteAccountConfirm": "هل أنت متأكد من حذف حسابك؟", "deleteAccountFailed": "فشل حذف الحساب", "deleteAccountSuccess": "تم إرسال طلب حذف الحساب", - "close": "إغلاق" + "close": "إغلاق", + "legacyBackupCodesInvalidated": "أكواد الاسترداد الحالية اتعملت قبل الإصدار 3.0.1 ومبقتش مقبولة. اعمل أكواد جديدة دلوقتي واحفظها في مكان آمن." }, "notifications": { "title": "الإشعارات", @@ -613,4 +614,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..f63af03 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -424,7 +424,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 +616,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..33e5103 100644 --- a/src/pages/Auth/LoginPage.tsx +++ b/src/pages/Auth/LoginPage.tsx @@ -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,7 +64,6 @@ export const LoginPage: React.FC = () => { const ResetTotpState = () => { setRequireTotp(false); - setPendingUserId(null); setShowTotpDialog(false); setTotpError(null); setInfo(null); @@ -106,13 +105,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 +151,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 +162,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/services/api.ts b/src/services/api.ts index 6681884..1650779 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -92,6 +92,7 @@ ApiInstance.interceptors.response.use( if ( originalRequest.url?.includes('/auth/login') || originalRequest.url?.includes('/auth/register') || + originalRequest.url?.includes('/auth/verify-2fa') || originalRequest.url?.includes('/auth/refresh') ) { // Clear any client-side non-sensitive data diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts new file mode 100644 index 0000000..ddcf819 --- /dev/null +++ b/src/services/authService.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AxiosError, AxiosHeaders } from "axios"; + +const post = vi.fn(); +vi.mock("./api", () => ({ default: { post: (...args: unknown[]) => post(...args) } })); + +import AuthService, { AuthApiError } from "./authService"; + +describe("AuthService 2FA (SEC-C3)", () => { + beforeEach(() => { + post.mockReset(); + }); + + it("Verify2FA sends only the code; the user comes from the mfa_pending cookie", async () => { + post.mockResolvedValue({ data: { Data: { User: { Id: 1 }, SessionId: 2 } } }); + await AuthService.Verify2FA(" 123456 "); + expect(post).toHaveBeenCalledWith("/auth/verify-2fa", { Code: "123456" }); + }); + + it("Login challenge does not expose a user id", async () => { + post.mockResolvedValue({ + data: { Data: { TwoFactorRequired: true, Username: "alice", ExpiresInSeconds: 300 } }, + }); + const result = await AuthService.Login({ Username: "alice", Password: "pw" }); + expect(result).toEqual({ TwoFactorRequired: true, Username: "alice", ExpiresInSeconds: 300 }); + expect("UserId" in result).toBe(false); + }); + + it("Verify2FA surfaces the server message and status", async () => { + const config = { headers: new AxiosHeaders() }; + post.mockRejectedValue( + new AxiosError("Request failed", "ERR_BAD_REQUEST", config, null, { + status: 429, + statusText: "Too Many Requests", + headers: {}, + config, + data: { Message: "Too many failed two-factor attempts. Try again later." }, + }) + ); + const error = await AuthService.Verify2FA("000000").catch((e: unknown) => e); + expect(error).toBeInstanceOf(AuthApiError); + expect((error as AuthApiError).Status).toBe(429); + expect((error as AuthApiError).message).toMatch(/two-factor/); + }); +}); diff --git a/src/services/authService.ts b/src/services/authService.ts index 70500e1..5dbfdc6 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -3,6 +3,7 @@ * Handles all authentication-related API calls */ +import { isAxiosError } from "axios"; import ApiInstance from "./api"; import type { ILoginCredentials, @@ -13,6 +14,26 @@ import type { ITwoFactorChallenge, } from "@/types"; +/** Error carrying the server's message and HTTP status. */ +export class AuthApiError extends Error { + public readonly Status?: number; + constructor(message: string, status?: number) { + super(message); + this.name = "AuthApiError"; + this.Status = status; + } +} + +const ToAuthError = (error: unknown, fallback: string): AuthApiError => { + if (isAxiosError<{ Message?: string }>(error)) { + return new AuthApiError( + error.response?.data?.Message || fallback, + error.response?.status + ); + } + return new AuthApiError(error instanceof Error ? error.message : fallback); +}; + class AuthService { /** * Login user with credentials @@ -29,8 +50,8 @@ class AuthService { if (data?.TwoFactorRequired) { const challenge: ITwoFactorChallenge = { TwoFactorRequired: true, - UserId: (data as any).UserId, - Username: (data as any).Username, + Username: data.Username ?? credentials.Username, + ExpiresInSeconds: data.ExpiresInSeconds, }; return challenge; } @@ -103,19 +124,24 @@ class AuthService { } /** - * Verify 2FA code + * Verify 2FA code. The server identifies the user from the httpOnly + * `mfa_pending` cookie set by Login, so only the code is sent. */ - public async Verify2FA(userId: number, code: string): Promise { - const response = await ApiInstance.post>( - "/auth/verify-2fa", - { UserId: userId, Code: code } - ); - - if (response.data.Data) { - return response.data.Data; + public async Verify2FA(code: string): Promise { + try { + const response = await ApiInstance.post>( + "/auth/verify-2fa", + { Code: code.trim() } + ); + + if (response.data.Data) { + return response.data.Data; + } + + throw new AuthApiError(response.data.Message || "2FA verification failed"); + } catch (error) { + throw ToAuthError(error, "2FA verification failed"); } - - throw new Error(response.data.Message || "2FA verification failed"); } } export default new AuthService(); diff --git a/src/services/userSettingsService.ts b/src/services/userSettingsService.ts index ed16cdc..e8ba65a 100644 --- a/src/services/userSettingsService.ts +++ b/src/services/userSettingsService.ts @@ -3,6 +3,7 @@ import type { IApiKey, IApiKeyCreateResponse, INotificationSettings, + ITwoFactorStatus, IUser, IUserPreferences, IUserProfile, @@ -75,7 +76,7 @@ export const UserSettingsService = { return response.data.Data?.backupCodes || []; }, - get2FAStatus: async (): Promise<{ enabled: boolean; enabledAt?: Date | null }> => { + get2FAStatus: async (): Promise => { const response = await ApiInstance.get('/users/me/2fa/status'); return response.data.Data; }, diff --git a/src/types/auth.ts b/src/types/auth.ts index c7df012..68e0053 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -39,10 +39,21 @@ export interface ILoginCredentials { TotpCode?: string; } +/** + * Returned by /auth/login for 2FA accounts. Since v3.0.1 the server binds the + * second step to an httpOnly `mfa_pending` cookie; no user id is exposed. + */ export interface ITwoFactorChallenge { TwoFactorRequired: true; - UserId: number; Username: string; + ExpiresInSeconds?: number; +} + +export interface ITwoFactorStatus { + enabled: boolean; + enabledAt?: Date | null; + backupCodesRemaining?: number; + legacyBackupCodesInvalidated?: boolean; } export interface IRegisterData { From 7cfb3fad32216981165bf6836af10dbd1b0b9350 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:22:51 +0000 Subject: [PATCH 3/8] fix(projects): treat project secrets as write-only (SEC-H3) The server no longer returns webhook secrets, SSH key ciphertext or secret config values; it sends a mask plus a Secrets { HasValue } map. - Webhook card shows set / not set. A regenerated secret is shown once, with a warning to copy it into GitHub now. - Variables manager shows hidden values as "(set, hidden)"; editing one starts empty and leaving it empty keeps the stored secret. - Project types drop the SSH ciphertext fields and add Secrets. - Tests for the card and the mask helpers. --- src/components/Projects/VariablesManager.tsx | 24 +++++-- src/i18n/locales/ar.json | 5 +- src/i18n/locales/en.json | 5 +- src/pages/Projects/ProjectDetailsPage.tsx | 6 +- .../components/ProjectWebhookCard.test.tsx | 58 ++++++++++++++++ .../components/ProjectWebhookCard.tsx | 69 ++++++++++++------- src/types/project.ts | 8 +-- src/utils/secrets.ts | 25 +++++++ 8 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 src/pages/Projects/components/ProjectWebhookCard.test.tsx create mode 100644 src/utils/secrets.ts diff --git a/src/components/Projects/VariablesManager.tsx b/src/components/Projects/VariablesManager.tsx index 111bbcd..b7e02ff 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); }; @@ -92,13 +97,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 +238,11 @@ export const VariablesManager: React.FC = ({ project, on wordBreak: 'break-all', }} > - {value || (empty)} + {IsMaskedSecret(value) ? ( + (set, hidden) + ) : ( + value || (empty) + )} @@ -296,7 +307,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/i18n/locales/ar.json b/src/i18n/locales/ar.json index aabb9ad..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": "عمليات النشر", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f63af03..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", diff --git a/src/pages/Projects/ProjectDetailsPage.tsx b/src/pages/Projects/ProjectDetailsPage.tsx index edf8cea..8ce7bd2 100644 --- a/src/pages/Projects/ProjectDetailsPage.tsx +++ b/src/pages/Projects/ProjectDetailsPage.tsx @@ -61,6 +61,7 @@ export const ProjectDetailsPage: React.FC = () => { const deleteProject = useDeleteProject(); const deployProject = useDeployProject(); const regenerateWebhook = useRegenerateWebhook(); + const [RevealedWebhookSecret, setRevealedWebhookSecret] = useState(null); // Local UI state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -140,7 +141,9 @@ export const ProjectDetailsPage: React.FC = () => { if (!window.confirm(t("projects.webhookRegenerationWarning"))) return; regenerateWebhook.mutate(project.Id, { - onSuccess: () => { + onSuccess: (secret) => { + // Write-only: this is the only time the new secret is available. + setRevealedWebhookSecret(secret || null); showSuccess(t("projects.webhookRegenerated")); }, onError: (error: Error) => { @@ -228,6 +231,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 && (