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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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