feat: Add functionality to reset password and handle wait time

This commit is contained in:
Peifan Li
2025-12-15 23:43:23 -05:00
parent 4fb1c1c8f9
commit c1d898b548
15 changed files with 482 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
{
"failedAttempts": 0,
"lastFailedAttemptTime": 0,
"waitUntil": 0
}

View File

@@ -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.",
});
};

View File

@@ -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));

View 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;
}

View File

@@ -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>
);
};

View File

@@ -231,6 +231,13 @@ export const ar = {
backendConnectionFailed:
"لا يمكن الاتصال بالخادم. يرجى التحقق مما إذا كان الخادم يعمل والمنفذ مفتوح، ثم حاول مرة أخرى.",
retry: "إعادة المحاولة",
resetPassword: "إعادة تعيين كلمة المرور",
resetPasswordTitle: "إعادة تعيين كلمة المرور",
resetPasswordMessage: "هل أنت متأكد من أنك تريد إعادة تعيين كلمة المرور؟ سيتم إعادة تعيين كلمة المرور الحالية إلى سلسلة عشوائية مكونة من 8 أحرف وعرضها في سجل الخادم.",
resetPasswordConfirm: "إعادة التعيين",
resetPasswordSuccess: "تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
linkCopied: "تم نسخ الرابط إلى الحافظة",
copyFailed: "فشل نسخ الرابط",

View File

@@ -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...",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -236,6 +236,13 @@ export const ja = {
backendConnectionFailed:
"サーバーに接続できません。バックエンドが実行中でポートが開いているか確認してから、もう一度お試しください。",
retry: "再試行",
resetPassword: "パスワードをリセット",
resetPasswordTitle: "パスワードをリセット",
resetPasswordMessage: "パスワードをリセットしてもよろしいですか現在のパスワードはランダムな8文字の文字列にリセットされ、バックエンドログに表示されます。",
resetPasswordConfirm: "リセット",
resetPasswordSuccess: "パスワードがリセットされました。新しいパスワードについては、バックエンドログを確認してください。",
waitTimeMessage: "再試行する前に {time} お待ちください。",
tooManyAttempts: "失敗した試行が多すぎます。",
linkCopied: "リンクをクリップボードにコピーしました",
copyFailed: "リンクのコピーに失敗しました",

View File

@@ -233,6 +233,13 @@ export const ko = {
backendConnectionFailed:
"서버에 연결할 수 없습니다. 백엔드가 실행 중이고 포트가 열려 있는지 확인한 후 다시 시도하세요.",
retry: "다시 시도",
resetPassword: "비밀번호 재설정",
resetPasswordTitle: "비밀번호 재설정",
resetPasswordMessage: "비밀번호를 재설정하시겠습니까? 현재 비밀번호는 무작위 8자 문자열로 재설정되며 백엔드 로그에 표시됩니다.",
resetPasswordConfirm: "재설정",
resetPasswordSuccess: "비밀번호가 재설정되었습니다. 새 비밀번호는 백엔드 로그를 확인하세요.",
waitTimeMessage: "다시 시도하기 전에 {time} 기다려 주세요.",
tooManyAttempts: "실패한 시도가 너무 많습니다.",
linkCopied: "링크가 클립보드에 복사되었습니다",
copyFailed: "링크 복사 실패",

View File

@@ -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",

View File

@@ -245,6 +245,13 @@ export const ru = {
backendConnectionFailed:
"Не удалось подключиться к серверу. Убедитесь, что сервер запущен и порт открыт, затем повторите попытку.",
retry: "Повторить",
resetPassword: "Сбросить пароль",
resetPasswordTitle: "Сбросить пароль",
resetPasswordMessage: "Вы уверены, что хотите сбросить пароль? Текущий пароль будет сброшен на случайную 8-символьную строку и отображен в логе бэкенда.",
resetPasswordConfirm: "Сбросить",
resetPasswordSuccess: "Пароль был сброшен. Проверьте логи бэкенда для нового пароля.",
waitTimeMessage: "Пожалуйста, подождите {time} перед повторной попыткой.",
tooManyAttempts: "Слишком много неудачных попыток.",
linkCopied: "Ссылка скопирована в буфер обмена",
copyFailed: "Не удалось скопировать ссылку",

View File

@@ -226,6 +226,13 @@ export const zh = {
backendConnectionFailed:
"无法连接到服务器。请检查后端是否正在运行并确保端口已开放,然后重试。",
retry: "重试",
resetPassword: "重置密码",
resetPasswordTitle: "重置密码",
resetPasswordMessage: "您确定要重置密码吗当前密码将被重置为一个随机的8位字符串并显示在后端日志中。",
resetPasswordConfirm: "重置",
resetPasswordSuccess: "密码已重置。请查看后端日志以获取新密码。",
waitTimeMessage: "请等待 {time} 后再试。",
tooManyAttempts: "失败尝试次数过多。",
linkCopied: "链接已复制到剪贴板",
copyFailed: "复制链接失败",