Skip to content
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -67,4 +67,4 @@
"vite": "^7.2.4",
"vitest": "^4.0.16"
}
}
}
4 changes: 1 addition & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/Layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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[] = [
Expand Down
1 change: 1 addition & 0 deletions src/components/Projects/DeploymentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const DeploymentModal: React.FC<IDeploymentModalProps> = ({
// 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('');
Expand Down
1 change: 1 addition & 0 deletions src/components/Projects/ProjectFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const ProjectFormModal: React.FC<IProjectFormModalProps> = ({
// 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);
Expand Down
15 changes: 8 additions & 7 deletions src/components/Projects/SshKeyManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,6 @@ export const SshKeyManagement: React.FC<ISshKeyManagementProps> = ({

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);
Expand All @@ -76,6 +69,14 @@ export const SshKeyManagement: React.FC<ISshKeyManagementProps> = ({
}
};

// 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('');
Expand Down
31 changes: 27 additions & 4 deletions src/components/Projects/VariablesManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,6 +44,8 @@ export const VariablesManager: React.FC<IVariablesManagerProps> = ({ project, on
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [editingKey, setEditingKey] = useState<string | null>(null);
// True while editing a write-only value: empty input means "keep current".
const [editingMasked, setEditingMasked] = useState(false);
const [localVariables, setLocalVariables] = useState<Record<string, string>>(
project.Config?.Variables || {}
);
Expand Down Expand Up @@ -74,7 +77,9 @@ export const VariablesManager: React.FC<IVariablesManagerProps> = ({ 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);
};

Expand All @@ -84,6 +89,13 @@ export const VariablesManager: React.FC<IVariablesManagerProps> = ({ 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 };

Expand All @@ -92,13 +104,15 @@ export const VariablesManager: React.FC<IVariablesManagerProps> = ({ 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;
Comment thread
sabrydawood marked this conversation as resolved.
Resolved
return updated;
});

setNewKey('');
setNewValue('');
setEditingKey(null);
setEditingMasked(false);
setDialogOpen(false);
};

Expand Down Expand Up @@ -231,7 +245,11 @@ export const VariablesManager: React.FC<IVariablesManagerProps> = ({ project, on
wordBreak: 'break-all',
}}
>
{value || <em style={{ color: '#999' }}>(empty)</em>}
{IsMaskedSecret(value) ? (
<em style={{ color: '#999' }}>(set, hidden)</em>
) : (
value || <em style={{ color: '#999' }}>(empty)</em>
)}
</Typography>
</Box>

Expand Down Expand Up @@ -296,7 +314,12 @@ export const VariablesManager: React.FC<IVariablesManagerProps> = ({ 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}
/>
Expand Down
4 changes: 2 additions & 2 deletions src/components/RoleGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/components/Settings/SecurityTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ export const SecurityTab: React.FC<ISecurityTabProps> = ({ t }) => {
{errorMessage}
</Alert>
)}
{twoFactorEnabled && twoFAStatus?.legacyBackupCodesInvalidated && (
<Alert severity="warning" sx={{ mb: 2 }}>
{t("settings.legacyBackupCodesInvalidated")}
</Alert>
)}

{!twoFactorEnabled && (
<Stack spacing={2}>
Expand Down
20 changes: 12 additions & 8 deletions src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,7 +22,7 @@ interface IAuthContextValue {
HasSession: boolean;
CurrentSessionId: number | null;
Login: (credentials: ILoginCredentials) => Promise<IAuthResponse | ITwoFactorChallenge>;
Verify2FA: (userId: number, code: string) => Promise<IAuthResponse>;
Verify2FA: (code: string) => Promise<IAuthResponse>;
Register: (data: IRegisterData) => Promise<void>;
Logout: () => Promise<void>;
RefreshUser: () => Promise<void>;
Expand All @@ -35,14 +36,15 @@ interface IAuthProviderProps {

export const AuthProvider: React.FC<IAuthProviderProps> = ({ children }) => {
const [User, setUser] = useState<IUser | null>(null);
const [IsLoading, setIsLoading] = useState<boolean>(true);
const [HasSession, setHasSession] = useState<boolean>(() => !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<boolean>(HasSession);
const [CurrentSessionId, setCurrentSessionId] = useState<number | null>(null);

// Initialize auth state by checking if user is authenticated via cookie
useEffect(() => {
if (!HasSession) {
setIsLoading(false);
return;
}

Expand Down Expand Up @@ -86,10 +88,12 @@ export const AuthProvider: React.FC<IAuthProviderProps> = ({ children }) => {
}
};

const Verify2FA = async (userId: number, code: string): Promise<IAuthResponse> => {
// 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<IAuthResponse> => {
try {
setIsLoading(true);
const response = await AuthService.Verify2FA(userId, code);
const response = await AuthService.Verify2FA(code);
setUser(response.User);
setCurrentSessionId(response.SessionId);
setHasSession(true);
Expand All @@ -98,8 +102,6 @@ export const AuthProvider: React.FC<IAuthProviderProps> = ({ children }) => {
} catch (error) {
console.error('2FA verification error:', error);
throw error;
} finally {
setIsLoading(false);
}
};

Expand All @@ -126,6 +128,8 @@ export const AuthProvider: React.FC<IAuthProviderProps> = ({ 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);
Expand Down
10 changes: 5 additions & 5 deletions src/contexts/RoleContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,7 +26,7 @@ interface IRoleContext {
canManageUsers: boolean;
canDeploy: boolean;
canViewReports: boolean;
hasRole: (roles: UserRole[]) => boolean;
hasRole: (roles: TRoleName[]) => boolean;
}

const RoleContext = createContext<IRoleContext | undefined>(undefined);
Expand All @@ -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;
Expand All @@ -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]);

Expand Down
1 change: 1 addition & 0 deletions src/contexts/UserSettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const UserSettingsProvider: React.FC<IUserSettingsProviderProps> = ({ 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]);

Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useUserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import UserSettingsService from '@/services/userSettingsService';
import type {
ITwoFactorStatus,
IUserSettings,
INotificationSettings,
IUserPreferences,
Expand Down Expand Up @@ -43,7 +44,7 @@ export const useUserSessions = () => {
* Custom hook to fetch 2FA status
*/
export const use2FAStatus = () => {
return useQuery<{ enabled: boolean }, Error>({
return useQuery<ITwoFactorStatus, Error>({
queryKey: ['2faStatus'],
queryFn: () => UserSettingsService.get2FAStatus(),
});
Expand Down
10 changes: 7 additions & 3 deletions src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@
"regenerateSshKeyWarning": "سيتم توليد مفتاح SSH جديد وإبطال المفتاح القديم. يجب عليك تحديث مفتاح النشر في مستودع GitHub الخاص بك. لا يمكن التراجع عن هذا الإجراء.",
"deleteSshKeyWarning": "سيتم حذف مفتاح SSH من هذا المشروع. ستعود عمليات النشر إلى استخدام HTTPS. لا يمكن التراجع عن هذا الإجراء.",
"processing": "جاري المعالجة...",
"webhookUrl": "لينك الويبهوك"
"webhookUrl": "لينك الويبهوك",
"secretSet": "متسجّل (مخفي). اعمل توليد جديد عشان تاخد سر جديد.",
"secretNotSet": "مش متسجّل",
"webhookSecretShownOnce": "انسخ السر ده دلوقتي وحدّثه في GitHub، مش هيظهر تاني."
},
"deployments": {
"title": "عمليات النشر",
Expand Down Expand Up @@ -423,7 +426,8 @@
"deleteAccountConfirm": "هل أنت متأكد من حذف حسابك؟",
"deleteAccountFailed": "فشل حذف الحساب",
"deleteAccountSuccess": "تم إرسال طلب حذف الحساب",
"close": "إغلاق"
"close": "إغلاق",
"legacyBackupCodesInvalidated": "أكواد الاسترداد الحالية اتعملت قبل الإصدار 3.0.1 ومبقتش مقبولة. اعمل أكواد جديدة دلوقتي واحفظها في مكان آمن."
},
"notifications": {
"title": "الإشعارات",
Expand Down Expand Up @@ -613,4 +617,4 @@
"maintenance": "الخدمة في صيانة. يرجى المحاولة لاحقاً.",
"rateLimit": "تم تجاوز حد التكرار. يرجى الانتظار قبل إعادة المحاولة."
}
}
}
10 changes: 7 additions & 3 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -615,4 +619,4 @@
"maintenance": "Service is under maintenance. Please try again later.",
"rateLimit": "Rate limit exceeded. Please wait before retrying."
}
}
}
Loading