From 218c4cccbefadb865d10c2a2338a2e7e14888142 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Wed, 8 Oct 2025 11:25:04 +0000 Subject: [PATCH 1/3] feat: Implement first login password change and 2FA setup flow --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 29 +-- .../src/domains/account/account.validation.ts | 4 +- apps/api/src/domains/auth/auth.controller.ts | 93 ++++++-- apps/api/src/domains/auth/auth.repository.ts | 20 ++ apps/api/src/domains/auth/auth.routes.ts | 8 + apps/api/src/domains/auth/auth.service.ts | 82 +++++++- apps/api/src/domains/auth/auth.types.ts | 11 +- .../auth/dto/first-login-password.dto.ts | 29 +++ apps/api/src/domains/auth/dto/index.ts | 1 + apps/api/src/utils/jwt.ts | 14 ++ apps/web/src/auth.tsx | 4 + apps/web/src/components/pages/Account.tsx | 11 +- .../src/components/pages/Force2FASetup.tsx | 198 ++++++++++++++++++ .../components/pages/ForcePasswordChange.tsx | 179 ++++++++++++++++ apps/web/src/components/pages/Login.tsx | 76 +++++-- apps/web/src/services/auth.service.ts | 16 ++ 17 files changed, 725 insertions(+), 52 deletions(-) create mode 100644 apps/api/prisma/migrations/20251008110124_add_first_login_flag/migration.sql create mode 100644 apps/api/src/domains/auth/dto/first-login-password.dto.ts create mode 100644 apps/web/src/components/pages/Force2FASetup.tsx create mode 100644 apps/web/src/components/pages/ForcePasswordChange.tsx diff --git a/apps/api/prisma/migrations/20251008110124_add_first_login_flag/migration.sql b/apps/api/prisma/migrations/20251008110124_add_first_login_flag/migration.sql new file mode 100644 index 0000000..64a6721 --- /dev/null +++ b/apps/api/prisma/migrations/20251008110124_add_first_login_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "isFirstLogin" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4679e1f..492f0c0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -32,20 +32,21 @@ enum ActivityType { } model User { - id String @id @default(cuid()) - username String @unique - email String @unique - password String - fullName String - role UserRole @default(viewer) - status UserStatus @default(active) - avatar String? - phone String? - timezone String @default("Asia/Ho_Chi_Minh") - language String @default("en") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastLogin DateTime? + id String @id @default(cuid()) + username String @unique + email String @unique + password String + fullName String + role UserRole @default(viewer) + status UserStatus @default(active) + avatar String? + phone String? + timezone String @default("Asia/Ho_Chi_Minh") + language String @default("en") + isFirstLogin Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? // Relations profile UserProfile? diff --git a/apps/api/src/domains/account/account.validation.ts b/apps/api/src/domains/account/account.validation.ts index bb1a7ad..d295431 100644 --- a/apps/api/src/domains/account/account.validation.ts +++ b/apps/api/src/domains/account/account.validation.ts @@ -41,8 +41,8 @@ export const changePasswordValidation: ValidationChain[] = [ .withMessage('New password is required') .isLength({ min: 8 }) .withMessage('New password must be at least 8 characters') - .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) - .withMessage('Password must contain uppercase, lowercase, and number'), + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/) + .withMessage('Password must contain uppercase, lowercase, number, and special character (@$!%*?&)'), body('confirmPassword') .notEmpty() .withMessage('Confirm password is required') diff --git a/apps/api/src/domains/auth/auth.controller.ts b/apps/api/src/domains/auth/auth.controller.ts index 5fb2cf2..b344413 100644 --- a/apps/api/src/domains/auth/auth.controller.ts +++ b/apps/api/src/domains/auth/auth.controller.ts @@ -2,8 +2,8 @@ import { Request, Response } from 'express'; import { validationResult } from 'express-validator'; import { AuthService } from './auth.service'; import { AuthRepository } from './auth.repository'; -import { LoginDto, LogoutDto, RefreshTokenDto, Verify2FADto } from './dto'; -import { RequestMetadata } from './auth.types'; +import { LoginDto, LogoutDto, RefreshTokenDto, Verify2FADto, FirstLoginPasswordDto } from './dto'; +import { RequestMetadata, LoginFirstTimeResult, Login2FARequiredResult } from './auth.types'; import logger from '../../utils/logger'; import { AppError } from '../../shared/errors/app-error'; @@ -45,30 +45,49 @@ export class AuthController { // Call service const result = await this.authService.login(dto, metadata); + // Check if password change is required (first login) + if ('requirePasswordChange' in result && result.requirePasswordChange) { + const firstLoginResult = result as LoginFirstTimeResult; + res.json({ + success: true, + message: 'Password change required', + data: { + requirePasswordChange: true, + userId: firstLoginResult.userId, + tempToken: firstLoginResult.tempToken, + user: firstLoginResult.user, + }, + }); + return; + } + // Check if 2FA is required - if ('requires2FA' in result) { + if ('requires2FA' in result && result.requires2FA) { + const twoFAResult = result as Login2FARequiredResult; res.json({ success: true, message: '2FA verification required', data: { - requires2FA: result.requires2FA, - userId: result.userId, - user: result.user, + requires2FA: true, + userId: twoFAResult.userId, + user: twoFAResult.user, }, }); return; } - // Return successful login response - res.json({ - success: true, - message: 'Login successful', - data: { - user: result.user, - accessToken: result.accessToken, - refreshToken: result.refreshToken, - }, - }); + // Return successful login response (LoginResult type) + if ('accessToken' in result && 'refreshToken' in result) { + res.json({ + success: true, + message: 'Login successful', + data: { + user: result.user, + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }, + }); + } } catch (error) { this.handleError(error, res); } @@ -180,6 +199,48 @@ export class AuthController { } }; + /** + * Change password on first login + * POST /api/auth/first-login/change-password + */ + changePasswordFirstLogin = async (req: Request, res: Response): Promise => { + try { + // Validate request + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + errors: errors.array(), + }); + return; + } + + const dto: FirstLoginPasswordDto = req.body; + + // Extract request metadata + const metadata: RequestMetadata = { + ip: req.ip || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown', + }; + + // Call service + const result = await this.authService.changePasswordFirstLogin(dto, metadata); + + // Return success response + res.json({ + success: true, + message: 'Password changed successfully', + data: { + require2FASetup: result.require2FASetup, + userId: result.userId, + user: result.user, + }, + }); + } catch (error) { + this.handleError(error, res); + } + }; + /** * Handle errors and send appropriate HTTP responses */ diff --git a/apps/api/src/domains/auth/auth.repository.ts b/apps/api/src/domains/auth/auth.repository.ts index 6e259c2..8fcf43d 100644 --- a/apps/api/src/domains/auth/auth.repository.ts +++ b/apps/api/src/domains/auth/auth.repository.ts @@ -134,4 +134,24 @@ export class AuthRepository { } return true; } + + /** + * Update user password + */ + async updateUserPassword(userId: string, hashedPassword: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { password: hashedPassword }, + }); + } + + /** + * Update user first login status + */ + async updateUserFirstLoginStatus(userId: string, isFirstLogin: boolean): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { isFirstLogin }, + }); + } } diff --git a/apps/api/src/domains/auth/auth.routes.ts b/apps/api/src/domains/auth/auth.routes.ts index 9530369..6aaca9f 100644 --- a/apps/api/src/domains/auth/auth.routes.ts +++ b/apps/api/src/domains/auth/auth.routes.ts @@ -4,6 +4,7 @@ import { loginValidation, verify2FAValidation, refreshTokenValidation, + firstLoginPasswordValidation, } from './dto'; const router = Router(); @@ -37,4 +38,11 @@ router.post('/logout', authController.logout); */ router.post('/refresh', refreshTokenValidation, authController.refreshAccessToken); +/** + * @route POST /api/auth/first-login/change-password + * @desc Change password on first login + * @access Public + */ +router.post('/first-login/change-password', firstLoginPasswordValidation, authController.changePasswordFirstLogin); + export default router; diff --git a/apps/api/src/domains/auth/auth.service.ts b/apps/api/src/domains/auth/auth.service.ts index d8c9c3d..0ba513f 100644 --- a/apps/api/src/domains/auth/auth.service.ts +++ b/apps/api/src/domains/auth/auth.service.ts @@ -1,5 +1,5 @@ -import { comparePassword } from '../../utils/password'; -import { generateAccessToken, generateRefreshToken } from '../../utils/jwt'; +import { comparePassword, hashPassword } from '../../utils/password'; +import { generateAccessToken, generateRefreshToken, generateTempToken, verifyTempToken } from '../../utils/jwt'; import { verify2FAToken } from '../../utils/twoFactor'; import logger from '../../utils/logger'; import { AuthRepository } from './auth.repository'; @@ -8,11 +8,13 @@ import { RefreshTokenDto, Verify2FADto, LogoutDto, + FirstLoginPasswordDto, } from './dto'; import { LoginResponse, LoginResult, Login2FARequiredResult, + LoginFirstTimeResult, RefreshTokenResult, RequestMetadata, TokenPayload, @@ -81,6 +83,23 @@ export class AuthService { throw new AuthenticationError('Invalid credentials'); } + // Check if this is first login + if (user.isFirstLogin) { + logger.info(`User ${username} is logging in for the first time`); + + const userData = this.mapUserData(user); + const tempToken = generateTempToken(user.id); + + const result: LoginFirstTimeResult = { + requirePasswordChange: true, + userId: user.id, + tempToken, + user: userData, + }; + + return result; + } + // Check if 2FA is enabled if (user.twoFactor?.enabled) { logger.info(`User ${username} requires 2FA verification`); @@ -197,6 +216,65 @@ export class AuthService { return { accessToken }; } + /** + * Change password on first login + */ + async changePasswordFirstLogin( + dto: FirstLoginPasswordDto, + metadata: RequestMetadata + ): Promise<{ require2FASetup: boolean; userId: string; user: UserData }> { + const { userId, tempToken, newPassword } = dto; + + // Verify temp token + try { + const payload = verifyTempToken(tempToken); + if (payload.userId !== userId) { + throw new AuthenticationError('Invalid token'); + } + } catch (error) { + throw new AuthenticationError('Invalid or expired token'); + } + + // Find user + const user = await this.authRepository.findUserById(userId); + if (!user) { + throw new NotFoundError('User not found'); + } + + // Check if user is still in first login state + if (!user.isFirstLogin) { + throw new ValidationError('Password has already been changed'); + } + + // Hash new password + const hashedPassword = await hashPassword(newPassword); + + // Update password and set isFirstLogin to false + await this.authRepository.updateUserPassword(userId, hashedPassword); + await this.authRepository.updateUserFirstLoginStatus(userId, false); + + // Log activity + await this.authRepository.createActivityLog( + userId, + 'Changed password on first login', + 'security', + metadata, + true + ); + + logger.info(`User ${user.username} changed password on first login`); + + // Check if 2FA is enabled + const require2FASetup = !user.twoFactor?.enabled; + const userData = this.mapUserData(user); + + return { + require2FASetup, + userId: user.id, + user: userData, + }; + } + /** * Complete login process (generate tokens, update user, create session, log activity) */ diff --git a/apps/api/src/domains/auth/auth.types.ts b/apps/api/src/domains/auth/auth.types.ts index 317561d..d567b1d 100644 --- a/apps/api/src/domains/auth/auth.types.ts +++ b/apps/api/src/domains/auth/auth.types.ts @@ -33,6 +33,8 @@ export interface LoginResult { user: UserData; accessToken: string; refreshToken: string; + requirePasswordChange?: boolean; + require2FASetup?: boolean; } export interface Login2FARequiredResult { @@ -41,7 +43,14 @@ export interface Login2FARequiredResult { user: UserData; } -export type LoginResponse = LoginResult | Login2FARequiredResult; +export interface LoginFirstTimeResult { + requirePasswordChange: true; + userId: string; + tempToken: string; + user: UserData; +} + +export type LoginResponse = LoginResult | Login2FARequiredResult | LoginFirstTimeResult; export interface RefreshTokenResult { accessToken: string; diff --git a/apps/api/src/domains/auth/dto/first-login-password.dto.ts b/apps/api/src/domains/auth/dto/first-login-password.dto.ts new file mode 100644 index 0000000..26527e7 --- /dev/null +++ b/apps/api/src/domains/auth/dto/first-login-password.dto.ts @@ -0,0 +1,29 @@ +import { body, ValidationChain } from 'express-validator'; + +/** + * First login password change request DTO + */ +export interface FirstLoginPasswordDto { + userId: string; + tempToken: string; + newPassword: string; +} + +/** + * First login password change validation rules + */ +export const firstLoginPasswordValidation: ValidationChain[] = [ + body('userId') + .notEmpty() + .withMessage('User ID is required'), + body('tempToken') + .notEmpty() + .withMessage('Temporary token is required'), + body('newPassword') + .notEmpty() + .withMessage('New password is required') + .isLength({ min: 8 }) + .withMessage('Password must be at least 8 characters') + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/) + .withMessage('Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), +]; diff --git a/apps/api/src/domains/auth/dto/index.ts b/apps/api/src/domains/auth/dto/index.ts index 6cb01ae..de95c92 100644 --- a/apps/api/src/domains/auth/dto/index.ts +++ b/apps/api/src/domains/auth/dto/index.ts @@ -2,3 +2,4 @@ export * from './login.dto'; export * from './logout.dto'; export * from './refresh-token.dto'; export * from './verify-2fa.dto'; +export * from './first-login-password.dto'; diff --git a/apps/api/src/utils/jwt.ts b/apps/api/src/utils/jwt.ts index 6653412..39958f6 100644 --- a/apps/api/src/utils/jwt.ts +++ b/apps/api/src/utils/jwt.ts @@ -27,3 +27,17 @@ export const verifyAccessToken = (token: string): TokenPayload => { export const verifyRefreshToken = (token: string): TokenPayload => { return jwt.verify(token, config.jwt.refreshSecret) as TokenPayload; }; + +export const generateTempToken = (userId: string): string => { + return jwt.sign({ userId, type: 'temp' }, config.jwt.accessSecret, { + expiresIn: '15m', // Temporary token valid for 15 minutes + } as any); +}; + +export const verifyTempToken = (token: string): { userId: string; type: string } => { + const payload = jwt.verify(token, config.jwt.accessSecret) as any; + if (payload.type !== 'temp') { + throw new Error('Invalid token type'); + } + return payload; +}; diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 8b69976..ddd583e 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -17,6 +17,10 @@ export interface LoginResponse { accessToken: string refreshToken: string requires2FA: boolean + requirePasswordChange?: boolean + require2FASetup?: boolean + userId?: string + tempToken?: string } const AuthContext = React.createContext(null) diff --git a/apps/web/src/components/pages/Account.tsx b/apps/web/src/components/pages/Account.tsx index be43d35..ad9d309 100644 --- a/apps/web/src/components/pages/Account.tsx +++ b/apps/web/src/components/pages/Account.tsx @@ -125,9 +125,16 @@ const Account = () => { return; } - if (passwordForm.newPassword.length < 8) { + // Validate password strength + const hasMinLength = passwordForm.newPassword.length >= 8; + const hasUpperCase = /[A-Z]/.test(passwordForm.newPassword); + const hasLowerCase = /[a-z]/.test(passwordForm.newPassword); + const hasNumber = /\d/.test(passwordForm.newPassword); + const hasSpecialChar = /[@$!%*?&]/.test(passwordForm.newPassword); + + if (!hasMinLength || !hasUpperCase || !hasLowerCase || !hasNumber || !hasSpecialChar) { toast.error("Weak password", { - description: "Password must be at least 8 characters long" + description: "Password must be at least 8 characters and contain uppercase, lowercase, number, and special character (@$!%*?&)" }); return; } diff --git a/apps/web/src/components/pages/Force2FASetup.tsx b/apps/web/src/components/pages/Force2FASetup.tsx new file mode 100644 index 0000000..232866f --- /dev/null +++ b/apps/web/src/components/pages/Force2FASetup.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect } from 'react'; +import { Shield, CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { toast } from 'sonner'; +import { accountService } from '@/services/auth.service'; + +interface Force2FASetupProps { + onComplete: () => void; +} + +export default function Force2FASetup({ onComplete }: Force2FASetupProps) { + const [twoFactorSetup, setTwoFactorSetup] = useState<{ secret: string; qrCode: string; backupCodes: string[] } | null>(null); + const [verificationToken, setVerificationToken] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Setup 2FA on component mount + const setup2FA = async () => { + try { + const setup = await accountService.setup2FA(); + setTwoFactorSetup(setup); + } catch (error: any) { + toast.error('Failed to setup 2FA', { + description: error.response?.data?.message || 'Please try again', + }); + } finally { + setIsLoading(false); + } + }; + + setup2FA(); + }, []); + + const handleVerify2FA = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!verificationToken || verificationToken.length !== 6) { + toast.error('Invalid token', { + description: 'Please enter a 6-digit code', + }); + return; + } + + setIsSubmitting(true); + + try { + await accountService.enable2FA(verificationToken); + toast.success('🛡️ 2FA Enabled Successfully', { + description: 'Your account is now secured with two-factor authentication!', + }); + + // Complete the setup + onComplete(); + } catch (error: any) { + toast.error('Verification failed', { + description: error.response?.data?.message || 'Invalid verification code', + }); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+ + +
+
+

Setting up 2FA...

+
+
+
+
+ ); + } + + if (!twoFactorSetup) { + return ( +
+ + + + + Failed to setup 2FA. Please contact administrator. + + + + +
+ ); + } + + return ( +
+ + +
+
+ +
+
+ Enable Two-Factor Authentication + + For security, you must enable 2FA before using this system + +
+ + + + + This is a mandatory security requirement. You cannot proceed without enabling 2FA. + + + +
+
+

Step 1: Scan QR Code

+

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.) +

