add cooldown notification repeat

This commit is contained in:
lendry
2026-06-25 14:45:01 +03:00
parent 6c63343fc7
commit 9671fe458b
11 changed files with 173 additions and 25 deletions

View File

@@ -30,10 +30,18 @@ import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AuthTokens, LoginMethod, OtpChannel } from '@/lib/api';
import type { AuthTokens, LoginMethod, OtpChannel, OtpSendResponse } from '@/lib/api';
import { createQrLoginSession, pollQrLoginSession } from '@/lib/api';
import {
clearOtpResendAvailableAt,
formatCooldownSeconds,
readOtpResendAvailableAt,
useOtpResendCooldown,
writeOtpResendAvailableAt
} from '@/hooks/use-otp-resend-cooldown';
type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'totp';
@@ -129,6 +137,10 @@ export default function LoginPage() {
const [qrExpiresAt, setQrExpiresAt] = useState<string | null>(null);
const [qrLoading, setQrLoading] = useState(false);
const [lastOtpChannel, setLastOtpChannel] = useState<string | undefined>();
const [otpResendAvailableAt, setOtpResendAvailableAt] = useState<string | null>(null);
const otpResendSecondsLeft = useOtpResendCooldown(otpResendAvailableAt);
const canResendOtp = otpResendSecondsLeft <= 0 && !isSubmitting;
@@ -180,34 +192,34 @@ export default function LoginPage() {
function applyOtpSendResponse(response: OtpSendResponse, loginRecipient: string, channel?: string) {
setMaskedTarget(response.maskedTarget);
setLastOtpChannel(channel);
setOtp('');
setShowMethods(false);
const resendAt = response.resendAvailableAt ?? new Date(Date.now() + 60_000).toISOString();
setOtpResendAvailableAt(resendAt);
writeOtpResendAvailableAt(loginRecipient, resendAt);
setStep('otp');
}
async function requestLoginOtp(channel?: string, recipientOverride?: string) {
setIsSubmitting(true);
const loginRecipient = recipientOverride ?? recipient;
try {
const masked = await sendLoginOtp(loginRecipient, channel);
setMaskedTarget(masked);
setOtp('');
setShowMethods(false);
setStep('otp');
const response = await sendLoginOtp(loginRecipient, channel);
applyOtpSendResponse(response, loginRecipient, channel);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
} finally {
setIsSubmitting(false);
}
}
async function handleResendOtp() {
if (!canResendOtp) return;
await requestLoginOtp(lastOtpChannel, recipient);
}
@@ -230,6 +242,14 @@ export default function LoginPage() {
useEffect(() => {
if (step !== 'otp' || !recipient) return;
const saved = readOtpResendAvailableAt(recipient);
if (saved && new Date(saved).getTime() > Date.now()) {
setOtpResendAvailableAt(saved);
}
}, [recipient, step]);
const startQrLogin = useCallback(async () => {
setQrLoading(true);
@@ -618,6 +638,10 @@ export default function LoginPage() {
setQrExpiresAt(null);
clearOtpResendAvailableAt(recipient);
setOtpResendAvailableAt(null);
setLastOtpChannel(undefined);
}
@@ -1022,6 +1046,22 @@ export default function LoginPage() {
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
<div className="mt-4 text-center">
{canResendOtp ? (
<button
type="button"
onClick={() => void handleResendOtp()}
className="text-sm text-[#b9bdc9] underline-offset-4 transition hover:text-white hover:underline"
>
Запросить новый код
</button>
) : (
<p className="text-sm text-[#b9bdc9]">
Запросить новый код через {formatCooldownSeconds(otpResendSecondsLeft)}
</p>
)}
</div>
{renderAlternativeMethods(false)}
{totpChallengeToken ? (

View File

@@ -51,8 +51,8 @@ export default function RegisterPage() {
try {
const identifier = getIdentifier();
setRecipient(identifier);
const masked = await sendLoginOtp(identifier);
setMaskedTarget(masked);
const response = await sendLoginOtp(identifier);
setMaskedTarget(response.maskedTarget);
setOtp('');
setStep('otp');
showToast('Код отправлен. Введите его ниже.');

View File

@@ -35,7 +35,7 @@ interface AuthContextValue {
hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>;
identifyLogin: (login: string) => Promise<IdentifyResponse>;
sendLoginOtp: (recipient: string, channel?: string) => Promise<string>;
sendLoginOtp: (recipient: string, channel?: string) => Promise<OtpSendResponse>;
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
@@ -308,11 +308,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => {
const response = await apiFetch<OtpSendResponse>('/auth/otp/send', {
return apiFetch<OtpSendResponse>('/auth/otp/send', {
method: 'POST',
body: JSON.stringify({ recipient, channel })
});
return response.maskedTarget;
}, []);
const verifyLoginOtp = React.useCallback(

View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useState } from 'react';
export function formatCooldownSeconds(seconds: number) {
const safe = Math.max(0, seconds);
const minutes = Math.floor(safe / 60);
const rest = safe % 60;
return `${minutes}:${String(rest).padStart(2, '0')}`;
}
export function useOtpResendCooldown(resendAvailableAt: string | null) {
const [secondsLeft, setSecondsLeft] = useState(0);
useEffect(() => {
if (!resendAvailableAt) {
setSecondsLeft(0);
return;
}
const tick = () => {
const diff = Math.ceil((new Date(resendAvailableAt).getTime() - Date.now()) / 1000);
setSecondsLeft(Math.max(0, diff));
};
tick();
const timer = window.setInterval(tick, 1000);
return () => window.clearInterval(timer);
}, [resendAvailableAt]);
return secondsLeft;
}
export function readOtpResendAvailableAt(recipient: string) {
if (typeof window === 'undefined' || !recipient) return null;
try {
return sessionStorage.getItem(`otp-resend:${recipient.toLowerCase()}`);
} catch {
return null;
}
}
export function writeOtpResendAvailableAt(recipient: string, resendAvailableAt: string) {
if (typeof window === 'undefined' || !recipient) return;
try {
sessionStorage.setItem(`otp-resend:${recipient.toLowerCase()}`, resendAvailableAt);
} catch {
// ignore quota errors
}
}
export function clearOtpResendAvailableAt(recipient: string) {
if (typeof window === 'undefined' || !recipient) return;
try {
sessionStorage.removeItem(`otp-resend:${recipient.toLowerCase()}`);
} catch {
// ignore
}
}

View File

@@ -273,6 +273,7 @@ export interface OtpSendResponse {
sent: boolean;
expiresAt: string;
maskedTarget: string;
resendAvailableAt?: string;
}
export interface PinVerificationResponse {

View File

@@ -38,6 +38,7 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
{ key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' },
{ key: 'OTP_RESEND_COOLDOWN_SECONDS', label: 'Пауза перед повторной отправкой OTP', group: 'auth', type: 'number', unit: 'сек', hint: 'Минимальный интервал между запросами нового кода (обычно 60)' },
{ key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' },
{
key: 'ACCOUNT_DELETE_GRACE_DAYS',