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";
|
||||
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as loginAttemptService from "../services/loginAttemptService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
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
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
||||
|
||||
// Return format expected by frontend: { enabled: boolean }
|
||||
res.json({ enabled: isEnabled });
|
||||
// Check for remaining wait time
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (isMatch) {
|
||||
// Reset failed attempts on successful login
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
// Return format expected by frontend: { success: boolean }
|
||||
res.json({ success: true });
|
||||
} 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");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
getSettings,
|
||||
migrateData,
|
||||
resetPassword,
|
||||
updateSettings,
|
||||
uploadCookies,
|
||||
verifyPassword,
|
||||
@@ -22,6 +23,7 @@ router.get("/", asyncHandler(getSettings));
|
||||
router.post("/", asyncHandler(updateSettings));
|
||||
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
|
||||
router.post("/verify-password", asyncHandler(verifyPassword));
|
||||
router.post("/reset-password", asyncHandler(resetPassword));
|
||||
router.post("/migrate", asyncHandler(migrateData));
|
||||
router.post("/delete-legacy", asyncHandler(deleteLegacyData));
|
||||
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 {
|
||||
Alert,
|
||||
Avatar,
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
ThemeProvider,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import logo from '../assets/logo.svg';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import getTheme from '../theme';
|
||||
@@ -23,8 +25,34 @@ const API_URL = import.meta.env.VITE_API_URL;
|
||||
const LoginPage: React.FC = () => {
|
||||
const [password, setPassword] = 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 { 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
|
||||
const { data: statusData, isLoading: isCheckingConnection, isError: isConnectionError, refetch: retryConnection } = useQuery({
|
||||
@@ -37,6 +65,13 @@ const LoginPage: React.FC = () => {
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
// Initialize wait time from server response
|
||||
useEffect(() => {
|
||||
if (statusData && statusData.waitTime) {
|
||||
setWaitTime(statusData.waitTime);
|
||||
}
|
||||
}, [statusData]);
|
||||
|
||||
// Auto-login if password is not enabled
|
||||
useEffect(() => {
|
||||
if (statusData && statusData.enabled === false) {
|
||||
@@ -44,9 +79,34 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
}, [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
|
||||
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({
|
||||
mutationFn: async (password: string) => {
|
||||
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
@@ -54,6 +114,7 @@ const LoginPage: React.FC = () => {
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
setWaitTime(0); // Reset wait time on success
|
||||
login();
|
||||
} else {
|
||||
setError(t('incorrectPassword'));
|
||||
@@ -61,20 +122,69 @@ const LoginPage: React.FC = () => {
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('Login error:', err);
|
||||
if (err.response && err.response.status === 401) {
|
||||
setError(t('incorrectPassword'));
|
||||
if (err.response) {
|
||||
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 {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (waitTime > 0) {
|
||||
return; // Don't allow submission if wait time is active
|
||||
}
|
||||
setError('');
|
||||
loginMutation.mutate(password);
|
||||
};
|
||||
|
||||
const handleResetPassword = () => {
|
||||
resetPasswordMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
@@ -118,13 +228,26 @@ const LoginPage: React.FC = () => {
|
||||
) : (
|
||||
// Normal login form
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
|
||||
<LockOutlined />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
{t('signIn')}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<img src={logo} alt="Logo" height={48} />
|
||||
<Box sx={{ ml: 1.5, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
|
||||
{websiteName}
|
||||
</Typography>
|
||||
{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
|
||||
margin="normal"
|
||||
required
|
||||
@@ -137,27 +260,55 @@ const LoginPage: React.FC = () => {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
disabled={waitTime > 0 || loginMutation.isPending}
|
||||
helperText={t('defaultPasswordHint') || "Default password: 123"}
|
||||
/>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
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
|
||||
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>
|
||||
</Container>
|
||||
<ConfirmationModal
|
||||
isOpen={showResetModal}
|
||||
onClose={() => setShowResetModal(false)}
|
||||
onConfirm={handleResetPassword}
|
||||
title={t('resetPasswordTitle')}
|
||||
message={t('resetPasswordMessage')}
|
||||
confirmText={t('resetPasswordConfirm')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -231,6 +231,13 @@ export const ar = {
|
||||
backendConnectionFailed:
|
||||
"لا يمكن الاتصال بالخادم. يرجى التحقق مما إذا كان الخادم يعمل والمنفذ مفتوح، ثم حاول مرة أخرى.",
|
||||
retry: "إعادة المحاولة",
|
||||
resetPassword: "إعادة تعيين كلمة المرور",
|
||||
resetPasswordTitle: "إعادة تعيين كلمة المرور",
|
||||
resetPasswordMessage: "هل أنت متأكد من أنك تريد إعادة تعيين كلمة المرور؟ سيتم إعادة تعيين كلمة المرور الحالية إلى سلسلة عشوائية مكونة من 8 أحرف وعرضها في سجل الخادم.",
|
||||
resetPasswordConfirm: "إعادة التعيين",
|
||||
resetPasswordSuccess: "تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
|
||||
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
|
||||
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
|
||||
linkCopied: "تم نسخ الرابط إلى الحافظة",
|
||||
copyFailed: "فشل نسخ الرابط",
|
||||
|
||||
|
||||
@@ -223,6 +223,13 @@ export const de = {
|
||||
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.",
|
||||
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",
|
||||
copyFailed: "Link konnte nicht kopiert werden",
|
||||
loadingCollection: "Sammlung wird geladen...",
|
||||
|
||||
@@ -228,6 +228,13 @@ export const en = {
|
||||
backendConnectionFailed:
|
||||
"Unable to connect to the server. Please check if the backend is running and port is open, then try again.",
|
||||
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",
|
||||
copyFailed: "Failed to copy link",
|
||||
|
||||
|
||||
@@ -247,6 +247,13 @@ export const es = {
|
||||
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.",
|
||||
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",
|
||||
copyFailed: "Error al copiar enlace",
|
||||
|
||||
|
||||
@@ -250,6 +250,13 @@ export const fr = {
|
||||
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.",
|
||||
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",
|
||||
copyFailed: "Échec de la copie du lien",
|
||||
|
||||
|
||||
@@ -236,6 +236,13 @@ export const ja = {
|
||||
backendConnectionFailed:
|
||||
"サーバーに接続できません。バックエンドが実行中でポートが開いているか確認してから、もう一度お試しください。",
|
||||
retry: "再試行",
|
||||
resetPassword: "パスワードをリセット",
|
||||
resetPasswordTitle: "パスワードをリセット",
|
||||
resetPasswordMessage: "パスワードをリセットしてもよろしいですか?現在のパスワードはランダムな8文字の文字列にリセットされ、バックエンドログに表示されます。",
|
||||
resetPasswordConfirm: "リセット",
|
||||
resetPasswordSuccess: "パスワードがリセットされました。新しいパスワードについては、バックエンドログを確認してください。",
|
||||
waitTimeMessage: "再試行する前に {time} お待ちください。",
|
||||
tooManyAttempts: "失敗した試行が多すぎます。",
|
||||
linkCopied: "リンクをクリップボードにコピーしました",
|
||||
copyFailed: "リンクのコピーに失敗しました",
|
||||
|
||||
|
||||
@@ -233,6 +233,13 @@ export const ko = {
|
||||
backendConnectionFailed:
|
||||
"서버에 연결할 수 없습니다. 백엔드가 실행 중이고 포트가 열려 있는지 확인한 후 다시 시도하세요.",
|
||||
retry: "다시 시도",
|
||||
resetPassword: "비밀번호 재설정",
|
||||
resetPasswordTitle: "비밀번호 재설정",
|
||||
resetPasswordMessage: "비밀번호를 재설정하시겠습니까? 현재 비밀번호는 무작위 8자 문자열로 재설정되며 백엔드 로그에 표시됩니다.",
|
||||
resetPasswordConfirm: "재설정",
|
||||
resetPasswordSuccess: "비밀번호가 재설정되었습니다. 새 비밀번호는 백엔드 로그를 확인하세요.",
|
||||
waitTimeMessage: "다시 시도하기 전에 {time} 기다려 주세요.",
|
||||
tooManyAttempts: "실패한 시도가 너무 많습니다.",
|
||||
linkCopied: "링크가 클립보드에 복사되었습니다",
|
||||
copyFailed: "링크 복사 실패",
|
||||
|
||||
|
||||
@@ -245,6 +245,13 @@ export const pt = {
|
||||
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.",
|
||||
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",
|
||||
copyFailed: "Falha ao copiar link",
|
||||
|
||||
|
||||
@@ -245,6 +245,13 @@ export const ru = {
|
||||
backendConnectionFailed:
|
||||
"Не удалось подключиться к серверу. Убедитесь, что сервер запущен и порт открыт, затем повторите попытку.",
|
||||
retry: "Повторить",
|
||||
resetPassword: "Сбросить пароль",
|
||||
resetPasswordTitle: "Сбросить пароль",
|
||||
resetPasswordMessage: "Вы уверены, что хотите сбросить пароль? Текущий пароль будет сброшен на случайную 8-символьную строку и отображен в логе бэкенда.",
|
||||
resetPasswordConfirm: "Сбросить",
|
||||
resetPasswordSuccess: "Пароль был сброшен. Проверьте логи бэкенда для нового пароля.",
|
||||
waitTimeMessage: "Пожалуйста, подождите {time} перед повторной попыткой.",
|
||||
tooManyAttempts: "Слишком много неудачных попыток.",
|
||||
linkCopied: "Ссылка скопирована в буфер обмена",
|
||||
copyFailed: "Не удалось скопировать ссылку",
|
||||
|
||||
|
||||
@@ -226,6 +226,13 @@ export const zh = {
|
||||
backendConnectionFailed:
|
||||
"无法连接到服务器。请检查后端是否正在运行并确保端口已开放,然后重试。",
|
||||
retry: "重试",
|
||||
resetPassword: "重置密码",
|
||||
resetPasswordTitle: "重置密码",
|
||||
resetPasswordMessage: "您确定要重置密码吗?当前密码将被重置为一个随机的8位字符串,并显示在后端日志中。",
|
||||
resetPasswordConfirm: "重置",
|
||||
resetPasswordSuccess: "密码已重置。请查看后端日志以获取新密码。",
|
||||
waitTimeMessage: "请等待 {time} 后再试。",
|
||||
tooManyAttempts: "失败尝试次数过多。",
|
||||
linkCopied: "链接已复制到剪贴板",
|
||||
copyFailed: "复制链接失败",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user