+
+ 2FA QR Code +
+

+ Manual Entry: {twoFactorSetup.secret} +

+
+ +
+

Step 2: Save Backup Codes

+

+ Save these backup codes in a secure location. You can use them to access your account if you lose your device. +

+
+ {twoFactorSetup.backupCodes.map((code, index) => ( +
+ {code} + +
+ ))} +
+
+ +
+
+

Step 3: Verify Setup

+

+ Enter the 6-digit code from your authenticator app to verify and enable 2FA. +

+ setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))} + maxLength={6} + className="text-center text-2xl tracking-widest font-mono" + required + /> +
+ + +
+
+ + + + + Once verified, you'll be able to access the system with your new password and 2FA enabled. + + +
+
+
+ ); +} diff --git a/apps/web/src/components/pages/ForcePasswordChange.tsx b/apps/web/src/components/pages/ForcePasswordChange.tsx new file mode 100644 index 0000000..cf3a0e2 --- /dev/null +++ b/apps/web/src/components/pages/ForcePasswordChange.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react'; +import { Shield, Eye, EyeOff, CheckCircle2, XCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { toast } from 'sonner'; +import { authService } from '@/services/auth.service'; + +interface ForcePasswordChangeProps { + userId: string; + tempToken: string; + onPasswordChanged: (require2FASetup: boolean) => void; +} + +export default function ForcePasswordChange({ userId, tempToken, onPasswordChanged }: ForcePasswordChangeProps) { + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Password strength validation + const hasMinLength = newPassword.length >= 8; + const hasUpperCase = /[A-Z]/.test(newPassword); + const hasLowerCase = /[a-z]/.test(newPassword); + const hasNumber = /\d/.test(newPassword); + const hasSpecialChar = /[@$!%*?&]/.test(newPassword); + const passwordsMatch = newPassword === confirmPassword && newPassword.length > 0; + const isPasswordValid = hasMinLength && hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isPasswordValid) { + toast.error('Password does not meet security requirements'); + return; + } + + if (!passwordsMatch) { + toast.error('Passwords do not match'); + return; + } + + setIsSubmitting(true); + + try { + const result = await authService.changePasswordFirstLogin({ + userId, + tempToken, + newPassword, + }); + + toast.success('Password changed successfully'); + + // Proceed to next step + onPasswordChanged(result.require2FASetup); + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Failed to change password'; + toast.error(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + +
+
+ +
+
+ Change Default Password + + For security reasons, you must change your default password before continuing + +
+ +
+ + + + Your password must meet the security requirements below + + + +
+ +
+ setNewPassword(e.target.value)} + placeholder="Enter new password" + required + className="pr-10" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + className="pr-10" + /> + +
+
+ + {/* Password Requirements */} +
+

