Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/entities/auth/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,15 @@ export class AuthHttp {
},
});
}
static resendCode(data: TAuth.ResendCodeBody): Promise<TAuth.ResendCodeResponse> {
return api<TAuth.ResendCodeResponse>({
url: '/auth/resend',
method: 'POST',
data: data,
contracts: {
body: SAuth.ResendCodeBody,
response: SAuth.ResendCodeResponse,
},
});
}
}
13 changes: 12 additions & 1 deletion src/entities/auth/model/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod/v4';
import { GlobalSuccess } from 'shared/api';
import { DateTimeString, GlobalSuccess } from 'shared/api';
import {
MAX_NAME_LENGTH,
MAX_PASS_LENGTH,
Expand Down Expand Up @@ -76,3 +76,14 @@ export const ResetPasswordConfirmBody = z.object({
});

export const ResetPasswordConfirmResponse = GlobalSuccess;

export const ResendCodeBody = z.object({
context: z.enum(['sign-up', 'reset-password']),
email: Email,
});

export const ResendCodeResponse = GlobalSuccess.extend({
nextResendAt: DateTimeString,
retryAfterSeconds: z.number(),
retries: z.number(),
});
3 changes: 3 additions & 0 deletions src/entities/auth/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export type ResetPasswordVerifyBody = z.infer<typeof SAuth.ResetPasswordVerifyBo
export type ResetPasswordVerifyResponse = z.infer<typeof SAuth.ResetPasswordVerifyResponse>;
export type ResetPasswordConfirmBody = z.infer<typeof SAuth.ResetPasswordConfirmBody>;
export type ResetPasswordConfirmResponse = z.infer<typeof SAuth.ResetPasswordConfirmResponse>;

export type ResendCodeBody = z.infer<typeof SAuth.ResendCodeBody>;
export type ResendCodeResponse = z.infer<typeof SAuth.ResendCodeResponse>;
2 changes: 1 addition & 1 deletion src/entities/user/model/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const UserTeamResponse = z.object({
permissions: TeamPermissions,
});

export const UserTeamsListResponse = PaginatedResponseSchema(UserTeamResponse);
export const UserTeamsListResponse = UserTeamResponse.array();

export const UserInvitationResponse = z.object({
code: z.string(),
Expand Down
17 changes: 17 additions & 0 deletions src/features/otp-form/model/useResend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
Comment thread
kapitulin24 marked this conversation as resolved.
import { AuthHttp, TAuth } from 'entities/auth';
Comment thread
kapitulin24 marked this conversation as resolved.

export type UseResendOptions = Omit<
UseMutationOptions<TAuth.ResendCodeResponse, DefaultError, TAuth.ResendCodeBody>,
'mutationFn'
>;

export function useResendCode({ ...rest }: UseResendOptions = {}) {
return useMutation<Awaited<TAuth.ResendCodeResponse>, DefaultError, TAuth.ResendCodeBody>({
...rest,
mutationFn: AuthHttp.resendCode,
meta: {
skipGlobalValidationToast: true,
},
});
}
64 changes: 25 additions & 39 deletions src/features/otp-form/ui/ResendCodeControl.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,47 @@
'use client';

import { ComponentProps, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { LocalStorageDraft } from 'shared/lib/classes';
import { ComponentProps } from 'react';
import { useTimer } from 'shared/lib/hooks';
import { classNames, formatTime } from 'shared/lib/utils';
import { Button } from 'shared/ui';
import { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from '../model/const';
import { RESEND_CODE_DELAY_MS } from '../model/const';
import { useResendCode, UseResendOptions } from '../model/useResend';
import { TAuth } from 'entities/auth';
import { toast } from 'sonner';

interface ResendCodeControlProps extends Omit<ComponentProps<'div'>, 'children'> {
resendDelayMs?: number;
storageKey?: string;
storageTtlMs?: number;
data: TAuth.ResendCodeBody;
nextResendAt: string | null;
mutateOptions?: UseResendOptions;
}

interface LastSentCodeDraft extends Record<string, unknown> {
lastSentAt: number;
}

const getTimestampMs = (value?: string | number | Date): number =>
value === undefined ? Date.now() : new Date(value).getTime();

export function ResendCodeControl(props: ResendCodeControlProps) {
const {
className,
resendDelayMs = RESEND_CODE_DELAY_MS,
storageKey = 'last-sent-code',
storageTtlMs = DRAFT_TTL_MS,
...divProps
} = props;
const { className, data, nextResendAt, mutateOptions = {}, ...divProps } = props;

const lastSentCodeDraft = useMemo(
() => new LocalStorageDraft<LastSentCodeDraft>(storageKey),
[storageKey]
);
const initialRemainingMs = nextResendAt
? Math.max(0, new Date(nextResendAt).getTime() - new Date().getTime())
: RESEND_CODE_DELAY_MS;

const { isFinished, remainingMs, restart } = useTimer({
durationMs: resendDelayMs,
autoStart: false,
durationMs: initialRemainingMs,
autoStart: initialRemainingMs > 0,
});

useEffect(() => {
const draft = lastSentCodeDraft.read();
const now = getTimestampMs();
const lastSentAt = draft?.lastSentAt ?? now;

if (!draft) {
lastSentCodeDraft.set({ lastSentAt }, storageTtlMs);
}

restart(resendDelayMs - (now - lastSentAt));
}, [lastSentCodeDraft, resendDelayMs, restart, storageTtlMs]);
const resend = useResendCode({
...mutateOptions,
onSuccess: (data, ...args) => {
mutateOptions.onSuccess?.(data, ...args);
restart(data.retryAfterSeconds * 1000);
toast.success(
data.message || 'Повторный код для восстановления пароля отправлен на вашу почту'
);
},
});

const handleResendCode = () => {
lastSentCodeDraft.set({ lastSentAt: getTimestampMs() }, storageTtlMs);
restart();
toast.warning('Функционал в разработке!');
resend.mutate(data);
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function useTeamsQueryWithTeamIdSync() {
useEffect(() => {
if (!query.data) return;

const items = query.data.items;
const items = query.data;
const hasTeamId = !!teamId && items.some((d) => d.id === teamId);

if (hasTeamId) return;
Expand Down
42 changes: 36 additions & 6 deletions src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { EmailForm } from './EmailForm';
import { PasswordForm } from './PasswordForm';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { DRAFT_TTL_MS, OTPForm, OTPFormLoader, ResendCodeControl } from 'features/otp-form';
import {
DRAFT_TTL_MS,
OTPForm,
OTPFormLoader,
RESEND_CODE_DELAY_MS,
ResendCodeControl,
} from 'features/otp-form';
import { useSendCode } from '../model/useSendCode';
import { useLocalStorageDraft } from 'shared/lib/hooks';

Expand All @@ -15,6 +21,7 @@ type ForgotPasswordStep = 'email' | 'password' | 'otp' | null;
interface ForgotPasswordDraft extends Record<string, unknown> {
email: string;
step: ForgotPasswordStep;
nextResendAt: string | null;
}

const DRAFT_KEY = 'drafted-forgot-password';
Expand All @@ -23,12 +30,12 @@ function ForgotPasswordPage() {
const router = useRouter();
const sendCode = useSendCode();
const { draft, setDraft, clearDraft } = useLocalStorageDraft<ForgotPasswordDraft>(DRAFT_KEY, {
defaultValues: { email: '', step: 'email' },
defaultValues: { email: '', step: 'email', nextResendAt: null },
});

const email = draft?.email ?? '';
const step: ForgotPasswordStep = draft?.step ?? null;
const resendCodeStorageKey = `${DRAFT_KEY}:last-sent-code:${email}`;
const nextResendAt = draft?.nextResendAt ?? null;

if (!step) {
return (
Expand All @@ -49,7 +56,15 @@ function ForgotPasswordPage() {
{step === 'email' ? (
<EmailForm
mutateOptions={{
onSuccess: (_res, { email }) => setDraft({ email, step: 'otp' }, DRAFT_TTL_MS),
onSuccess: (_res, { email }) =>
setDraft(
{
email,
step: 'otp',
nextResendAt: new Date(Date.now() + RESEND_CODE_DELAY_MS).toISOString(),
},
DRAFT_TTL_MS
),
}}
/>
) : null}
Expand All @@ -58,10 +73,25 @@ function ForgotPasswordPage() {
email={email}
mutation={sendCode}
mutateOptions={{
onSuccess: () => setDraft({ email, step: 'password' }),
onSuccess: () =>
setDraft({
email,
step: 'password',
nextResendAt,
}),
}}
>
<ResendCodeControl storageKey={resendCodeStorageKey} />
<ResendCodeControl
mutateOptions={{
onSuccess: (data) =>
setDraft({ email, step, nextResendAt: data.nextResendAt }, DRAFT_TTL_MS),
onError: () => {
setDraft({ step: 'email', email, nextResendAt: null });
},
}}
nextResendAt={nextResendAt}
data={{ email, context: 'reset-password' }}
/>
<OTPFormLoader status={sendCode.status} />
</OTPForm>
)}
Expand Down
36 changes: 31 additions & 5 deletions src/pages/auth/signup/ui/SignupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

import { FieldDescription, Link, Logo, Spinner } from 'shared/ui';
import { SignupForm } from './SignupForm';
import { DRAFT_TTL_MS, OTPForm, OTPFormLoader, ResendCodeControl } from 'features/otp-form';
import {
DRAFT_TTL_MS,
OTPForm,
OTPFormLoader,
RESEND_CODE_DELAY_MS,
ResendCodeControl,
} from 'features/otp-form';
import { useRouter } from 'next/navigation';
import { AccessToken } from 'shared/api';
import { routes } from 'shared/config';
Expand All @@ -15,6 +21,7 @@ type SignupStep = 'signup' | 'otp' | null;
interface SignupDraft extends Record<string, unknown> {
email: string;
step: SignupStep;
nextResendAt: string | null;
}

const DRAFT_KEY = 'drafted-signup';
Expand All @@ -23,12 +30,12 @@ function SignupPage() {
const router = useRouter();
const sendConfirm = useSignupConfirm();
const { draft, setDraft, resetDraft, clearDraft } = useLocalStorageDraft<SignupDraft>(DRAFT_KEY, {
defaultValues: { email: '', step: 'signup' },
defaultValues: { email: '', step: 'signup', nextResendAt: null },
});

const email = draft?.email ?? '';
const step: SignupStep = draft?.step ?? null;
const resendCodeStorageKey = `${DRAFT_KEY}:last-sent-code:${email}`;
const nextResendAt = draft?.nextResendAt ?? null;

if (!step) {
return (
Expand All @@ -50,7 +57,15 @@ function SignupPage() {
{step === 'signup' ? (
<SignupForm
mutateOptions={{
onSuccess: (_res, { email }) => setDraft({ email, step: 'otp' }, DRAFT_TTL_MS),
onSuccess: (_res, { email }) =>
setDraft(
{
email,
step: 'otp',
nextResendAt: new Date(Date.now() + RESEND_CODE_DELAY_MS).toISOString(),
},
DRAFT_TTL_MS
),
}}
/>
) : null}
Expand All @@ -71,7 +86,18 @@ function SignupPage() {
},
}}
>
<ResendCodeControl storageKey={resendCodeStorageKey} />
<ResendCodeControl
mutateOptions={{
onSuccess: (data) => {
setDraft({ email, step, nextResendAt: data.nextResendAt }, DRAFT_TTL_MS);
},
onError: () => {
setDraft({ step: 'signup', email, nextResendAt: null });
},
}}
nextResendAt={nextResendAt}
data={{ email, context: 'reset-password' }}
/>
<OTPFormLoader status={sendConfirm.status} />
</OTPForm>
) : null}
Expand Down
4 changes: 2 additions & 2 deletions src/pages/teams/ui/TeamsPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { TeamCard } from './TeamCard';

export function TeamsPageContent() {
const teamsQuery = useSuspenseQuery(UserQueries.getMyTeams());
const teams = teamsQuery.data.items;
const teamsCount = teamsQuery.data.meta.total ?? teams.length;
const teams = teamsQuery.data;
const teamsCount = teams.length;

const { switchTeam } = useSwitchTeam({
teams,
Expand Down
2 changes: 1 addition & 1 deletion src/widgets/app-sidebar/model/useTeamsDropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function useTeamsDropdown() {
const [open, setOpen] = useState(false);

const query = useQuery(UserQueries.getMyTeams());
const teams = useMemo(() => query.data?.items ?? [], [query.data]);
const teams = useMemo(() => query.data ?? [], [query.data]);

const { switchTeam } = useSwitchTeam({ teams });

Expand Down
2 changes: 1 addition & 1 deletion src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function TeamTrigger({ query }: TeamTriggerProps) {

const activeTeam = useMemo(() => {
if (query.data) {
return query.data.items.find((d) => d.id === teamId);
return query.data.find((d) => d.id === teamId);
}
}, [teamId, query.data]);

Expand Down
Loading