add cooldown notification repeat
This commit is contained in:
@@ -19,6 +19,8 @@ function grpcToHttp(code?: number): number {
|
|||||||
return 404;
|
return 404;
|
||||||
case GrpcStatus.ALREADY_EXISTS:
|
case GrpcStatus.ALREADY_EXISTS:
|
||||||
return 409;
|
return 409;
|
||||||
|
case GrpcStatus.RESOURCE_EXHAUSTED:
|
||||||
|
return 429;
|
||||||
default:
|
default:
|
||||||
return 500;
|
return 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,18 @@ import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'
|
|||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { 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';
|
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 [qrExpiresAt, setQrExpiresAt] = useState<string | null>(null);
|
||||||
|
|
||||||
const [qrLoading, setQrLoading] = useState(false);
|
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) {
|
async function requestLoginOtp(channel?: string, recipientOverride?: string) {
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const loginRecipient = recipientOverride ?? recipient;
|
const loginRecipient = recipientOverride ?? recipient;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await sendLoginOtp(loginRecipient, channel);
|
||||||
const masked = await sendLoginOtp(loginRecipient, channel);
|
applyOtpSendResponse(response, loginRecipient, channel);
|
||||||
|
|
||||||
setMaskedTarget(masked);
|
|
||||||
|
|
||||||
setOtp('');
|
|
||||||
|
|
||||||
setShowMethods(false);
|
|
||||||
|
|
||||||
setStep('otp');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
setIsSubmitting(false);
|
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 () => {
|
const startQrLogin = useCallback(async () => {
|
||||||
|
|
||||||
setQrLoading(true);
|
setQrLoading(true);
|
||||||
@@ -618,6 +638,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
setQrExpiresAt(null);
|
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}
|
{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)}
|
{renderAlternativeMethods(false)}
|
||||||
|
|
||||||
{totpChallengeToken ? (
|
{totpChallengeToken ? (
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export default function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
const identifier = getIdentifier();
|
const identifier = getIdentifier();
|
||||||
setRecipient(identifier);
|
setRecipient(identifier);
|
||||||
const masked = await sendLoginOtp(identifier);
|
const response = await sendLoginOtp(identifier);
|
||||||
setMaskedTarget(masked);
|
setMaskedTarget(response.maskedTarget);
|
||||||
setOtp('');
|
setOtp('');
|
||||||
setStep('otp');
|
setStep('otp');
|
||||||
showToast('Код отправлен. Введите его ниже.');
|
showToast('Код отправлен. Введите его ниже.');
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ interface AuthContextValue {
|
|||||||
hasStoredSession: boolean;
|
hasStoredSession: boolean;
|
||||||
login: (login: string, password: string) => Promise<AuthTokens>;
|
login: (login: string, password: string) => Promise<AuthTokens>;
|
||||||
identifyLogin: (login: string) => Promise<IdentifyResponse>;
|
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>;
|
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
|
||||||
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
|
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
|
||||||
loginWithLdap: (username: 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 sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => {
|
||||||
const response = await apiFetch<OtpSendResponse>('/auth/otp/send', {
|
return apiFetch<OtpSendResponse>('/auth/otp/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ recipient, channel })
|
body: JSON.stringify({ recipient, channel })
|
||||||
});
|
});
|
||||||
return response.maskedTarget;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const verifyLoginOtp = React.useCallback(
|
const verifyLoginOtp = React.useCallback(
|
||||||
|
|||||||
59
apps/frontend/hooks/use-otp-resend-cooldown.ts
Normal file
59
apps/frontend/hooks/use-otp-resend-cooldown.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,7 @@ export interface OtpSendResponse {
|
|||||||
sent: boolean;
|
sent: boolean;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
maskedTarget: string;
|
maskedTarget: string;
|
||||||
|
resendAvailableAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PinVerificationResponse {
|
export interface PinVerificationResponse {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
|||||||
{ key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
|
{ key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
|
||||||
{ key: 'PIN_MAX_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_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: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' },
|
||||||
{
|
{
|
||||||
key: 'ACCOUNT_DELETE_GRACE_DAYS',
|
key: 'ACCOUNT_DELETE_GRACE_DAYS',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
@@ -214,6 +214,30 @@ export class AuthService {
|
|||||||
async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
|
async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
|
||||||
const existingUser = await this.findUserByRecipient(recipient);
|
const existingUser = await this.findUserByRecipient(recipient);
|
||||||
const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser));
|
const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser));
|
||||||
|
const cooldownSeconds = await this.settings.getNumber('OTP_RESEND_COOLDOWN_SECONDS', 60);
|
||||||
|
const resendKey = this.otpResendKey(target);
|
||||||
|
|
||||||
|
const ttl = await this.redis.client.ttl(resendKey);
|
||||||
|
if (ttl > 0) {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
message: `Запросить новый код можно через ${this.formatOtpCooldown(ttl)}`,
|
||||||
|
code: 'OTP_RESEND_COOLDOWN',
|
||||||
|
resendAvailableAt: new Date(Date.now() + ttl * 1000).toISOString()
|
||||||
|
},
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.authCode.updateMany({
|
||||||
|
where: {
|
||||||
|
target,
|
||||||
|
purpose: 'passwordless-login',
|
||||||
|
usedAt: null
|
||||||
|
},
|
||||||
|
data: { usedAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
const deliveryChannel = target.includes('@') ? 'email' : 'sms';
|
const deliveryChannel = target.includes('@') ? 'email' : 'sms';
|
||||||
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
|
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
|
||||||
const code = String(randomInt(100000, 999999));
|
const code = String(randomInt(100000, 999999));
|
||||||
@@ -238,7 +262,9 @@ export class AuthService {
|
|||||||
await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
|
await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
|
||||||
}
|
}
|
||||||
await this.redis.client.del(this.otpAttemptKey(target));
|
await this.redis.client.del(this.otpAttemptKey(target));
|
||||||
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
|
await this.redis.client.set(resendKey, '1', 'EX', cooldownSeconds);
|
||||||
|
const resendAvailableAt = new Date(Date.now() + cooldownSeconds * 1000).toISOString();
|
||||||
|
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target), resendAvailableAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) {
|
private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) {
|
||||||
@@ -329,6 +355,22 @@ export class AuthService {
|
|||||||
return `otp:attempts:passwordless-login:${target.toLowerCase()}`;
|
return `otp:attempts:passwordless-login:${target.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private otpResendKey(target: string) {
|
||||||
|
return `otp:resend:passwordless-login:${target.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatOtpCooldown(seconds: number) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const rest = seconds % 60;
|
||||||
|
if (minutes > 0 && rest > 0) {
|
||||||
|
return `${minutes}:${String(rest).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes} мин`;
|
||||||
|
}
|
||||||
|
return `${rest} сек`;
|
||||||
|
}
|
||||||
|
|
||||||
private maskTarget(value: string) {
|
private maskTarget(value: string) {
|
||||||
if (value.includes('@')) {
|
if (value.includes('@')) {
|
||||||
const [name, domain] = value.split('@');
|
const [name, domain] = value.split('@');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
|||||||
{ key: 'PIN_MAX_LENGTH', value: '6', description: 'Максимальная длина PIN-кода' },
|
{ key: 'PIN_MAX_LENGTH', value: '6', description: 'Максимальная длина PIN-кода' },
|
||||||
{ key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' },
|
{ key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' },
|
||||||
{ key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' },
|
{ key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' },
|
||||||
|
{ key: 'OTP_RESEND_COOLDOWN_SECONDS', value: '60', description: 'Минимальный интервал между повторными запросами OTP-кода (секунды)' },
|
||||||
{ key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' },
|
{ key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' },
|
||||||
{
|
{
|
||||||
key: 'ACCOUNT_DELETE_GRACE_DAYS',
|
key: 'ACCOUNT_DELETE_GRACE_DAYS',
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function httpToGrpc(code: number): number {
|
|||||||
return GrpcStatus.NOT_FOUND;
|
return GrpcStatus.NOT_FOUND;
|
||||||
case 409:
|
case 409:
|
||||||
return GrpcStatus.ALREADY_EXISTS;
|
return GrpcStatus.ALREADY_EXISTS;
|
||||||
|
case 429:
|
||||||
|
return GrpcStatus.RESOURCE_EXHAUSTED;
|
||||||
default:
|
default:
|
||||||
return GrpcStatus.UNKNOWN;
|
return GrpcStatus.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ message PasswordlessOtpResponse {
|
|||||||
bool sent = 1;
|
bool sent = 1;
|
||||||
string expiresAt = 2;
|
string expiresAt = 2;
|
||||||
string maskedTarget = 3;
|
string maskedTarget = 3;
|
||||||
|
string resendAvailableAt = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PasswordlessVerifyRequest {
|
message PasswordlessVerifyRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user