Password Requirements:

+
+
+ {hasMinLength ? : } + At least 8 characters +
+
+ {hasUpperCase ? : } + Contains uppercase letter (A-Z) +
+
+ {hasLowerCase ? : } + Contains lowercase letter (a-z) +
+
+ {hasNumber ? : } + Contains number (0-9) +
+
+ {hasSpecialChar ? : } + Contains special character (@$!%*?&) +
+ {confirmPassword && ( +
+ {passwordsMatch ? : } + Passwords match +
+ )} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/pages/Login.tsx b/apps/web/src/components/pages/Login.tsx index 531c186..4cf8957 100644 --- a/apps/web/src/components/pages/Login.tsx +++ b/apps/web/src/components/pages/Login.tsx @@ -9,19 +9,24 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { useAuth } from '@/auth'; import { toast } from 'sonner'; import { Route } from '@/routes/login'; +import ForcePasswordChange from './ForcePasswordChange'; +import Force2FASetup from './Force2FASetup'; + +type LoginStep = 'login' | 'passwordChange' | '2faSetup' | '2faVerify'; export default function Login() { const { t } = useTranslation(); const router = useRouter(); const isLoading = useRouterState({ select: (s) => s.isLoading }); - const { login, loginWith2FA, isAuthenticated, isLoading: authLoading } = useAuth(); + const { login, loginWith2FA, isLoading: authLoading } = useAuth(); const navigate = Route.useNavigate(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [twoFactor, setTwoFactor] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [requires2FA, setRequires2FA] = useState(false); + const [currentStep, setCurrentStep] = useState('login'); const [userId, setUserId] = useState(''); + const [tempToken, setTempToken] = useState(''); const search = Route.useSearch(); @@ -32,9 +37,9 @@ export default function Login() { setIsSubmitting(true); try { - if (requires2FA && userId) { + if (currentStep === '2faVerify' && userId) { // Verify 2FA token - const response = await loginWith2FA(userId, twoFactor); + await loginWith2FA(userId, twoFactor); toast.success('Login successful'); await router.invalidate(); @@ -47,11 +52,19 @@ export default function Login() { // Initial login const response = await login(username, password); - if (response.requires2FA) { - setRequires2FA(true); + if (response.requirePasswordChange && response.tempToken) { + // First login - need to change password + setUserId(response.userId || ''); + setTempToken(response.tempToken); + setCurrentStep('passwordChange'); + toast.info('Please change your default password'); + } else if (response.requires2FA) { + // 2FA required setUserId(response.user.id); + setCurrentStep('2faVerify'); toast.info('Please enter your 2FA code'); } else { + // Login successful toast.success('Login successful'); await router.invalidate(); @@ -70,6 +83,39 @@ export default function Login() { } }; + const handlePasswordChanged = (require2FASetup: boolean) => { + if (require2FASetup) { + setCurrentStep('2faSetup'); + toast.info('Please setup 2FA to secure your account'); + } else { + // If 2FA already enabled, go to 2FA verify step + setCurrentStep('2faVerify'); + toast.info('Please enter your 2FA code'); + } + }; + + const handle2FASetupComplete = async () => { + toast.success('Setup complete! Redirecting to dashboard...'); + + await router.invalidate(); + + // Wait a moment for auth state to update + await new Promise(resolve => setTimeout(resolve, 500)); + + await navigate({ to: search.redirect || '/dashboard' }); + }; + + // Show password change screen + if (currentStep === 'passwordChange') { + return ; + } + + // Show 2FA setup screen + if (currentStep === '2faSetup') { + return ; + } + + // Show login/2FA verify screen return (
@@ -80,10 +126,10 @@ export default function Login() {
- {requires2FA ? 'Two-Factor Authentication' : t('login.title')} + {currentStep === '2faVerify' ? 'Two-Factor Authentication' : t('login.title')} - {requires2FA + {currentStep === '2faVerify' ? 'Enter the 6-digit code from your authenticator app' : 'Nginx + ModSecurity Management Portal' } @@ -91,7 +137,7 @@ export default function Login() {
- {!requires2FA && ( + {currentStep === 'login' && ( <>
@@ -118,7 +164,7 @@ export default function Login() { )} - {requires2FA && ( + {currentStep === '2faVerify' && (
)} - - {requires2FA && ( + {currentStep === '2faVerify' && ( )} - {!requires2FA && ( + {currentStep === 'login' && (

)} diff --git a/apps/web/src/services/auth.service.ts b/apps/web/src/services/auth.service.ts index f6bfec9..d02f605 100644 --- a/apps/web/src/services/auth.service.ts +++ b/apps/web/src/services/auth.service.ts @@ -12,6 +12,16 @@ export interface LoginResponse { accessToken: string; refreshToken: string; requires2FA: boolean; + requirePasswordChange?: boolean; + require2FASetup?: boolean; + userId?: string; + tempToken?: string; +} + +export interface FirstLoginPasswordChangeRequest { + userId: string; + tempToken: string; + newPassword: string; } export interface ChangePasswordRequest { @@ -59,6 +69,12 @@ export const authService = { return response.data.data; }, + // Change password on first login + changePasswordFirstLogin: async (data: FirstLoginPasswordChangeRequest): Promise<{ require2FASetup: boolean; userId: string; user: UserProfile }> => { + const response = await api.post('/auth/first-login/change-password', data); + return response.data.data; + }, + // Verify 2FA during login verify2FA: async (data: Verify2FARequest): Promise => { const response = await api.post('/auth/verify-2fa', data); From 01ccf7b667207fc54bae4f7fbd454724e434b9ed Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Wed, 8 Oct 2025 12:09:02 +0000 Subject: [PATCH 2/3] feat: Enhance first login flow with token return and 2FA setup option --- apps/api/src/domains/auth/auth.controller.ts | 7 +- apps/api/src/domains/auth/auth.service.ts | 15 ++- .../src/components/pages/Force2FASetup.tsx | 93 +++++++++++++++++-- .../components/pages/ForcePasswordChange.tsx | 5 + apps/web/src/components/pages/Login.tsx | 13 ++- apps/web/src/services/auth.service.ts | 2 +- 6 files changed, 116 insertions(+), 19 deletions(-) diff --git a/apps/api/src/domains/auth/auth.controller.ts b/apps/api/src/domains/auth/auth.controller.ts index b344413..1d657cb 100644 --- a/apps/api/src/domains/auth/auth.controller.ts +++ b/apps/api/src/domains/auth/auth.controller.ts @@ -226,14 +226,15 @@ export class AuthController { // Call service const result = await this.authService.changePasswordFirstLogin(dto, metadata); - // Return success response + // Return success response with tokens res.json({ success: true, message: 'Password changed successfully', data: { - require2FASetup: result.require2FASetup, - userId: result.userId, user: result.user, + accessToken: result.accessToken, + refreshToken: result.refreshToken, + require2FASetup: result.require2FASetup, }, }); } catch (error) { diff --git a/apps/api/src/domains/auth/auth.service.ts b/apps/api/src/domains/auth/auth.service.ts index 0ba513f..ed8f9b8 100644 --- a/apps/api/src/domains/auth/auth.service.ts +++ b/apps/api/src/domains/auth/auth.service.ts @@ -222,7 +222,7 @@ export class AuthService { async changePasswordFirstLogin( dto: FirstLoginPasswordDto, metadata: RequestMetadata - ): Promise<{ require2FASetup: boolean; userId: string; user: UserData }> { + ): Promise { const { userId, tempToken, newPassword } = dto; // Verify temp token @@ -264,14 +264,13 @@ export class AuthService { logger.info(`User ${user.username} changed password on first login`); - // Check if 2FA is enabled - const require2FASetup = !user.twoFactor?.enabled; - const userData = this.mapUserData(user); - + // Generate tokens and complete login (no need to login again) + const result = await this.completeLogin(user, metadata, false); + + // Add flag to indicate if 2FA setup is needed return { - require2FASetup, - userId: user.id, - user: userData, + ...result, + require2FASetup: !user.twoFactor?.enabled, }; } diff --git a/apps/web/src/components/pages/Force2FASetup.tsx b/apps/web/src/components/pages/Force2FASetup.tsx index 232866f..ca9a917 100644 --- a/apps/web/src/components/pages/Force2FASetup.tsx +++ b/apps/web/src/components/pages/Force2FASetup.tsx @@ -1,21 +1,33 @@ import { useState, useEffect } from 'react'; -import { Shield, CheckCircle2 } from 'lucide-react'; +import { Shield, CheckCircle2, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; import { accountService } from '@/services/auth.service'; interface Force2FASetupProps { onComplete: () => void; + onSkip?: () => void; } -export default function Force2FASetup({ onComplete }: Force2FASetupProps) { +export default function Force2FASetup({ onComplete, onSkip }: Force2FASetupProps) { const [twoFactorSetup, setTwoFactorSetup] = useState<{ secret: string; qrCode: string; backupCodes: string[] } | null>(null); const [verificationToken, setVerificationToken] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [showSkipDialog, setShowSkipDialog] = useState(false); useEffect(() => { // Setup 2FA on component mount @@ -64,6 +76,20 @@ export default function Force2FASetup({ onComplete }: Force2FASetupProps) { } }; + const handleSkip = () => { + setShowSkipDialog(true); + }; + + const handleConfirmSkip = () => { + setShowSkipDialog(false); + toast.warning('⚠️ 2FA Not Enabled', { + description: 'Your account is not protected by two-factor authentication', + }); + if (onSkip) { + onSkip(); + } + }; + if (isLoading) { return (
@@ -96,8 +122,49 @@ export default function Force2FASetup({ onComplete }: Force2FASetupProps) { } return ( -
- + <> + {/* Security Warning Dialog */} + + + + + + Security Warning + + +

+ You are about to skip Two-Factor Authentication setup. +

+
+

+ ⚠️ Security Risks: +

+
    +
  • Your account will be vulnerable to unauthorized access
  • +
  • Single password authentication is not secure enough
  • +
  • Risk of account compromise increases significantly
  • +
  • System administrators may restrict your access
  • +
+
+

+ Are you sure you want to continue without 2FA? +

+
+
+ + Go Back & Setup 2FA + + I Understand the Risks, Continue + + +
+
+ +
+
@@ -106,14 +173,14 @@ export default function Force2FASetup({ onComplete }: Force2FASetupProps) {
Enable Two-Factor Authentication - For security, you must enable 2FA before using this system + Secure your account with an additional layer of protection - This is a mandatory security requirement. You cannot proceed without enabling 2FA. + Two-factor authentication significantly improves your account security by requiring both your password and a verification code. @@ -191,8 +258,22 @@ export default function Force2FASetup({ onComplete }: Force2FASetupProps) { Once verified, you'll be able to access the system with your new password and 2FA enabled. + + {onSkip && ( +
+ +
+ )}
+ ); } diff --git a/apps/web/src/components/pages/ForcePasswordChange.tsx b/apps/web/src/components/pages/ForcePasswordChange.tsx index cf3a0e2..0a6d9ba 100644 --- a/apps/web/src/components/pages/ForcePasswordChange.tsx +++ b/apps/web/src/components/pages/ForcePasswordChange.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Alert, AlertDescription } from '@/components/ui/alert'; import { toast } from 'sonner'; import { authService } from '@/services/auth.service'; +import { useAuthStorage } from '@/hooks/useAuthStorage'; interface ForcePasswordChangeProps { userId: string; @@ -15,6 +16,7 @@ interface ForcePasswordChangeProps { } export default function ForcePasswordChange({ userId, tempToken, onPasswordChanged }: ForcePasswordChangeProps) { + const { setAuth } = useAuthStorage(); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -52,6 +54,9 @@ export default function ForcePasswordChange({ userId, tempToken, onPasswordChang newPassword, }); + // Save auth tokens + setAuth(result.user, result.accessToken, result.refreshToken); + toast.success('Password changed successfully'); // Proceed to next step diff --git a/apps/web/src/components/pages/Login.tsx b/apps/web/src/components/pages/Login.tsx index 4cf8957..49a8b7c 100644 --- a/apps/web/src/components/pages/Login.tsx +++ b/apps/web/src/components/pages/Login.tsx @@ -105,6 +105,17 @@ export default function Login() { await navigate({ to: search.redirect || '/dashboard' }); }; + const handle2FASkip = async () => { + toast.info('Redirecting to dashboard...'); + + await router.invalidate(); + + // Wait a moment for auth state to update + await new Promise(resolve => setTimeout(resolve, 500)); + + await navigate({ to: search.redirect || '/dashboard' }); + }; + // Show password change screen if (currentStep === 'passwordChange') { return ; @@ -112,7 +123,7 @@ export default function Login() { // Show 2FA setup screen if (currentStep === '2faSetup') { - return ; + return ; } // Show login/2FA verify screen diff --git a/apps/web/src/services/auth.service.ts b/apps/web/src/services/auth.service.ts index d02f605..3a4f00f 100644 --- a/apps/web/src/services/auth.service.ts +++ b/apps/web/src/services/auth.service.ts @@ -70,7 +70,7 @@ export const authService = { }, // Change password on first login - changePasswordFirstLogin: async (data: FirstLoginPasswordChangeRequest): Promise<{ require2FASetup: boolean; userId: string; user: UserProfile }> => { + changePasswordFirstLogin: async (data: FirstLoginPasswordChangeRequest): Promise => { const response = await api.post('/auth/first-login/change-password', data); return response.data.data; }, From c8bae900ffea5b829aa7689ae7560b2feb8cbba8 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Wed, 8 Oct 2025 12:54:00 +0000 Subject: [PATCH 3/3] feat: Implement secure password generation and reset confirmation dialog in Users component --- apps/api/prisma/seed.ts | 4 +- apps/api/src/domains/users/users.service.ts | 7 +- apps/api/src/utils/password.ts | 48 ++++ apps/web/src/components/pages/Users.tsx | 263 ++++++++++++++++++-- 4 files changed, 301 insertions(+), 21 deletions(-) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 04704fb..a255af8 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -50,7 +50,7 @@ async function main() { password: operatorPassword, fullName: 'System Operator', role: 'moderator', - status: 'active', + status: 'inactive', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=operator', phone: '+84 987 654 321', timezone: 'Asia/Ho_Chi_Minh', @@ -73,7 +73,7 @@ async function main() { password: viewerPassword, fullName: 'Read Only User', role: 'viewer', - status: 'active', + status: 'inactive', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=viewer', timezone: 'Asia/Singapore', language: 'en', diff --git a/apps/api/src/domains/users/users.service.ts b/apps/api/src/domains/users/users.service.ts index c8a95d0..5fa5856 100644 --- a/apps/api/src/domains/users/users.service.ts +++ b/apps/api/src/domains/users/users.service.ts @@ -3,7 +3,7 @@ import { User, UserWithProfile, UserStatistics } from './users.types'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto, SelfUpdateUserDto } from './dto/update-user.dto'; import { UserQueryDto } from './dto/user-query.dto'; -import { hashPassword } from '../../utils/password'; +import { hashPassword, generateSecurePassword } from '../../utils/password'; import { ValidationError, NotFoundError, ConflictError } from '../../shared/errors/app-error'; import { UserStatus, UserRole } from '../../shared/types/common.types'; import prisma from '../../config/database'; @@ -238,9 +238,8 @@ export class UsersService { throw new NotFoundError('User not found'); } - // Generate temporary password (16 characters, alphanumeric) - const tempPassword = - Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8).toUpperCase(); + // Generate secure temporary password (16 characters with all required types) + const tempPassword = generateSecurePassword(16); const hashedPassword = await hashPassword(tempPassword); // Update user password diff --git a/apps/api/src/utils/password.ts b/apps/api/src/utils/password.ts index d84b1fb..0b19ec0 100644 --- a/apps/api/src/utils/password.ts +++ b/apps/api/src/utils/password.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcrypt'; import { config } from '../config'; +import crypto from 'crypto'; export const hashPassword = async (password: string): Promise => { return bcrypt.hash(password, config.security.bcryptRounds); @@ -11,3 +12,50 @@ export const comparePassword = async ( ): Promise => { return bcrypt.compare(password, hashedPassword); }; + +/** + * Generate a secure random password with all required character types + * @param length - Length of password (default: 16) + * @returns Secure password with uppercase, lowercase, numbers, and special characters + */ +export const generateSecurePassword = (length: number = 16): string => { + // Ensure minimum length of 12 for security + const finalLength = Math.max(length, 12); + + // Character sets + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const numbers = '0123456789'; + const special = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + + // Ensure at least one character from each set + const requiredChars = [ + uppercase[crypto.randomInt(0, uppercase.length)], + lowercase[crypto.randomInt(0, lowercase.length)], + numbers[crypto.randomInt(0, numbers.length)], + special[crypto.randomInt(0, special.length)], + ]; + + // All possible characters + const allChars = uppercase + lowercase + numbers + special; + + // Generate remaining random characters + const remainingLength = finalLength - requiredChars.length; + const randomChars: string[] = []; + + for (let i = 0; i < remainingLength; i++) { + const randomIndex = crypto.randomInt(0, allChars.length); + randomChars.push(allChars[randomIndex]); + } + + // Combine and shuffle all characters + const allPasswordChars = [...requiredChars, ...randomChars]; + + // Fisher-Yates shuffle algorithm for cryptographically secure shuffle + for (let i = allPasswordChars.length - 1; i > 0; i--) { + const j = crypto.randomInt(0, i + 1); + [allPasswordChars[i], allPasswordChars[j]] = [allPasswordChars[j], allPasswordChars[i]]; + } + + return allPasswordChars.join(''); +}; diff --git a/apps/web/src/components/pages/Users.tsx b/apps/web/src/components/pages/Users.tsx index bb8c5dd..86f9189 100644 --- a/apps/web/src/components/pages/Users.tsx +++ b/apps/web/src/components/pages/Users.tsx @@ -1,16 +1,16 @@ import { useState } from "react"; import { Suspense } from "react"; -import { useTranslation } from "react-i18next"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { UserPlus, Mail, Key, Trash2, Edit, Shield, Loader2, Users as UsersIcon } from "lucide-react"; +import { UserPlus, Key, Trash2, Edit, Shield, Loader2, Users as UsersIcon, Copy, CheckCircle2, AlertTriangle } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { useStore } from "@/store/useStore"; import { SkeletonStatsCard, SkeletonTable } from "@/components/ui/skeletons"; @@ -74,12 +74,13 @@ function UserStatsCards() { // Component for users table with suspense function UsersTable() { - const { t } = useTranslation(); const { toast } = useToast(); const currentUser = useStore(state => state.currentUser); const { data: users } = useSuspenseUsers(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingUser, setEditingUser] = useState(null); + const [resetPasswordDialog, setResetPasswordDialog] = useState<{ isOpen: boolean; userId: string; username: string; newPassword?: string; }>({ isOpen: false, userId: '', username: '' }); + const [passwordCopied, setPasswordCopied] = useState(false); const createUser = useCreateUser(); const updateUser = useUpdateUser(); @@ -221,26 +222,154 @@ function UsersTable() { } }; - const handleResetPassword = async (userId: string) => { - if (!confirm("Are you sure you want to reset this user's password?")) return; + const handleResetPassword = async (userId: string, username: string) => { + // Open confirmation dialog + setResetPasswordDialog({ isOpen: true, userId, username }); + }; + + const confirmResetPassword = async () => { + if (!resetPasswordDialog.userId) return; try { - const result = await resetUserPassword.mutateAsync(userId); - toast({ - title: "Password reset successfully", - description: result.data?.temporaryPassword - ? `Temporary password: ${result.data.temporaryPassword}` - : "Password reset email sent to user" - }); + const result = await resetUserPassword.mutateAsync(resetPasswordDialog.userId); + + // Backend returns: { success, message, data: { temporaryPassword, note } } + const newPassword = result?.data?.temporaryPassword; + + if (!newPassword) { + throw new Error('Failed to get temporary password from response'); + } + + // Force re-render: close and reopen dialog with password + const currentUserId = resetPasswordDialog.userId; + const currentUsername = resetPasswordDialog.username; + + // Close dialog first + setResetPasswordDialog({ isOpen: false, userId: '', username: '' }); + + // Then reopen with password after a tiny delay + setTimeout(() => { + setResetPasswordDialog({ + isOpen: true, + userId: currentUserId, + username: currentUsername, + newPassword: newPassword + }); + setPasswordCopied(false); + }, 100); + } catch (error: any) { toast({ title: "Error resetting password", - description: error.response?.data?.message || "Failed to reset password", + description: error.response?.data?.message || error.message || "Failed to reset password", variant: "destructive" }); + // Close dialog on error + setResetPasswordDialog({ isOpen: false, userId: '', username: '' }); } }; + const handleCopyPassword = async () => { + if (!resetPasswordDialog.newPassword) { + toast({ + title: "No password to copy", + description: "Password is empty", + variant: "destructive" + }); + return; + } + + const password = resetPasswordDialog.newPassword; + let copySuccess = false; + let copyMethod = ''; + + try { + // Method 1: Modern Clipboard API (requires HTTPS or localhost) + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(password); + copySuccess = true; + copyMethod = 'clipboard API'; + } catch (clipboardError) { + // Clipboard API failed, will try fallback + } + } + + // Method 2: execCommand with proper event handling and delay + if (!copySuccess) { + try { + const textArea = document.createElement('textarea'); + textArea.value = password; + + // Make it visible but off-screen to ensure it works + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '-9999px'; + textArea.style.opacity = '0'; + textArea.setAttribute('readonly', ''); + textArea.contentEditable = 'true'; + + document.body.appendChild(textArea); + + // iOS Safari needs this + if (navigator.userAgent.match(/ipad|iphone/i)) { + const range = document.createRange(); + range.selectNodeContents(textArea); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + textArea.setSelectionRange(0, password.length); + } else { + textArea.focus(); + textArea.select(); + textArea.setSelectionRange(0, password.length); + } + + // Small delay before executing copy + await new Promise(resolve => setTimeout(resolve, 50)); + + const successful = document.execCommand('copy'); + + if (successful) { + copySuccess = true; + copyMethod = 'execCommand'; + + // Keep element for a bit to ensure clipboard is populated + await new Promise(resolve => setTimeout(resolve, 100)); + } + + document.body.removeChild(textArea); + } catch (execError) { + // execCommand failed + } + } + + if (copySuccess) { + setPasswordCopied(true); + toast({ + title: "Password copied!", + description: `Password copied using ${copyMethod}. Try pasting now (Ctrl+V or Cmd+V).`, + duration: 5000 + }); + } else { + throw new Error('All copy methods failed'); + } + + } catch (error) { + toast({ + title: "Automatic copy failed", + description: "Please click the password text to select it, then press Ctrl+C (or Cmd+C) to copy manually", + variant: "destructive", + duration: 7000 + }); + } + }; + + const closeResetPasswordDialog = () => { + setResetPasswordDialog({ isOpen: false, userId: '', username: '' }); + setPasswordCopied(false); + }; + if (!canViewUsers) { return (
@@ -262,7 +391,7 @@ function UsersTable() { } }; - const getRoleIcon = (role: string) => { + const getRoleIcon = (_role: string) => { return ; }; @@ -378,6 +507,110 @@ function UsersTable() { )}
+ {/* Password Reset Confirmation/Result Dialog */} + !open && closeResetPasswordDialog()}> + + + + {resetPasswordDialog.newPassword ? ( + <> + + Password Reset Successful + + ) : ( + <> + + Reset Password Confirmation + + )} + + + {!resetPasswordDialog.newPassword ? ( + <> +

Are you sure you want to reset the password for user {resetPasswordDialog.username}?

+

+ A new secure password will be generated. The user will need to use this temporary password to log in. +

+ + ) : ( + <> +

+ Password has been reset for user {resetPasswordDialog.username}. +

+
+ +
+ e.currentTarget.select()} + onFocus={(e) => e.currentTarget.select()} + /> + +
+

+ 💡 Tip: Click the password field to select all, then press Ctrl+C (or Cmd+C on Mac) to copy +

+
+
+

+ + + Please save this password securely. For security reasons, it cannot be displayed again. + Share it with the user through a secure channel. + +

+
+ + )} +
+
+ + {!resetPasswordDialog.newPassword ? ( + <> + Cancel + + {resetUserPassword.isPending ? ( + <> + + Resetting... + + ) : ( + 'Reset Password' + )} + + + ) : ( + + Close + + )} + +
+
+ }> @@ -427,7 +660,7 @@ function UsersTable() { -