From 1a2dbb8aba53469c53f3791fb28a7ac9f59cac3c Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Wed, 10 Jun 2026 18:09:37 +0300 Subject: [PATCH 1/7] fix(team): fixed validation schemas --- src/entities/user/model/schemas.ts | 2 +- .../model/useTeamsQueryWithTeamIdSync.ts | 2 +- src/pages/profile/ui/teams-page/TeamList.tsx | 80 +++++++++++++++++++ src/pages/teams/ui/TeamsPageContent.tsx | 4 +- .../app-sidebar/model/useTeamsDropdown.ts | 2 +- .../app-sidebar/ui/teams/TeamTrigger.tsx | 2 +- 6 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 src/pages/profile/ui/teams-page/TeamList.tsx diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 100fef2..82a91b5 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -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(), diff --git a/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts b/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts index 4a850b1..1c59909 100644 --- a/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts +++ b/src/features/teams/active-team/model/useTeamsQueryWithTeamIdSync.ts @@ -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; diff --git a/src/pages/profile/ui/teams-page/TeamList.tsx b/src/pages/profile/ui/teams-page/TeamList.tsx new file mode 100644 index 0000000..fbf5b00 --- /dev/null +++ b/src/pages/profile/ui/teams-page/TeamList.tsx @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query'; +import { TeamAvatar, useTeamStore } from 'entities/team'; +import { UserQueries } from 'entities/user'; +import { RemoveTeamDialog } from 'features/teams/remove'; +import { Trash2Icon } from 'lucide-react'; +import { + Badge, + Button, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from 'shared/ui'; +import { useSwitchTeam } from 'features/teams/active-team'; + +export function TeamsList() { + const teamsQuery = useQuery(UserQueries.getMyTeams()); + const teamId = useTeamStore.use.teamId(); + + const { switchTeam } = useSwitchTeam({ + teams: teamsQuery.data, + defaultOptions: { redirect: true }, + }); + + if (teamsQuery.isError) { + return ( +

+ {teamsQuery.error.message} +

