feat: Add endpoint for retrieving reset password cooldown

This commit is contained in:
Peifan Li
2026-01-03 12:58:35 -05:00
parent 9296390b82
commit 092a79f635
14 changed files with 148 additions and 30 deletions

View File

@@ -40,6 +40,20 @@ export const verifyPassword = async (
}
};
/**
* Get the remaining cooldown time for password reset
* Errors are automatically handled by asyncHandler middleware
*/
export const getResetPasswordCooldown = async (
_req: Request,
res: Response
): Promise<void> => {
const remainingCooldown = passwordService.getResetPasswordCooldown();
res.json({
cooldown: remainingCooldown,
});
};
/**
* Reset password to a random 8-character string
* Errors are automatically handled by asyncHandler middleware

View File

@@ -20,6 +20,7 @@ import {
} from "../controllers/hookController";
import {
getPasswordEnabled,
getResetPasswordCooldown,
resetPassword,
verifyPassword,
} from "../controllers/passwordController";
@@ -54,6 +55,7 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
// Password routes
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
router.get("/reset-password-cooldown", asyncHandler(getResetPasswordCooldown));
router.post("/verify-password", asyncHandler(verifyPassword));
router.post("/reset-password", asyncHandler(resetPassword));

View File

@@ -109,6 +109,28 @@ export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, salt);
}
const RESET_PASSWORD_COOLDOWN = 60 * 60 * 1000; // 1 hour in milliseconds
/**
* Get the remaining cooldown time for password reset
* Returns the remaining time in milliseconds, or 0 if no cooldown
*/
export function getResetPasswordCooldown(): number {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
const lastResetTime = (mergedSettings as any).lastPasswordResetTime as number | undefined;
if (!lastResetTime) {
return 0;
}
const timeSinceLastReset = Date.now() - lastResetTime;
const remainingCooldown = RESET_PASSWORD_COOLDOWN - timeSinceLastReset;
return remainingCooldown > 0 ? remainingCooldown : 0;
}
/**
* Reset password to a random 8-character string
* Returns the new password (should be logged, not sent to frontend)
@@ -131,6 +153,13 @@ export async function resetPassword(): Promise<string> {
throw new Error("Password reset is not allowed when password login is disabled");
}
// Check cooldown period (1 hour)
const remainingCooldown = getResetPasswordCooldown();
if (remainingCooldown > 0) {
const minutes = Math.ceil(remainingCooldown / (60 * 1000));
throw new Error(`Password reset is on cooldown. Please wait ${minutes} minute${minutes !== 1 ? 's' : ''} before trying again.`);
}
// Generate random 8-character password using cryptographically secure random
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -142,9 +171,10 @@ export async function resetPassword(): Promise<string> {
// Hash the new password
const hashedPassword = await hashPassword(newPassword);
// Update settings with new password
// Update settings with new password and reset timestamp
mergedSettings.password = hashedPassword;
mergedSettings.loginEnabled = true; // Ensure login is enabled
(mergedSettings as any).lastPasswordResetTime = Date.now();
storageService.saveSettings(mergedSettings);

View File

@@ -39,6 +39,7 @@ const LoginPage: React.FC = () => {
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [websiteName, setWebsiteName] = useState('MyTube');
const [resetPasswordCooldown, setResetPasswordCooldown] = useState(0); // in milliseconds
const { t } = useLanguage();
const { login } = useAuth();
const queryClient = useQueryClient();
@@ -97,6 +98,27 @@ const LoginPage: React.FC = () => {
const passkeysExist = passkeysData?.exists || false;
// Fetch reset password cooldown from backend
const { data: cooldownData } = useQuery({
queryKey: ['resetPasswordCooldown'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings/reset-password-cooldown`, { timeout: 5000 });
return response.data;
} catch (error) {
return { cooldown: 0 };
}
},
retry: 1,
retryDelay: 1000,
enabled: !isCheckingConnection && !isConnectionError,
refetchInterval: (query) => {
// Refetch every second if there's an active cooldown
const cooldown = query.state.data?.cooldown || 0;
return cooldown > 0 ? 1000 : false;
},
});
// Initialize wait time from server response
useEffect(() => {
if (statusData && statusData.waitTime) {
@@ -104,6 +126,13 @@ const LoginPage: React.FC = () => {
}
}, [statusData]);
// Update reset password cooldown from server response
useEffect(() => {
if (cooldownData && cooldownData.cooldown !== undefined) {
setResetPasswordCooldown(cooldownData.cooldown);
}
}, [cooldownData]);
// Auto-login only if login is not required
useEffect(() => {
if (statusData && statusData.loginRequired === false) {
@@ -124,6 +153,19 @@ const LoginPage: React.FC = () => {
}
}, [waitTime]);
// Countdown timer for reset password cooldown (updates local state while server refetches)
useEffect(() => {
if (resetPasswordCooldown > 0) {
const interval = setInterval(() => {
setResetPasswordCooldown((prev) => {
const newTime = prev - 1000;
return newTime > 0 ? newTime : 0;
});
}, 1000);
return () => clearInterval(interval);
}
}, [resetPasswordCooldown]);
// Use dark theme for login page to match app style
const theme = getTheme('dark');
@@ -196,13 +238,22 @@ const LoginPage: React.FC = () => {
setShowResetModal(false);
setError('');
setWaitTime(0);
// Invalidate queries to refresh cooldown status
queryClient.invalidateQueries({ queryKey: ['healthCheck'] });
queryClient.invalidateQueries({ queryKey: ['resetPasswordCooldown'] });
// Show success message
showAlert(t('success'), t('resetPasswordSuccess'));
},
onError: (err: any) => {
console.error('Reset password error:', err);
showAlert(t('error'), t('loginFailed'));
if (err.response && err.response.data && err.response.data.message) {
// Server returned a specific error message (likely cooldown)
showAlert(t('error'), err.response.data.message);
// Refresh cooldown status
queryClient.invalidateQueries({ queryKey: ['resetPasswordCooldown'] });
} else {
showAlert(t('error'), t('loginFailed'));
}
}
});
@@ -442,9 +493,11 @@ const LoginPage: React.FC = () => {
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending}
disabled={resetPasswordMutation.isPending || resetPasswordCooldown > 0}
>
{t('resetPassword')}
{resetPasswordCooldown > 0
? `${t('resetPassword')} (${formatWaitTime(resetPasswordCooldown)})`
: t('resetPassword')}
</Button>
)}
{!allowResetPassword && passwordLoginAllowed && (
@@ -489,7 +542,7 @@ const LoginPage: React.FC = () => {
onClose={() => setShowResetModal(false)}
onConfirm={handleResetPassword}
title={t('resetPasswordTitle')}
message={t('resetPasswordMessage')}
message={`${t('resetPasswordMessage')}\n\n${t('resetPasswordScriptGuide')}`}
confirmText={t('resetPasswordConfirm')}
cancelText={t('cancel')}
isDanger={true}

View File

@@ -315,6 +315,7 @@ export const ar = {
resetPasswordSuccess:
"تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
resetPasswordDisabledInfo: "تم تعطيل إعادة تعيين كلمة المرور. لإعادة تعيين كلمة المرور، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nسيؤدي هذا إلى إنشاء كلمة مرور عشوائية جديدة وتمكين تسجيل الدخول بكلمة المرور.",
resetPasswordScriptGuide: "لإعادة تعيين كلمة المرور يدوياً، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nإذا لم يتم توفير كلمة مرور، سيتم إنشاء كلمة مرور عشوائية مكونة من 8 أحرف.",
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
// Passkeys

View File

@@ -315,6 +315,7 @@ export const de = {
resetPasswordSuccess:
"Das Passwort wurde zurückgesetzt. Überprüfen Sie die Backend-Protokolle für das neue Passwort.",
resetPasswordDisabledInfo: "Die Passwort-Zurücksetzung ist deaktiviert. Um Ihr Passwort zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nDies generiert ein neues zufälliges Passwort und aktiviert die Passwort-Anmeldung.",
resetPasswordScriptGuide: "Um das Passwort manuell zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nWenn kein Passwort angegeben wird, wird ein zufälliges 8-stelliges Passwort generiert.",
waitTimeMessage: "Bitte warten Sie {time}, bevor Sie es erneut versuchen.",
tooManyAttempts: "Zu viele fehlgeschlagene Versuche.",
// Passkeys

View File

@@ -329,6 +329,7 @@ export const en = {
resetPasswordSuccess:
"Password has been reset. Check backend logs for the new password.",
resetPasswordDisabledInfo: "Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.",
resetPasswordScriptGuide: "To reset password manually, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nIf no password is provided, a random 8-character password will be generated.",
waitTimeMessage: "Please wait {time} before trying again.",
tooManyAttempts: "Too many failed attempts.",
// Passkeys

View File

@@ -338,6 +338,7 @@ export const es = {
resetPasswordSuccess:
"La contraseña ha sido restablecida. Consulte los registros del backend para obtener la nueva contraseña.",
resetPasswordDisabledInfo: "El restablecimiento de contraseña está deshabilitado. Para restablecer su contraseña, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nEsto generará una nueva contraseña aleatoria y habilitará el inicio de sesión con contraseña.",
resetPasswordScriptGuide: "Para restablecer la contraseña manualmente, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nSi no se proporciona una contraseña, se generará una contraseña aleatoria de 8 caracteres.",
waitTimeMessage: "Por favor espere {time} antes de intentar nuevamente.",
tooManyAttempts: "Demasiados intentos fallidos.",
// Passkeys

View File

@@ -338,6 +338,7 @@ export const fr = {
resetPasswordSuccess:
"Le mot de passe a été réinitialisé. Consultez les journaux du backend pour le nouveau mot de passe.",
resetPasswordDisabledInfo: "La réinitialisation du mot de passe est désactivée. Pour réinitialiser votre mot de passe, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nCela générera un nouveau mot de passe aléatoire et activera la connexion par mot de passe.",
resetPasswordScriptGuide: "Pour réinitialiser le mot de passe manuellement, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nSi aucun mot de passe n'est fourni, un mot de passe aléatoire de 8 caractères sera généré.",
waitTimeMessage: "Veuillez attendre {time} avant de réessayer.",
tooManyAttempts: "Trop de tentatives échouées.",
// Passkeys

View File

@@ -322,6 +322,7 @@ export const ja = {
resetPasswordSuccess:
"パスワードがリセットされました。新しいパスワードについては、バックエンドログを確認してください。",
resetPasswordDisabledInfo: "パスワードリセットは無効になっています。パスワードをリセットするには、バックエンドディレクトリで次のコマンドを実行してください:\n\nnpm run reset-password\n\nまたは\n\nts-node scripts/reset-password.ts\n\nこれにより、新しいランダムパスワードが生成され、パスワードログインが有効になります。",
resetPasswordScriptGuide: "パスワードを手動でリセットするには、バックエンドディレクトリで次のコマンドを実行してください:\n\nnpm run reset-password\n\nまたは\n\nts-node scripts/reset-password.ts\n\nパスワードが提供されない場合、ランダムな8文字のパスワードが生成されます。",
waitTimeMessage: "再試行する前に {time} お待ちください。",
tooManyAttempts: "失敗した試行が多すぎます。",
// Passkeys

View File

@@ -317,6 +317,7 @@ export const ko = {
resetPasswordSuccess:
"비밀번호가 재설정되었습니다. 새 비밀번호는 백엔드 로그를 확인하세요.",
resetPasswordDisabledInfo: "비밀번호 재설정이 비활성화되어 있습니다. 비밀번호를 재설정하려면 백엔드 디렉토리에서 다음 명령을 실행하세요:\n\nnpm run reset-password\n\n또는:\n\nts-node scripts/reset-password.ts\n\n이렇게 하면 새로운 임의의 비밀번호가 생성되고 비밀번호 로그인이 활성화됩니다.",
resetPasswordScriptGuide: "비밀번호를 수동으로 재설정하려면 백엔드 디렉토리에서 다음 명령을 실행하세요:\n\nnpm run reset-password\n\n또는:\n\nts-node scripts/reset-password.ts\n\n비밀번호가 제공되지 않으면 임의의 8자 비밀번호가 생성됩니다.",
waitTimeMessage: "다시 시도하기 전에 {time} 기다려 주세요.",
tooManyAttempts: "실패한 시도가 너무 많습니다.",
// Passkeys

View File

@@ -333,6 +333,7 @@ export const pt = {
resetPasswordSuccess:
"A senha foi redefinida. Verifique os logs do backend para a nova senha.",
resetPasswordDisabledInfo: "A redefinição de senha está desabilitada. Para redefinir sua senha, execute o seguinte comando no diretório do backend:\n\nnpm run reset-password\n\nOu:\n\nts-node scripts/reset-password.ts\n\nIsso gerará uma nova senha aleatória e habilitará o login com senha.",
resetPasswordScriptGuide: "Para redefinir a senha manualmente, execute o seguinte comando no diretório do backend:\n\nnpm run reset-password\n\nOu:\n\nts-node scripts/reset-password.ts\n\nSe nenhuma senha for fornecida, uma senha aleatória de 8 caracteres será gerada.",
waitTimeMessage: "Por favor, aguarde {time} antes de tentar novamente.",
tooManyAttempts: "Muitas tentativas falharam.",
// Passkeys

View File

@@ -333,6 +333,7 @@ export const ru = {
resetPasswordSuccess:
"Пароль был сброшен. Проверьте логи бэкенда для нового пароля.",
resetPasswordDisabledInfo: "Сброс пароля отключен. Чтобы сбросить пароль, выполните следующую команду в директории бэкенда:\n\nnpm run reset-password\n\nИли:\n\nts-node scripts/reset-password.ts\n\nЭто сгенерирует новый случайный пароль и включит вход по паролю.",
resetPasswordScriptGuide: "Чтобы вручную сбросить пароль, выполните следующую команду в директории бэкенда:\n\nnpm run reset-password\n\nИли:\n\nts-node scripts/reset-password.ts\n\nЕсли пароль не указан, будет сгенерирован случайный 8-символьный пароль.",
waitTimeMessage: "Пожалуйста, подождите {time} перед повторной попыткой.",
tooManyAttempts: "Слишком много неудачных попыток.",
// Passkeys

View File

@@ -53,9 +53,11 @@ export const zh = {
columnsCount: "{count} 列",
enableLogin: "启用登录保护",
allowPasswordLogin: "允许密码登录",
allowPasswordLoginHelper: "禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。",
allowPasswordLoginHelper:
"禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。",
allowResetPassword: "允许重置密码",
allowResetPasswordHelper: "禁用后,登录页面将不显示重置密码按钮,并且重置密码 API 将被阻止。",
allowResetPasswordHelper:
"禁用后,登录页面将不显示重置密码按钮,并且重置密码 API 将被阻止。",
password: "密码",
enterPassword: "输入密码",
togglePasswordVisibility: "切换密码可见性",
@@ -304,7 +306,10 @@ export const zh = {
"您确定要重置密码吗当前密码将被重置为一个随机的8位字符串并显示在后端日志中。",
resetPasswordConfirm: "重置",
resetPasswordSuccess: "密码已重置。请查看后端日志以获取新密码。",
resetPasswordDisabledInfo: "密码重置已禁用。要重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或\n\nts-node scripts/reset-password.ts\n\n这将生成一个新的随机密码并启用密码登录。",
resetPasswordDisabledInfo:
"密码重置已禁用。要重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或\n\nts-node scripts/reset-password.ts\n\n这将生成一个新的随机密码并启用密码登录。",
resetPasswordScriptGuide:
"要手动重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或\n\nts-node scripts/reset-password.ts\n\n如果未提供密码将生成一个随机的8位密码。",
waitTimeMessage: "请等待 {time} 后再试。",
tooManyAttempts: "失败尝试次数过多。",
// Passkeys
@@ -312,8 +317,10 @@ export const zh = {
creatingPasskey: "创建中...",
passkeyCreated: "通行密钥创建成功",
passkeyCreationFailed: "创建通行密钥失败,请重试。",
passkeyWebAuthnNotSupported: "此浏览器不支持 WebAuthn。请使用支持 WebAuthn 的现代浏览器。",
passkeyRequiresHttps: "WebAuthn 需要 HTTPS 或 localhost。请通过 HTTPS 访问应用程序,或使用 localhost 而不是 IP 地址。",
passkeyWebAuthnNotSupported:
"此浏览器不支持 WebAuthn。请使用支持 WebAuthn 的现代浏览器。",
passkeyRequiresHttps:
"WebAuthn 需要 HTTPS 或 localhost。请通过 HTTPS 访问应用程序,或使用 localhost 而不是 IP 地址。",
removePasskeys: "删除所有通行密钥",
removePasskeysTitle: "删除所有通行密钥",
removePasskeysMessage: "您确定要删除所有通行密钥吗?此操作无法撤销。",
@@ -322,7 +329,8 @@ export const zh = {
loginWithPasskey: "使用通行密钥登录",
authenticating: "验证中...",
passkeyLoginFailed: "通行密钥验证失败,请重试。",
passkeyErrorPermissionDenied: "用户代理或平台在当前上下文中不允许该请求,可能是因为用户拒绝了权限。",
passkeyErrorPermissionDenied:
"用户代理或平台在当前上下文中不允许该请求,可能是因为用户拒绝了权限。",
passkeyErrorAlreadyRegistered: "该认证器之前已注册。",
linkCopied: "链接已复制到剪贴板",
copyFailed: "复制链接失败",
@@ -480,7 +488,8 @@ export const zh = {
taskDeleted: "任务已成功删除",
clearFinishedTasks: "清除已完成任务",
tasksCleared: "已成功清除已完成的任务",
confirmClearFinishedTasks: "您确定要清除所有已完成的任务(包括已完成和已取消)吗?这只会将其从列表中移除,不会删除任何已下载的文件。",
confirmClearFinishedTasks:
"您确定要清除所有已完成的任务(包括已完成和已取消)吗?这只会将其从列表中移除,不会删除任何已下载的文件。",
clear: "清除",
// Existing Video Detection
@@ -636,24 +645,25 @@ export const zh = {
copyUrl: "复制链接",
new: "新",
// Task Hooks
taskHooks: '任务钩子',
taskHooksDescription: '在任务生命周期的特定时间点执行自定义 Shell 命令。可用环境变量: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。',
taskHooksWarning: '警告:命令将以服务器权限运行。请谨慎使用。',
enterPasswordToUploadHook: '请输入密码以上传此 Hook 脚本。',
riskCommandDetected: '检测到危险命令:{command}。上传已拒绝。',
hookTaskBeforeStart: '任务开始前',
hookTaskBeforeStartHelper: '在下载开始前执行。',
hookTaskSuccess: '任务成功',
hookTaskSuccessHelper: '在下载成功后,云上传/删除前执行 (等待完成)。',
hookTaskFail: '任务失败',
hookTaskFailHelper: '当任务失败时执行。',
hookTaskCancel: '任务取消',
hookTaskCancelHelper: '当任务被手动取消时执行。',
found: '已找到',
notFound: '未设置',
deleteHook: '删除钩子脚本',
confirmDeleteHook: '确定要删除钩子脚本吗?',
uploadHook: '上传 .sh',
taskHooks: "任务钩子",
taskHooksDescription:
"在任务生命周期的特定时间点执行自定义 Shell 命令。可用环境变量: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。",
taskHooksWarning: "警告:命令将以服务器权限运行。请谨慎使用。",
enterPasswordToUploadHook: "请输入密码以上传此 Hook 脚本。",
riskCommandDetected: "检测到危险命令:{command}。上传已拒绝。",
hookTaskBeforeStart: "任务开始前",
hookTaskBeforeStartHelper: "在下载开始前执行。",
hookTaskSuccess: "任务成功",
hookTaskSuccessHelper: "在下载成功后,云上传/删除前执行 (等待完成)。",
hookTaskFail: "任务失败",
hookTaskFailHelper: "当任务失败时执行。",
hookTaskCancel: "任务取消",
hookTaskCancelHelper: "当任务被手动取消时执行。",
found: "已找到",
notFound: "未设置",
deleteHook: "删除钩子脚本",
confirmDeleteHook: "确定要删除此钩子脚本吗?",
uploadHook: "上传 .sh",
disclaimerTitle: "免责声明",
disclaimerText: