feat: Add functionality to reset password and handle wait time
This commit is contained in:
5
backend/data/login-attempts.json
Normal file
5
backend/data/login-attempts.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"failedAttempts": 0,
|
||||||
|
"lastFailedAttemptTime": 0,
|
||||||
|
"waitUntil": 0
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "../config/paths";
|
} from "../config/paths";
|
||||||
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||||
import downloadManager from "../services/downloadManager";
|
import downloadManager from "../services/downloadManager";
|
||||||
|
import * as loginAttemptService from "../services/loginAttemptService";
|
||||||
import * as storageService from "../services/storageService";
|
import * as storageService from "../services/storageService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { successMessage } from "../utils/response";
|
import { successMessage } from "../utils/response";
|
||||||
@@ -263,8 +264,14 @@ export const getPasswordEnabled = async (
|
|||||||
// Return true only if login is enabled AND a password is set
|
// Return true only if login is enabled AND a password is set
|
||||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
||||||
|
|
||||||
// Return format expected by frontend: { enabled: boolean }
|
// Check for remaining wait time
|
||||||
res.json({ enabled: isEnabled });
|
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||||
|
|
||||||
|
// Return format expected by frontend: { enabled: boolean, waitTime?: number }
|
||||||
|
res.json({
|
||||||
|
enabled: isEnabled,
|
||||||
|
waitTime: remainingWaitTime > 0 ? remainingWaitTime : undefined,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,13 +300,37 @@ export const verifyPassword = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user can attempt login (wait time check)
|
||||||
|
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||||
|
if (remainingWaitTime > 0) {
|
||||||
|
// User must wait before trying again
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
waitTime: remainingWaitTime,
|
||||||
|
message: "Too many failed attempts. Please wait before trying again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
|
// Reset failed attempts on successful login
|
||||||
|
loginAttemptService.resetFailedAttempts();
|
||||||
// Return format expected by frontend: { success: boolean }
|
// Return format expected by frontend: { success: boolean }
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} else {
|
} else {
|
||||||
throw new ValidationError("Incorrect password", "password");
|
// Record failed attempt and get wait time
|
||||||
|
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||||
|
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||||
|
|
||||||
|
// Return wait time information
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
waitTime,
|
||||||
|
failedAttempts,
|
||||||
|
message: "Incorrect password",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,3 +397,45 @@ export const deleteCookies = async (
|
|||||||
throw new NotFoundError("Cookies file", "cookies.txt");
|
throw new NotFoundError("Cookies file", "cookies.txt");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password to a random 8-character string
|
||||||
|
* Errors are automatically handled by asyncHandler middleware
|
||||||
|
*/
|
||||||
|
export const resetPassword = async (
|
||||||
|
_req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
// Generate random 8-character password
|
||||||
|
const chars =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let newPassword = "";
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
newPassword += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||||
|
|
||||||
|
// Update settings with new password
|
||||||
|
const settings = storageService.getSettings();
|
||||||
|
const mergedSettings = { ...defaultSettings, ...settings };
|
||||||
|
mergedSettings.password = hashedPassword;
|
||||||
|
mergedSettings.loginEnabled = true; // Ensure login is enabled
|
||||||
|
|
||||||
|
storageService.saveSettings(mergedSettings);
|
||||||
|
|
||||||
|
// Log the new password (as requested)
|
||||||
|
logger.info(`Password has been reset. New password: ${newPassword}`);
|
||||||
|
|
||||||
|
// Reset failed login attempts
|
||||||
|
loginAttemptService.resetFailedAttempts();
|
||||||
|
|
||||||
|
// Return success (but don't send password to frontend for security)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Password has been reset. Check backend logs for the new password.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getPasswordEnabled,
|
getPasswordEnabled,
|
||||||
getSettings,
|
getSettings,
|
||||||
migrateData,
|
migrateData,
|
||||||
|
resetPassword,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
uploadCookies,
|
uploadCookies,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
@@ -22,6 +23,7 @@ router.get("/", asyncHandler(getSettings));
|
|||||||
router.post("/", asyncHandler(updateSettings));
|
router.post("/", asyncHandler(updateSettings));
|
||||||
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
|
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
|
||||||
router.post("/verify-password", asyncHandler(verifyPassword));
|
router.post("/verify-password", asyncHandler(verifyPassword));
|
||||||
|
router.post("/reset-password", asyncHandler(resetPassword));
|
||||||
router.post("/migrate", asyncHandler(migrateData));
|
router.post("/migrate", asyncHandler(migrateData));
|
||||||
router.post("/delete-legacy", asyncHandler(deleteLegacyData));
|
router.post("/delete-legacy", asyncHandler(deleteLegacyData));
|
||||||
router.post("/format-filenames", asyncHandler(formatFilenames));
|
router.post("/format-filenames", asyncHandler(formatFilenames));
|
||||||
|
|||||||
160
backend/src/services/loginAttemptService.ts
Normal file
160
backend/src/services/loginAttemptService.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import fs from "fs-extra";
|
||||||
|
import path from "path";
|
||||||
|
import { DATA_DIR } from "../config/paths";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface LoginAttemptData {
|
||||||
|
failedAttempts: number;
|
||||||
|
lastFailedAttemptTime: number; // timestamp in milliseconds
|
||||||
|
waitUntil: number; // timestamp in milliseconds when user can try again
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGIN_ATTEMPTS_FILE = path.join(DATA_DIR, "login-attempts.json");
|
||||||
|
|
||||||
|
// Wait time mapping based on failed attempts
|
||||||
|
const WAIT_TIMES: Record<number, number> = {
|
||||||
|
1: 5 * 1000, // 5 seconds
|
||||||
|
2: 5 * 1000, // 5 seconds
|
||||||
|
3: 10 * 1000, // 10 seconds
|
||||||
|
4: 30 * 1000, // 30 seconds
|
||||||
|
5: 60 * 1000, // 1 minute
|
||||||
|
6: 3 * 60 * 1000, // 3 minutes
|
||||||
|
7: 10 * 60 * 1000, // 10 minutes
|
||||||
|
8: 2 * 60 * 60 * 1000, // 2 hours
|
||||||
|
9: 6 * 60 * 60 * 1000, // 6 hours
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_WAIT_TIME = 24 * 60 * 60 * 1000; // 24 hours for 10+ attempts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get wait time in milliseconds for a given number of failed attempts
|
||||||
|
*/
|
||||||
|
function getWaitTime(attempts: number): number {
|
||||||
|
if (attempts <= 0) return 0;
|
||||||
|
if (attempts >= 10) return MAX_WAIT_TIME;
|
||||||
|
return WAIT_TIMES[attempts] || MAX_WAIT_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load login attempt data from file
|
||||||
|
*/
|
||||||
|
function loadAttemptData(): LoginAttemptData {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(LOGIN_ATTEMPTS_FILE)) {
|
||||||
|
const data = fs.readJsonSync(LOGIN_ATTEMPTS_FILE);
|
||||||
|
return {
|
||||||
|
failedAttempts: data.failedAttempts || 0,
|
||||||
|
lastFailedAttemptTime: data.lastFailedAttemptTime || 0,
|
||||||
|
waitUntil: data.waitUntil || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading login attempt data", error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
failedAttempts: 0,
|
||||||
|
lastFailedAttemptTime: 0,
|
||||||
|
waitUntil: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save login attempt data to file
|
||||||
|
*/
|
||||||
|
function saveAttemptData(data: LoginAttemptData): void {
|
||||||
|
try {
|
||||||
|
// Ensure data directory exists
|
||||||
|
fs.ensureDirSync(DATA_DIR);
|
||||||
|
fs.writeJsonSync(LOGIN_ATTEMPTS_FILE, data, { spaces: 2 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error saving login attempt data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can attempt login (no wait time remaining)
|
||||||
|
* Returns remaining wait time in milliseconds, or 0 if can proceed
|
||||||
|
*/
|
||||||
|
export function canAttemptLogin(): number {
|
||||||
|
const data = loadAttemptData();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (data.waitUntil > now) {
|
||||||
|
return data.waitUntil - now;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failed login attempt
|
||||||
|
* Returns the wait time in milliseconds that was set
|
||||||
|
*/
|
||||||
|
export function recordFailedAttempt(): number {
|
||||||
|
const data = loadAttemptData();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Only increment if wait time has passed (user is allowed to try again)
|
||||||
|
// If still in wait period, don't increment (this shouldn't happen as canAttemptLogin checks this)
|
||||||
|
// But if it does, we still increment to track the attempt
|
||||||
|
if (data.waitUntil > now) {
|
||||||
|
// Still in wait period - this shouldn't happen, but increment anyway
|
||||||
|
logger.warn(
|
||||||
|
`Attempt recorded while still in wait period. Current attempts: ${data.failedAttempts}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment failed attempts (counter persists until correct password is entered)
|
||||||
|
data.failedAttempts += 1;
|
||||||
|
data.lastFailedAttemptTime = now;
|
||||||
|
|
||||||
|
// Calculate wait time based on current attempt count
|
||||||
|
const waitTime = getWaitTime(data.failedAttempts);
|
||||||
|
data.waitUntil = now + waitTime;
|
||||||
|
|
||||||
|
saveAttemptData(data);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`Failed login attempt #${data.failedAttempts}. Wait time: ${
|
||||||
|
waitTime / 1000
|
||||||
|
}s`
|
||||||
|
);
|
||||||
|
|
||||||
|
return waitTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset failed attempts (called on successful login)
|
||||||
|
*/
|
||||||
|
export function resetFailedAttempts(): void {
|
||||||
|
const data: LoginAttemptData = {
|
||||||
|
failedAttempts: 0,
|
||||||
|
lastFailedAttemptTime: 0,
|
||||||
|
waitUntil: 0,
|
||||||
|
};
|
||||||
|
saveAttemptData(data);
|
||||||
|
logger.info("Login attempts reset after successful login");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current failed attempts count
|
||||||
|
* Returns the count even if wait time has passed (counter persists until reset)
|
||||||
|
*/
|
||||||
|
export function getFailedAttempts(): number {
|
||||||
|
const data = loadAttemptData();
|
||||||
|
return data.failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining wait time in milliseconds
|
||||||
|
*/
|
||||||
|
export function getRemainingWaitTime(): number {
|
||||||
|
const data = loadAttemptData();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (data.waitUntil > now) {
|
||||||
|
return data.waitUntil - now;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ErrorOutline, LockOutlined } from '@mui/icons-material';
|
import { ErrorOutline, LockOutlined, Refresh } from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import logo from '../assets/logo.svg';
|
||||||
|
import ConfirmationModal from '../components/ConfirmationModal';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import getTheme from '../theme';
|
import getTheme from '../theme';
|
||||||
@@ -23,8 +25,34 @@ const API_URL = import.meta.env.VITE_API_URL;
|
|||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [waitTime, setWaitTime] = useState(0); // in milliseconds
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false);
|
||||||
|
const [websiteName, setWebsiteName] = useState('MyTube');
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch website name from settings
|
||||||
|
const { data: settingsData } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update website name when settings are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsData && settingsData.websiteName) {
|
||||||
|
setWebsiteName(settingsData.websiteName);
|
||||||
|
}
|
||||||
|
}, [settingsData]);
|
||||||
|
|
||||||
// Check backend connection and password status
|
// Check backend connection and password status
|
||||||
const { data: statusData, isLoading: isCheckingConnection, isError: isConnectionError, refetch: retryConnection } = useQuery({
|
const { data: statusData, isLoading: isCheckingConnection, isError: isConnectionError, refetch: retryConnection } = useQuery({
|
||||||
@@ -37,6 +65,13 @@ const LoginPage: React.FC = () => {
|
|||||||
retryDelay: 1000,
|
retryDelay: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize wait time from server response
|
||||||
|
useEffect(() => {
|
||||||
|
if (statusData && statusData.waitTime) {
|
||||||
|
setWaitTime(statusData.waitTime);
|
||||||
|
}
|
||||||
|
}, [statusData]);
|
||||||
|
|
||||||
// Auto-login if password is not enabled
|
// Auto-login if password is not enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusData && statusData.enabled === false) {
|
if (statusData && statusData.enabled === false) {
|
||||||
@@ -44,9 +79,34 @@ const LoginPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [statusData, login]);
|
}, [statusData, login]);
|
||||||
|
|
||||||
|
// Countdown timer for wait time
|
||||||
|
useEffect(() => {
|
||||||
|
if (waitTime > 0) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setWaitTime((prev) => {
|
||||||
|
const newTime = prev - 1000;
|
||||||
|
return newTime > 0 ? newTime : 0;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [waitTime]);
|
||||||
|
|
||||||
// Use dark theme for login page to match app style
|
// Use dark theme for login page to match app style
|
||||||
const theme = getTheme('dark');
|
const theme = getTheme('dark');
|
||||||
|
|
||||||
|
const formatWaitTime = (ms: number): string => {
|
||||||
|
if (ms < 1000) return 'a moment';
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds} second${seconds !== 1 ? 's' : ''}`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days} day${days !== 1 ? 's' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async (password: string) => {
|
mutationFn: async (password: string) => {
|
||||||
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
|
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||||
@@ -54,6 +114,7 @@ const LoginPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
setWaitTime(0); // Reset wait time on success
|
||||||
login();
|
login();
|
||||||
} else {
|
} else {
|
||||||
setError(t('incorrectPassword'));
|
setError(t('incorrectPassword'));
|
||||||
@@ -61,20 +122,69 @@ const LoginPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
if (err.response && err.response.status === 401) {
|
if (err.response) {
|
||||||
setError(t('incorrectPassword'));
|
const responseData = err.response.data;
|
||||||
|
if (err.response.status === 429) {
|
||||||
|
// Too many attempts - wait time required
|
||||||
|
const waitTimeMs = responseData.waitTime || 0;
|
||||||
|
setWaitTime(waitTimeMs);
|
||||||
|
const formattedTime = formatWaitTime(waitTimeMs);
|
||||||
|
setError(
|
||||||
|
`${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`
|
||||||
|
);
|
||||||
|
} else if (err.response.status === 401) {
|
||||||
|
// Incorrect password - check if wait time is returned
|
||||||
|
const waitTimeMs = responseData.waitTime || 0;
|
||||||
|
if (waitTimeMs > 0) {
|
||||||
|
setWaitTime(waitTimeMs);
|
||||||
|
const formattedTime = formatWaitTime(waitTimeMs);
|
||||||
|
setError(
|
||||||
|
`${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(t('incorrectPassword'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(t('loginFailed'));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(t('loginFailed'));
|
setError(t('loginFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resetPasswordMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const response = await axios.post(`${API_URL}/settings/reset-password`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowResetModal(false);
|
||||||
|
setError('');
|
||||||
|
setWaitTime(0);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['healthCheck'] });
|
||||||
|
// Show success message
|
||||||
|
alert(t('resetPasswordSuccess'));
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
console.error('Reset password error:', err);
|
||||||
|
setError(t('loginFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (waitTime > 0) {
|
||||||
|
return; // Don't allow submission if wait time is active
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
loginMutation.mutate(password);
|
loginMutation.mutate(password);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = () => {
|
||||||
|
resetPasswordMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@@ -118,13 +228,26 @@ const LoginPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
// Normal login form
|
// Normal login form
|
||||||
<>
|
<>
|
||||||
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||||
<LockOutlined />
|
<img src={logo} alt="Logo" height={48} />
|
||||||
</Avatar>
|
<Box sx={{ ml: 1.5, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Typography component="h1" variant="h5">
|
<Typography variant="h4" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
|
||||||
{t('signIn')}
|
{websiteName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
{websiteName !== 'MyTube' && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', lineHeight: 1.2, mt: 0.25 }}>
|
||||||
|
Powered by MyTube
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<LockOutlined sx={{ color: 'text.primary' }} />
|
||||||
|
<Typography component="h1" variant="h5">
|
||||||
|
{t('signIn')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1, width: '100%' }}>
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
@@ -137,27 +260,55 @@ const LoginPage: React.FC = () => {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
disabled={waitTime > 0 || loginMutation.isPending}
|
||||||
helperText={t('defaultPasswordHint') || "Default password: 123"}
|
helperText={t('defaultPasswordHint') || "Default password: 123"}
|
||||||
/>
|
/>
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{ mt: 3, mb: 2 }}
|
sx={{ mt: 3, mb: 2 }}
|
||||||
disabled={loginMutation.isPending}
|
disabled={loginMutation.isPending || waitTime > 0}
|
||||||
>
|
>
|
||||||
{loginMutation.isPending ? t('verifying') : t('signIn')}
|
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Refresh />}
|
||||||
|
onClick={() => setShowResetModal(true)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
disabled={resetPasswordMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('resetPassword')}
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ minHeight: waitTime > 0 || (error && waitTime === 0) ? 'auto' : 0, mt: 2 }}>
|
||||||
|
{waitTime > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ width: '100%' }}>
|
||||||
|
{t('waitTimeMessage').replace('{time}', formatWaitTime(waitTime))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{error && waitTime === 0 && (
|
||||||
|
<Alert severity="error" sx={{ width: '100%' }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={showResetModal}
|
||||||
|
onClose={() => setShowResetModal(false)}
|
||||||
|
onConfirm={handleResetPassword}
|
||||||
|
title={t('resetPasswordTitle')}
|
||||||
|
message={t('resetPasswordMessage')}
|
||||||
|
confirmText={t('resetPasswordConfirm')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
isDanger={true}
|
||||||
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ export const ar = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"لا يمكن الاتصال بالخادم. يرجى التحقق مما إذا كان الخادم يعمل والمنفذ مفتوح، ثم حاول مرة أخرى.",
|
"لا يمكن الاتصال بالخادم. يرجى التحقق مما إذا كان الخادم يعمل والمنفذ مفتوح، ثم حاول مرة أخرى.",
|
||||||
retry: "إعادة المحاولة",
|
retry: "إعادة المحاولة",
|
||||||
|
resetPassword: "إعادة تعيين كلمة المرور",
|
||||||
|
resetPasswordTitle: "إعادة تعيين كلمة المرور",
|
||||||
|
resetPasswordMessage: "هل أنت متأكد من أنك تريد إعادة تعيين كلمة المرور؟ سيتم إعادة تعيين كلمة المرور الحالية إلى سلسلة عشوائية مكونة من 8 أحرف وعرضها في سجل الخادم.",
|
||||||
|
resetPasswordConfirm: "إعادة التعيين",
|
||||||
|
resetPasswordSuccess: "تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
|
||||||
|
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
|
||||||
|
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
|
||||||
linkCopied: "تم نسخ الرابط إلى الحافظة",
|
linkCopied: "تم نسخ الرابط إلى الحافظة",
|
||||||
copyFailed: "فشل نسخ الرابط",
|
copyFailed: "فشل نسخ الرابط",
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,13 @@ export const de = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"Verbindung zum Server nicht möglich. Bitte überprüfen Sie, ob das Backend läuft und der Port geöffnet ist, und versuchen Sie es erneut.",
|
"Verbindung zum Server nicht möglich. Bitte überprüfen Sie, ob das Backend läuft und der Port geöffnet ist, und versuchen Sie es erneut.",
|
||||||
retry: "Wiederholen",
|
retry: "Wiederholen",
|
||||||
|
resetPassword: "Passwort zurücksetzen",
|
||||||
|
resetPasswordTitle: "Passwort zurücksetzen",
|
||||||
|
resetPasswordMessage: "Sind Sie sicher, dass Sie das Passwort zurücksetzen möchten? Das aktuelle Passwort wird auf eine zufällige 8-stellige Zeichenfolge zurückgesetzt und im Backend-Protokoll angezeigt.",
|
||||||
|
resetPasswordConfirm: "Zurücksetzen",
|
||||||
|
resetPasswordSuccess: "Das Passwort wurde zurückgesetzt. Überprüfen Sie die Backend-Protokolle für das neue Passwort.",
|
||||||
|
waitTimeMessage: "Bitte warten Sie {time}, bevor Sie es erneut versuchen.",
|
||||||
|
tooManyAttempts: "Zu viele fehlgeschlagene Versuche.",
|
||||||
linkCopied: "Link in die Zwischenablage kopiert",
|
linkCopied: "Link in die Zwischenablage kopiert",
|
||||||
copyFailed: "Link konnte nicht kopiert werden",
|
copyFailed: "Link konnte nicht kopiert werden",
|
||||||
loadingCollection: "Sammlung wird geladen...",
|
loadingCollection: "Sammlung wird geladen...",
|
||||||
|
|||||||
@@ -228,6 +228,13 @@ export const en = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"Unable to connect to the server. Please check if the backend is running and port is open, then try again.",
|
"Unable to connect to the server. Please check if the backend is running and port is open, then try again.",
|
||||||
retry: "Retry",
|
retry: "Retry",
|
||||||
|
resetPassword: "Reset Password",
|
||||||
|
resetPasswordTitle: "Reset Password",
|
||||||
|
resetPasswordMessage: "Are you sure you want to reset the password? The current password will be reset to a random 8-character string and displayed in the backend log.",
|
||||||
|
resetPasswordConfirm: "Reset",
|
||||||
|
resetPasswordSuccess: "Password has been reset. Check backend logs for the new password.",
|
||||||
|
waitTimeMessage: "Please wait {time} before trying again.",
|
||||||
|
tooManyAttempts: "Too many failed attempts.",
|
||||||
linkCopied: "Link copied to clipboard",
|
linkCopied: "Link copied to clipboard",
|
||||||
copyFailed: "Failed to copy link",
|
copyFailed: "Failed to copy link",
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,13 @@ export const es = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"No se puede conectar al servidor. Por favor, verifique si el backend está en ejecución y el puerto está abierto, luego intente nuevamente.",
|
"No se puede conectar al servidor. Por favor, verifique si el backend está en ejecución y el puerto está abierto, luego intente nuevamente.",
|
||||||
retry: "Reintentar",
|
retry: "Reintentar",
|
||||||
|
resetPassword: "Restablecer Contraseña",
|
||||||
|
resetPasswordTitle: "Restablecer Contraseña",
|
||||||
|
resetPasswordMessage: "¿Está seguro de que desea restablecer la contraseña? La contraseña actual se restablecerá a una cadena aleatoria de 8 caracteres y se mostrará en el registro del backend.",
|
||||||
|
resetPasswordConfirm: "Restablecer",
|
||||||
|
resetPasswordSuccess: "La contraseña ha sido restablecida. Consulte los registros del backend para obtener la nueva contraseña.",
|
||||||
|
waitTimeMessage: "Por favor espere {time} antes de intentar nuevamente.",
|
||||||
|
tooManyAttempts: "Demasiados intentos fallidos.",
|
||||||
linkCopied: "Enlace copiado al portapapeles",
|
linkCopied: "Enlace copiado al portapapeles",
|
||||||
copyFailed: "Error al copiar enlace",
|
copyFailed: "Error al copiar enlace",
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,13 @@ export const fr = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"Impossible de se connecter au serveur. Veuillez vérifier que le backend est en cours d'exécution et que le port est ouvert, puis réessayez.",
|
"Impossible de se connecter au serveur. Veuillez vérifier que le backend est en cours d'exécution et que le port est ouvert, puis réessayez.",
|
||||||
retry: "Réessayer",
|
retry: "Réessayer",
|
||||||
|
resetPassword: "Réinitialiser le mot de passe",
|
||||||
|
resetPasswordTitle: "Réinitialiser le mot de passe",
|
||||||
|
resetPasswordMessage: "Êtes-vous sûr de vouloir réinitialiser le mot de passe ? Le mot de passe actuel sera réinitialisé en une chaîne aléatoire de 8 caractères et affiché dans le journal du backend.",
|
||||||
|
resetPasswordConfirm: "Réinitialiser",
|
||||||
|
resetPasswordSuccess: "Le mot de passe a été réinitialisé. Consultez les journaux du backend pour le nouveau mot de passe.",
|
||||||
|
waitTimeMessage: "Veuillez attendre {time} avant de réessayer.",
|
||||||
|
tooManyAttempts: "Trop de tentatives échouées.",
|
||||||
linkCopied: "Lien copié dans le presse-papiers",
|
linkCopied: "Lien copié dans le presse-papiers",
|
||||||
copyFailed: "Échec de la copie du lien",
|
copyFailed: "Échec de la copie du lien",
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,13 @@ export const ja = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"サーバーに接続できません。バックエンドが実行中でポートが開いているか確認してから、もう一度お試しください。",
|
"サーバーに接続できません。バックエンドが実行中でポートが開いているか確認してから、もう一度お試しください。",
|
||||||
retry: "再試行",
|
retry: "再試行",
|
||||||
|
resetPassword: "パスワードをリセット",
|
||||||
|
resetPasswordTitle: "パスワードをリセット",
|
||||||
|
resetPasswordMessage: "パスワードをリセットしてもよろしいですか?現在のパスワードはランダムな8文字の文字列にリセットされ、バックエンドログに表示されます。",
|
||||||
|
resetPasswordConfirm: "リセット",
|
||||||
|
resetPasswordSuccess: "パスワードがリセットされました。新しいパスワードについては、バックエンドログを確認してください。",
|
||||||
|
waitTimeMessage: "再試行する前に {time} お待ちください。",
|
||||||
|
tooManyAttempts: "失敗した試行が多すぎます。",
|
||||||
linkCopied: "リンクをクリップボードにコピーしました",
|
linkCopied: "リンクをクリップボードにコピーしました",
|
||||||
copyFailed: "リンクのコピーに失敗しました",
|
copyFailed: "リンクのコピーに失敗しました",
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,13 @@ export const ko = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"서버에 연결할 수 없습니다. 백엔드가 실행 중이고 포트가 열려 있는지 확인한 후 다시 시도하세요.",
|
"서버에 연결할 수 없습니다. 백엔드가 실행 중이고 포트가 열려 있는지 확인한 후 다시 시도하세요.",
|
||||||
retry: "다시 시도",
|
retry: "다시 시도",
|
||||||
|
resetPassword: "비밀번호 재설정",
|
||||||
|
resetPasswordTitle: "비밀번호 재설정",
|
||||||
|
resetPasswordMessage: "비밀번호를 재설정하시겠습니까? 현재 비밀번호는 무작위 8자 문자열로 재설정되며 백엔드 로그에 표시됩니다.",
|
||||||
|
resetPasswordConfirm: "재설정",
|
||||||
|
resetPasswordSuccess: "비밀번호가 재설정되었습니다. 새 비밀번호는 백엔드 로그를 확인하세요.",
|
||||||
|
waitTimeMessage: "다시 시도하기 전에 {time} 기다려 주세요.",
|
||||||
|
tooManyAttempts: "실패한 시도가 너무 많습니다.",
|
||||||
linkCopied: "링크가 클립보드에 복사되었습니다",
|
linkCopied: "링크가 클립보드에 복사되었습니다",
|
||||||
copyFailed: "링크 복사 실패",
|
copyFailed: "링크 복사 실패",
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,13 @@ export const pt = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"Não foi possível conectar ao servidor. Verifique se o backend está em execução e a porta está aberta, depois tente novamente.",
|
"Não foi possível conectar ao servidor. Verifique se o backend está em execução e a porta está aberta, depois tente novamente.",
|
||||||
retry: "Tentar Novamente",
|
retry: "Tentar Novamente",
|
||||||
|
resetPassword: "Redefinir Senha",
|
||||||
|
resetPasswordTitle: "Redefinir Senha",
|
||||||
|
resetPasswordMessage: "Tem certeza de que deseja redefinir a senha? A senha atual será redefinida para uma string aleatória de 8 caracteres e exibida no log do backend.",
|
||||||
|
resetPasswordConfirm: "Redefinir",
|
||||||
|
resetPasswordSuccess: "A senha foi redefinida. Verifique os logs do backend para a nova senha.",
|
||||||
|
waitTimeMessage: "Por favor, aguarde {time} antes de tentar novamente.",
|
||||||
|
tooManyAttempts: "Muitas tentativas falharam.",
|
||||||
linkCopied: "Link copiado para a área de transferência",
|
linkCopied: "Link copiado para a área de transferência",
|
||||||
copyFailed: "Falha ao copiar link",
|
copyFailed: "Falha ao copiar link",
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,13 @@ export const ru = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"Не удалось подключиться к серверу. Убедитесь, что сервер запущен и порт открыт, затем повторите попытку.",
|
"Не удалось подключиться к серверу. Убедитесь, что сервер запущен и порт открыт, затем повторите попытку.",
|
||||||
retry: "Повторить",
|
retry: "Повторить",
|
||||||
|
resetPassword: "Сбросить пароль",
|
||||||
|
resetPasswordTitle: "Сбросить пароль",
|
||||||
|
resetPasswordMessage: "Вы уверены, что хотите сбросить пароль? Текущий пароль будет сброшен на случайную 8-символьную строку и отображен в логе бэкенда.",
|
||||||
|
resetPasswordConfirm: "Сбросить",
|
||||||
|
resetPasswordSuccess: "Пароль был сброшен. Проверьте логи бэкенда для нового пароля.",
|
||||||
|
waitTimeMessage: "Пожалуйста, подождите {time} перед повторной попыткой.",
|
||||||
|
tooManyAttempts: "Слишком много неудачных попыток.",
|
||||||
linkCopied: "Ссылка скопирована в буфер обмена",
|
linkCopied: "Ссылка скопирована в буфер обмена",
|
||||||
copyFailed: "Не удалось скопировать ссылку",
|
copyFailed: "Не удалось скопировать ссылку",
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,13 @@ export const zh = {
|
|||||||
backendConnectionFailed:
|
backendConnectionFailed:
|
||||||
"无法连接到服务器。请检查后端是否正在运行并确保端口已开放,然后重试。",
|
"无法连接到服务器。请检查后端是否正在运行并确保端口已开放,然后重试。",
|
||||||
retry: "重试",
|
retry: "重试",
|
||||||
|
resetPassword: "重置密码",
|
||||||
|
resetPasswordTitle: "重置密码",
|
||||||
|
resetPasswordMessage: "您确定要重置密码吗?当前密码将被重置为一个随机的8位字符串,并显示在后端日志中。",
|
||||||
|
resetPasswordConfirm: "重置",
|
||||||
|
resetPasswordSuccess: "密码已重置。请查看后端日志以获取新密码。",
|
||||||
|
waitTimeMessage: "请等待 {time} 后再试。",
|
||||||
|
tooManyAttempts: "失败尝试次数过多。",
|
||||||
linkCopied: "链接已复制到剪贴板",
|
linkCopied: "链接已复制到剪贴板",
|
||||||
copyFailed: "复制链接失败",
|
copyFailed: "复制链接失败",
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user