+ ); + } + + const teams = teamsQuery.data ?? []; + + return ( + + ); +} diff --git a/src/pages/teams/ui/TeamsPageContent.tsx b/src/pages/teams/ui/TeamsPageContent.tsx index 5e229ff..62c75b0 100644 --- a/src/pages/teams/ui/TeamsPageContent.tsx +++ b/src/pages/teams/ui/TeamsPageContent.tsx @@ -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, diff --git a/src/widgets/app-sidebar/model/useTeamsDropdown.ts b/src/widgets/app-sidebar/model/useTeamsDropdown.ts index 8563583..956ffab 100644 --- a/src/widgets/app-sidebar/model/useTeamsDropdown.ts +++ b/src/widgets/app-sidebar/model/useTeamsDropdown.ts @@ -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 }); diff --git a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx index c2a71c7..abb90a8 100644 --- a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx +++ b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx @@ -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]); From df11fe1c88333c321e7e13a7521481928db1f1b6 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 11 Jun 2026 18:33:51 +0300 Subject: [PATCH 2/7] feat(auth): add OAuth schemas, quries, http and types --- src/entities/auth/api/http.ts | 36 ++++++++++++++++++++++++++++++ src/entities/auth/api/queries.ts | 12 ++++++++++ src/entities/auth/index.ts | 1 + src/entities/auth/model/schemas.ts | 25 +++++++++++++++++++++ src/entities/auth/model/types.ts | 6 +++++ src/shared/config/routes.ts | 1 + 6 files changed, 81 insertions(+) create mode 100644 src/entities/auth/api/queries.ts diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index cbf2c97..a6b6679 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -86,4 +86,40 @@ export class AuthHttp { }, }); } + static oAuthProviders() { + return api({ + url: '/auth/oauth/providers', + method: 'GET', + contracts: { + response: SAuth.OAuthProvidersResponse, + }, + }); + } + static connectedOAuthProviders() { + return api({ + url: '/auth/oauth/providers/connected', + method: 'GET', + contracts: { + response: SAuth.ConnectedOAuthProvidersResponse, + }, + }); + } + static connecteOAuthProvder(provider: TAuth.OAuthProvider) { + return api({ + url: `/auth/oauth/${provider}/connect`, + method: 'POST', + contracts: { + response: SAuth.ConnectOAuthProviderResponse, + }, + }); + } + static removeOAuthProvder(provider: TAuth.OAuthProvider) { + return api({ + url: `/auth/oauth/${provider}/connect`, + method: 'DELETE', + contracts: { + response: SAuth.RemoveOAuthProviderResponse, + }, + }); + } } diff --git a/src/entities/auth/api/queries.ts b/src/entities/auth/api/queries.ts new file mode 100644 index 0000000..be48f4e --- /dev/null +++ b/src/entities/auth/api/queries.ts @@ -0,0 +1,12 @@ +import { queryOptions } from '@tanstack/react-query'; +import { AuthHttp } from './http'; + +export class AuthQueries { + static getOAuthProviders() { + return queryOptions({ + queryKey: ['auth'], + queryFn: async () => AuthHttp.oAuthProviders(), + staleTime: 60_000, + }); + } +} diff --git a/src/entities/auth/index.ts b/src/entities/auth/index.ts index e75adc2..5287e31 100644 --- a/src/entities/auth/index.ts +++ b/src/entities/auth/index.ts @@ -2,3 +2,4 @@ export * as SAuth from './model/schemas'; export type * as TAuth from './model/types'; export * as CAuth from './model/const'; export { AuthHttp } from './api/http'; +export { AuthQueries } from './api/queries'; diff --git a/src/entities/auth/model/schemas.ts b/src/entities/auth/model/schemas.ts index 2b0dee9..c222916 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -76,3 +76,28 @@ export const ResetPasswordConfirmBody = z.object({ }); export const ResetPasswordConfirmResponse = GlobalSuccess; + +export const OAuthProvider = z.enum(['google', 'github', 'yandex', 'vkontakte']); + +export const OAuthProvidersResponse = z + .object({ + label: z.string(), + value: OAuthProvider, + }) + .array(); + +export const ConnectedOAuthProvidersResponse = z + .object({ + email: Email, + avatarUrl: z.string().nullable(), + provider: z.string(), + connectedAt: z.string(), + }) + .array(); + +export const ConnectOAuthProviderResponse = z.object({ + success: z.boolean(), + url: z.string(), +}); + +export const RemoveOAuthProviderResponse = GlobalSuccess; diff --git a/src/entities/auth/model/types.ts b/src/entities/auth/model/types.ts index c83f166..196869a 100644 --- a/src/entities/auth/model/types.ts +++ b/src/entities/auth/model/types.ts @@ -4,6 +4,7 @@ import * as SAuth from './schemas'; export type Email = z.infer; export type Password = z.infer; export type OTPCode = z.infer; +export type OAuthProvider = z.infer; export type SigninBody = z.infer; export type SigninResponse = z.infer; @@ -20,3 +21,8 @@ export type ResetPasswordVerifyBody = z.infer; export type ResetPasswordConfirmBody = z.infer; export type ResetPasswordConfirmResponse = z.infer; + +export type OAuthProvidersResponse = z.infer; +export type ConnectedOAuthProvidersResponse = z.infer; +export type ConnectOAuthProviderResponse = z.infer; +export type RemoveOAuthProviderResponse = z.infer; diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 1cee79d..36081e4 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -25,5 +25,6 @@ export const routes = { signin: (): Route => '/signin', signup: (): Route => '/signup', forgotPassword: (): Route => '/forgot-password', + oauth: () => '/oauth' as Route, }, } as const; From cbd9d77a7a9caddba01fd3fd657d4bc6ccf8ba71 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 11 Jun 2026 18:48:17 +0300 Subject: [PATCH 3/7] feat(oauth): add oauth feature --- src/entities/auth/api/queries.ts | 2 +- src/features/auth/oauth-login/index.ts | 3 + src/features/auth/oauth-login/model/consts.ts | 44 ++++++++++++++ .../auth/oauth-login/ui/OAuthIcons.tsx | 59 +++++++++++++++++++ .../auth/oauth-login/ui/OAuthLoginButtons.tsx | 37 ++++++++++++ .../auth/oauth-login/ui/OAuthSeparator.tsx | 11 ++++ src/pages/auth/oauth/index.ts | 1 + 7 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/features/auth/oauth-login/index.ts create mode 100644 src/features/auth/oauth-login/model/consts.ts create mode 100644 src/features/auth/oauth-login/ui/OAuthIcons.tsx create mode 100644 src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx create mode 100644 src/features/auth/oauth-login/ui/OAuthSeparator.tsx create mode 100644 src/pages/auth/oauth/index.ts diff --git a/src/entities/auth/api/queries.ts b/src/entities/auth/api/queries.ts index be48f4e..ba8ebee 100644 --- a/src/entities/auth/api/queries.ts +++ b/src/entities/auth/api/queries.ts @@ -4,7 +4,7 @@ import { AuthHttp } from './http'; export class AuthQueries { static getOAuthProviders() { return queryOptions({ - queryKey: ['auth'], + queryKey: ['oauth-providers'], queryFn: async () => AuthHttp.oAuthProviders(), staleTime: 60_000, }); diff --git a/src/features/auth/oauth-login/index.ts b/src/features/auth/oauth-login/index.ts new file mode 100644 index 0000000..e413910 --- /dev/null +++ b/src/features/auth/oauth-login/index.ts @@ -0,0 +1,3 @@ +export { OAuthLoginButtons } from './ui/OAuthLoginButtons'; +export { OAuthSeparator } from './ui/OAuthSeparator'; +export { type StartOauthParams } from './model/consts'; diff --git a/src/features/auth/oauth-login/model/consts.ts b/src/features/auth/oauth-login/model/consts.ts new file mode 100644 index 0000000..091ac95 --- /dev/null +++ b/src/features/auth/oauth-login/model/consts.ts @@ -0,0 +1,44 @@ +import { type TAuth } from 'entities/auth'; +import { ComponentType, SVGProps } from 'react'; +import { GithubIcon, GoogleIcon, VkontakteIcon, YandexIcon } from '../ui/OAuthIcons'; +import { routes } from 'shared/config'; + +export type StartOauthParams = { + provider: TAuth.OAuthProvider; + startOAuth: 'true' | 'false'; +}; +const getRoute = (provider: TAuth.OAuthProvider) => { + const params = new URLSearchParams({ + provider, + startOAuth: 'true', + } satisfies Record); + return `${routes.auth.oauth()}?${params.toString()}`; +}; + +export const OAUTH_PROVIDERS: Record< + TAuth.OAuthProvider, + { + name: TAuth.OAuthProvider; + label: string; + icon: ComponentType>; + href: string; + color?: string; + } +> = { + yandex: { label: 'Яндекс', name: 'yandex', icon: YandexIcon, href: getRoute('yandex') }, + vkontakte: { + label: 'Вконтакте', + name: 'vkontakte', + icon: VkontakteIcon, + href: getRoute('vkontakte'), + color: '#07f', + }, + google: { label: 'Google', name: 'google', icon: GoogleIcon, href: getRoute('google') }, + github: { + label: 'GitHub', + name: 'github', + icon: GithubIcon, + href: getRoute('github'), + color: '#24292f', + }, +}; diff --git a/src/features/auth/oauth-login/ui/OAuthIcons.tsx b/src/features/auth/oauth-login/ui/OAuthIcons.tsx new file mode 100644 index 0000000..213049a --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthIcons.tsx @@ -0,0 +1,59 @@ +type SVGProps = { className?: string }; + +export function GoogleIcon({ className }: SVGProps) { + return ( + + + + + + + + ); +} + +export function YandexIcon({ className }: SVGProps) { + return ( + + + + ); +} +export function GithubIcon({ className }: SVGProps) { + return ( + + + + ); +} +export function VkontakteIcon({ className }: SVGProps) { + return ( + + + + ); +} diff --git a/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx b/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx new file mode 100644 index 0000000..a03fa62 --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx @@ -0,0 +1,37 @@ +'use client'; +import { Button, Skeleton } from 'shared/ui'; +import { OAUTH_PROVIDERS } from '../model/consts'; +import { cn } from 'shared/lib/utils'; +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { AuthQueries } from 'entities/auth'; +import { type Route } from 'next'; + +export function OAuthLoginButtons({ className }: { className?: string }) { + const { data, isLoading } = useQuery(AuthQueries.getOAuthProviders()); + + return ( +
+ {isLoading && + [...new Array(3)].map((_v, i) => { + return ; + })} + {data?.map((item) => { + const data = OAUTH_PROVIDERS[item.value]; + return ( + + ); + })} +
+ ); +} diff --git a/src/features/auth/oauth-login/ui/OAuthSeparator.tsx b/src/features/auth/oauth-login/ui/OAuthSeparator.tsx new file mode 100644 index 0000000..ad935c7 --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthSeparator.tsx @@ -0,0 +1,11 @@ +import { classNames } from 'shared/lib/utils'; + +export function OAuthSeparator({ className }: { className?: string }) { + return ( +
+
+ или +
+
+ ); +} diff --git a/src/pages/auth/oauth/index.ts b/src/pages/auth/oauth/index.ts new file mode 100644 index 0000000..daa0dd4 --- /dev/null +++ b/src/pages/auth/oauth/index.ts @@ -0,0 +1 @@ +export { OAuthPage } from './ui/OAuthPage'; From ec5137d4849da877dc5d8247b2099b38c2f51a51 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 11 Jun 2026 18:48:42 +0300 Subject: [PATCH 4/7] feat(oauth): implement OAuth login functionality and update routing --- app/(auth)/oauth/page.tsx | 1 + src/pages/auth/oauth/ui/OAuthPage.tsx | 41 +++++++++++++++++++++++++ src/pages/auth/signin/ui/SigninForm.tsx | 4 +++ src/shared/config/index.ts | 3 +- 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 app/(auth)/oauth/page.tsx create mode 100644 src/pages/auth/oauth/ui/OAuthPage.tsx diff --git a/app/(auth)/oauth/page.tsx b/app/(auth)/oauth/page.tsx new file mode 100644 index 0000000..3fad911 --- /dev/null +++ b/app/(auth)/oauth/page.tsx @@ -0,0 +1 @@ +export { OAuthPage as default } from 'pages/auth/oauth'; diff --git a/src/pages/auth/oauth/ui/OAuthPage.tsx b/src/pages/auth/oauth/ui/OAuthPage.tsx new file mode 100644 index 0000000..960533b --- /dev/null +++ b/src/pages/auth/oauth/ui/OAuthPage.tsx @@ -0,0 +1,41 @@ +import { type TAuth } from 'entities/auth'; +import { StartOauthParams } from 'features/auth/oauth-login'; +import { Route } from 'next'; +import { redirect } from 'next/navigation'; +import { routes } from 'shared/config'; +import { env } from 'shared/config'; + +type BooleanRaw = 'false' | 'true'; +type OAuthParams = { + success: BooleanRaw; + message: string; + access: string; + provider: TAuth.OAuthProvider; + isNewUser: BooleanRaw; +} & StartOauthParams; + +interface Props { + searchParams: Promise>; +} + +export async function OAuthPage({ searchParams }: Props) { + const { success, message, provider, startOAuth } = await searchParams; + + if (provider && startOAuth === 'true') { + redirect(`${env.NEXT_PUBLIC_API_BASE_URL}/auth/oauth/${provider}` as Route); + } + + if (!success) { + redirect(routes.auth.signin()); + } + + if (success === 'true') { + redirect(routes.user.profile()); + } + + const errorUrl = message + ? `${routes.auth.signin()}?oauth_error=1&message=${encodeURIComponent(message)}` + : routes.auth.signin(); + + redirect(errorUrl as Route); +} diff --git a/src/pages/auth/signin/ui/SigninForm.tsx b/src/pages/auth/signin/ui/SigninForm.tsx index a004d37..afb7be9 100644 --- a/src/pages/auth/signin/ui/SigninForm.tsx +++ b/src/pages/auth/signin/ui/SigninForm.tsx @@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, Field, @@ -26,6 +27,7 @@ import { extractValidationIssues } from 'shared/api'; import { TAuth } from 'entities/auth'; import { ComponentProps } from 'react'; import { useSignin, UseSigninOptions } from '../model/useSignin'; +import { OAuthLoginButtons, OAuthSeparator } from 'features/auth/oauth-login'; interface SigninFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseSigninOptions; @@ -119,6 +121,8 @@ export function SigninForm({ className, mutateOptions = {}, ...props }: SigninFo + + ); diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index cc2e625..4625352 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -1,2 +1,3 @@ -export { routes } from './routes'; +export { env } from './env.client'; export { default as FrontendObservability } from './metrics/FrontendObservability'; +export { routes } from './routes'; From 957d1617fe0c220c12c23ef82f605a2d865f0e4c Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 11 Jun 2026 21:27:02 +0300 Subject: [PATCH 5/7] refactor(oauth): refactor OAuth types and improve login button rendering --- src/features/auth/oauth-login/index.ts | 2 +- src/features/auth/oauth-login/model/consts.ts | 31 ++++++++----------- src/features/auth/oauth-login/model/types.ts | 6 ++++ .../auth/oauth-login/ui/OAuthIcons.tsx | 13 +++----- .../auth/oauth-login/ui/OAuthLoginButtons.tsx | 20 ++++++------ .../auth/oauth-login/ui/OAuthSeparator.tsx | 17 ++++++---- src/pages/auth/signin/ui/SigninPage.tsx | 1 - 7 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 src/features/auth/oauth-login/model/types.ts diff --git a/src/features/auth/oauth-login/index.ts b/src/features/auth/oauth-login/index.ts index e413910..efc440b 100644 --- a/src/features/auth/oauth-login/index.ts +++ b/src/features/auth/oauth-login/index.ts @@ -1,3 +1,3 @@ export { OAuthLoginButtons } from './ui/OAuthLoginButtons'; export { OAuthSeparator } from './ui/OAuthSeparator'; -export { type StartOauthParams } from './model/consts'; +export { type StartOauthParams } from './model/types'; diff --git a/src/features/auth/oauth-login/model/consts.ts b/src/features/auth/oauth-login/model/consts.ts index 091ac95..d611103 100644 --- a/src/features/auth/oauth-login/model/consts.ts +++ b/src/features/auth/oauth-login/model/consts.ts @@ -2,43 +2,38 @@ import { type TAuth } from 'entities/auth'; import { ComponentType, SVGProps } from 'react'; import { GithubIcon, GoogleIcon, VkontakteIcon, YandexIcon } from '../ui/OAuthIcons'; import { routes } from 'shared/config'; +import { StartOauthParams } from './types'; -export type StartOauthParams = { - provider: TAuth.OAuthProvider; - startOAuth: 'true' | 'false'; -}; const getRoute = (provider: TAuth.OAuthProvider) => { const params = new URLSearchParams({ provider, startOAuth: 'true', } satisfies Record); + return `${routes.auth.oauth()}?${params.toString()}`; }; -export const OAUTH_PROVIDERS: Record< - TAuth.OAuthProvider, - { - name: TAuth.OAuthProvider; - label: string; - icon: ComponentType>; - href: string; - color?: string; - } -> = { - yandex: { label: 'Яндекс', name: 'yandex', icon: YandexIcon, href: getRoute('yandex') }, +export type OAuthProviderConfig = { + label: string; + icon: ComponentType>; + href: string; + color?: string; +}; + +export const OAUTH_PROVIDERS: Record = { + yandex: { label: 'Яндекс', icon: YandexIcon, href: getRoute('yandex') }, vkontakte: { label: 'Вконтакте', - name: 'vkontakte', icon: VkontakteIcon, href: getRoute('vkontakte'), color: '#07f', }, - google: { label: 'Google', name: 'google', icon: GoogleIcon, href: getRoute('google') }, + google: { label: 'Google', icon: GoogleIcon, href: getRoute('google') }, github: { label: 'GitHub', - name: 'github', icon: GithubIcon, href: getRoute('github'), color: '#24292f', }, }; +export const OAUTH_PROVIDERS_COUNT = Object.keys(OAUTH_PROVIDERS).length; diff --git a/src/features/auth/oauth-login/model/types.ts b/src/features/auth/oauth-login/model/types.ts new file mode 100644 index 0000000..da8f909 --- /dev/null +++ b/src/features/auth/oauth-login/model/types.ts @@ -0,0 +1,6 @@ +import { TAuth } from 'entities/auth'; + +export type StartOauthParams = { + provider: TAuth.OAuthProvider; + startOAuth: 'true' | 'false'; +}; diff --git a/src/features/auth/oauth-login/ui/OAuthIcons.tsx b/src/features/auth/oauth-login/ui/OAuthIcons.tsx index 213049a..9332584 100644 --- a/src/features/auth/oauth-login/ui/OAuthIcons.tsx +++ b/src/features/auth/oauth-login/ui/OAuthIcons.tsx @@ -1,6 +1,6 @@ -type SVGProps = { className?: string }; +type IconProps = { className?: string }; -export function GoogleIcon({ className }: SVGProps) { +export function GoogleIcon({ className }: IconProps) { return ( ); } -export function GithubIcon({ className }: SVGProps) { +export function GithubIcon({ className }: IconProps) { return ( ); } -export function VkontakteIcon({ className }: SVGProps) { +export function VkontakteIcon({ className }: IconProps) { return ( diff --git a/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx b/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx index a03fa62..9de4cd7 100644 --- a/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx +++ b/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx @@ -1,6 +1,6 @@ 'use client'; import { Button, Skeleton } from 'shared/ui'; -import { OAUTH_PROVIDERS } from '../model/consts'; +import { OAUTH_PROVIDERS, OAUTH_PROVIDERS_COUNT } from '../model/consts'; import { cn } from 'shared/lib/utils'; import Link from 'next/link'; import { useQuery } from '@tanstack/react-query'; @@ -13,21 +13,23 @@ export function OAuthLoginButtons({ className }: { className?: string }) { return (
{isLoading && - [...new Array(3)].map((_v, i) => { - return ; - })} + Array.from({ length: OAUTH_PROVIDERS_COUNT }, (_v, i) => ( + + ))} + {data?.map((item) => { - const data = OAUTH_PROVIDERS[item.value]; + const providerConfig = OAUTH_PROVIDERS[item.value]; + return ( ); diff --git a/src/features/auth/oauth-login/ui/OAuthSeparator.tsx b/src/features/auth/oauth-login/ui/OAuthSeparator.tsx index ad935c7..7522746 100644 --- a/src/features/auth/oauth-login/ui/OAuthSeparator.tsx +++ b/src/features/auth/oauth-login/ui/OAuthSeparator.tsx @@ -1,11 +1,16 @@ -import { classNames } from 'shared/lib/utils'; +import { cn } from 'shared/lib/utils'; -export function OAuthSeparator({ className }: { className?: string }) { +interface OAuthSeparatorProps { + className?: string; + label?: string; +} + +export function OAuthSeparator({ className, label = 'или' }: OAuthSeparatorProps) { return ( -
-
- или -
+
+ + {label} +
); } diff --git a/src/pages/auth/signin/ui/SigninPage.tsx b/src/pages/auth/signin/ui/SigninPage.tsx index cfe7c5c..d3b2222 100644 --- a/src/pages/auth/signin/ui/SigninPage.tsx +++ b/src/pages/auth/signin/ui/SigninPage.tsx @@ -2,7 +2,6 @@ import { SigninForm } from './SigninForm'; import { Link, Logo } from 'shared/ui'; -import * as React from 'react'; import { routes } from 'shared/config'; import { AccessToken } from 'shared/api'; import { toast } from 'sonner'; From 3f0e77ed3ee0384c655381c23e661f3d8613da98 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 11 Jun 2026 21:57:21 +0300 Subject: [PATCH 6/7] feat(auth): add AuthRedirectHandler and useAuthRedirectMessage for OAuth error handling --- .../signin/model/useAuthRedirectMessage.ts | 20 +++++++++ .../auth/signin/ui/AuthRedirectHandler.tsx | 7 +++ src/pages/auth/signin/ui/SigninPage.tsx | 44 +++++++++++-------- 3 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 src/pages/auth/signin/model/useAuthRedirectMessage.ts create mode 100644 src/pages/auth/signin/ui/AuthRedirectHandler.tsx diff --git a/src/pages/auth/signin/model/useAuthRedirectMessage.ts b/src/pages/auth/signin/model/useAuthRedirectMessage.ts new file mode 100644 index 0000000..e5fffcf --- /dev/null +++ b/src/pages/auth/signin/model/useAuthRedirectMessage.ts @@ -0,0 +1,20 @@ +'use client'; +import { useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; +import { routes } from 'shared/config'; +import { toast } from 'sonner'; + +export function useAuthRedirectMessage() { + const params = useSearchParams(); + + useEffect(() => { + const error = params?.get('oauth_error'); + const message = params?.get('message'); + + if (!error) return; + console.log(error); + + toast.error(message ?? 'Authorization failed'); + window.history.replaceState(null, '', routes.auth.signin()); + }, [params]); +} diff --git a/src/pages/auth/signin/ui/AuthRedirectHandler.tsx b/src/pages/auth/signin/ui/AuthRedirectHandler.tsx new file mode 100644 index 0000000..2b5a8ee --- /dev/null +++ b/src/pages/auth/signin/ui/AuthRedirectHandler.tsx @@ -0,0 +1,7 @@ +'use client'; +import { useAuthRedirectMessage } from '../model/useAuthRedirectMessage'; + +export function AuthRedirectHandler() { + useAuthRedirectMessage(); + return null; +} diff --git a/src/pages/auth/signin/ui/SigninPage.tsx b/src/pages/auth/signin/ui/SigninPage.tsx index d3b2222..70cbad5 100644 --- a/src/pages/auth/signin/ui/SigninPage.tsx +++ b/src/pages/auth/signin/ui/SigninPage.tsx @@ -6,31 +6,37 @@ import { routes } from 'shared/config'; import { AccessToken } from 'shared/api'; import { toast } from 'sonner'; import { useRouter } from 'next/navigation'; +import { Suspense } from 'react'; +import { AuthRedirectHandler } from './AuthRedirectHandler'; function SigninPage() { const router = useRouter(); - return ( -
-
- - - - { - if (res.success) { - AccessToken.token = res.token; - router.replace(routes.user.root()); - if (res.message) { - toast.success(res.message); + <> + + + +
+
+ + + + { + if (res.success) { + AccessToken.token = res.token; + router.replace(routes.user.root()); + if (res.message) { + toast.success(res.message); + } } - } - }, - }} - /> + }, + }} + /> +
-
+ ); } From 6693a217bc8d10696f3baf9ef5da0d6a5749890a Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 11 Jun 2026 22:43:14 +0300 Subject: [PATCH 7/7] fixup! Merge branch 'dev' into feature/oauth --- src/entities/auth/api/http.ts | 3 ++- src/entities/auth/api/queries.ts | 4 ++-- src/pages/auth/signin/model/useAuthRedirectMessage.ts | 9 ++++----- src/pages/auth/signin/ui/SigninForm.tsx | 1 - src/pages/auth/signup/ui/SignupForm.tsx | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index c013326..3b5a245 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -86,13 +86,14 @@ export class AuthHttp { }, }); } - static oAuthProviders() { + static oAuthProviders(signal: AbortSignal) { return api({ url: '/auth/oauth/providers', method: 'GET', contracts: { response: SAuth.OAuthProvidersResponse, }, + signal, }); } static connectedOAuthProviders() { diff --git a/src/entities/auth/api/queries.ts b/src/entities/auth/api/queries.ts index ba8ebee..f1c3a2c 100644 --- a/src/entities/auth/api/queries.ts +++ b/src/entities/auth/api/queries.ts @@ -5,8 +5,8 @@ export class AuthQueries { static getOAuthProviders() { return queryOptions({ queryKey: ['oauth-providers'], - queryFn: async () => AuthHttp.oAuthProviders(), - staleTime: 60_000, + queryFn: async ({ signal }) => AuthHttp.oAuthProviders(signal), + staleTime: 60_000 * 360 * 24, }); } } diff --git a/src/pages/auth/signin/model/useAuthRedirectMessage.ts b/src/pages/auth/signin/model/useAuthRedirectMessage.ts index e5fffcf..8d64438 100644 --- a/src/pages/auth/signin/model/useAuthRedirectMessage.ts +++ b/src/pages/auth/signin/model/useAuthRedirectMessage.ts @@ -1,20 +1,19 @@ 'use client'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect } from 'react'; import { routes } from 'shared/config'; import { toast } from 'sonner'; export function useAuthRedirectMessage() { const params = useSearchParams(); + const router = useRouter(); useEffect(() => { const error = params?.get('oauth_error'); const message = params?.get('message'); if (!error) return; - console.log(error); - toast.error(message ?? 'Authorization failed'); - window.history.replaceState(null, '', routes.auth.signin()); - }, [params]); + router.replace(routes.auth.signin()); + }, [params, router]); } diff --git a/src/pages/auth/signin/ui/SigninForm.tsx b/src/pages/auth/signin/ui/SigninForm.tsx index afb7be9..0fa94d5 100644 --- a/src/pages/auth/signin/ui/SigninForm.tsx +++ b/src/pages/auth/signin/ui/SigninForm.tsx @@ -9,7 +9,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, Field, diff --git a/src/pages/auth/signup/ui/SignupForm.tsx b/src/pages/auth/signup/ui/SignupForm.tsx index 01602db..7d7b61b 100644 --- a/src/pages/auth/signup/ui/SignupForm.tsx +++ b/src/pages/auth/signup/ui/SignupForm.tsx @@ -29,7 +29,7 @@ import { fieldNameMapper } from '../model/utils/field-name-mapper'; import { prepareFullName } from '../model/utils/prepare-fullname'; import { extractValidationIssues } from 'shared/api'; import { TAuth } from 'entities/auth'; -import { useSignup, UseSignupOptions } from '../model/useSignup'; +import { useSignup, type UseSignupOptions } from '../model/useSignup'; interface SignupFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseSignupOptions;