diff --git a/public/github-logo.svg b/public/github-logo.svg new file mode 100644 index 0000000..d4c9ed9 --- /dev/null +++ b/public/github-logo.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/public/google-logo.svg b/public/google-logo.svg new file mode 100644 index 0000000..7a6dba8 --- /dev/null +++ b/public/google-logo.svg @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/public/vkontakte-logo.svg b/public/vkontakte-logo.svg new file mode 100644 index 0000000..4f105a1 --- /dev/null +++ b/public/vkontakte-logo.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/public/yandex-logo.svg b/public/yandex-logo.svg new file mode 100644 index 0000000..27e8a20 --- /dev/null +++ b/public/yandex-logo.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx index 76e167b..ed3d1e5 100644 --- a/src/app/providers/AppProviders.tsx +++ b/src/app/providers/AppProviders.tsx @@ -2,7 +2,6 @@ import { PropsWithChildren } from 'react'; import { QueryProvider } from './QueryProvider'; import { Toaster, TooltipProvider } from 'shared/ui'; import { FrontendObservability } from 'shared/config/'; - export function AppProviders({ children }: PropsWithChildren) { return ( <> diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index 3b5a245..959f5a1 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -96,16 +96,17 @@ export class AuthHttp { signal, }); } - static connectedOAuthProviders() { + static connectedOAuthProviders(signal: AbortSignal) { return api({ url: '/auth/oauth/providers/connected', method: 'GET', contracts: { response: SAuth.ConnectedOAuthProvidersResponse, }, + signal, }); } - static connecteOAuthProvder(provider: TAuth.OAuthProvider) { + static connectOAuthProvder(provider: TAuth.OAuthProvider) { return api({ url: `/auth/oauth/${provider}/connect`, method: 'POST', diff --git a/src/entities/auth/api/queries.ts b/src/entities/auth/api/queries.ts index f1c3a2c..fc28d30 100644 --- a/src/entities/auth/api/queries.ts +++ b/src/entities/auth/api/queries.ts @@ -1,12 +1,20 @@ import { queryOptions } from '@tanstack/react-query'; import { AuthHttp } from './http'; +import { authFabricKeys } from '../model/const'; export class AuthQueries { static getOAuthProviders() { return queryOptions({ - queryKey: ['oauth-providers'], + queryKey: authFabricKeys.availableProviders(), queryFn: async ({ signal }) => AuthHttp.oAuthProviders(signal), staleTime: 60_000 * 360 * 24, }); } + static getConnectedOAuthProviders() { + return queryOptions({ + queryKey: authFabricKeys.connectedProviders(), + queryFn: async ({ signal }) => AuthHttp.connectedOAuthProviders(signal), + staleTime: 60_000 * 360 * 24, + }); + } } diff --git a/src/entities/auth/config/oauth-providers.ts b/src/entities/auth/config/oauth-providers.ts new file mode 100644 index 0000000..ea62141 --- /dev/null +++ b/src/entities/auth/config/oauth-providers.ts @@ -0,0 +1,26 @@ +import { type TAuth } from 'entities/auth'; +import YandexIcon from 'public/yandex-logo.svg'; +import VkontakteIcon from 'public/vkontakte-logo.svg'; +import GoogleIcon from 'public/google-logo.svg'; +import GithubIcon from 'public/github-logo.svg'; + +export type OAuthProviderMeta = { + iconSrc: string; + buttonClassName?: string; +}; + +export const OAUTH_PROVIDERS: Record = { + yandex: { + iconSrc: YandexIcon, + buttonClassName: 'text-[#fc3f1d] hover:text-[#fc3f1d]', + }, + vkontakte: { + iconSrc: VkontakteIcon, + buttonClassName: 'bg-[#07f] hover:bg-[#07f]', + }, + google: { iconSrc: GoogleIcon }, + github: { + iconSrc: GithubIcon, + buttonClassName: 'bg-[#24292f] hover:bg-[#24292f] text-white hover:text-white ', + }, +} as const; diff --git a/src/entities/auth/index.ts b/src/entities/auth/index.ts index 5287e31..4d39286 100644 --- a/src/entities/auth/index.ts +++ b/src/entities/auth/index.ts @@ -3,3 +3,5 @@ export type * as TAuth from './model/types'; export * as CAuth from './model/const'; export { AuthHttp } from './api/http'; export { AuthQueries } from './api/queries'; +export { authFabricKeys } from './model/const'; +export { OAUTH_PROVIDERS } from './config/oauth-providers'; diff --git a/src/entities/auth/model/const.ts b/src/entities/auth/model/const.ts index 2d40b1f..2252d6c 100644 --- a/src/entities/auth/model/const.ts +++ b/src/entities/auth/model/const.ts @@ -1,6 +1,13 @@ +import { createEntityKeys } from 'shared/lib/utils'; + export const MIN_PASS_LENGTH = 8; export const MAX_PASS_LENGTH = 32; export const OTP_LENGTH = 6; export const MIN_NAME_LENGTH = 2; export const MAX_NAME_LENGTH = 50; + +export const authFabricKeys = createEntityKeys('auth', { + availableProviders: () => ['providers', 'available'], + connectedProviders: () => ['providers', 'connected'], +}); diff --git a/src/features/auth/oauth-login/model/consts.ts b/src/features/auth/oauth-login/model/consts.ts deleted file mode 100644 index d611103..0000000 --- a/src/features/auth/oauth-login/model/consts.ts +++ /dev/null @@ -1,39 +0,0 @@ -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'; - -const getRoute = (provider: TAuth.OAuthProvider) => { - const params = new URLSearchParams({ - provider, - startOAuth: 'true', - } satisfies Record); - - return `${routes.auth.oauth()}?${params.toString()}`; -}; - -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: 'Вконтакте', - icon: VkontakteIcon, - href: getRoute('vkontakte'), - color: '#07f', - }, - google: { label: 'Google', icon: GoogleIcon, href: getRoute('google') }, - github: { - label: '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/ui/OAuthButton.tsx b/src/features/auth/oauth-login/ui/OAuthButton.tsx new file mode 100644 index 0000000..fde9e03 --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthButton.tsx @@ -0,0 +1,48 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import { Button } from 'shared/ui'; +import { cn } from 'shared/lib/utils'; +import { OAUTH_PROVIDERS } from 'entities/auth'; +import type { ButtonHTMLAttributes } from 'react'; +import type { Route } from 'next'; +import type { TAuth } from 'entities/auth'; + +type OAuthButtonProps = Omit, 'children'> & { + iconClassName?: string; + href: Route; + data: { + label: string; + value: TAuth.OAuthProvider; + }; +}; + +export function OAuthButton({ className, iconClassName, href, data, ...props }: OAuthButtonProps) { + const { label, value } = data; + const meta = OAUTH_PROVIDERS[value]; + + if (!meta) return null; + + return ( + + ); +} diff --git a/src/features/auth/oauth-login/ui/OAuthIcons.tsx b/src/features/auth/oauth-login/ui/OAuthIcons.tsx deleted file mode 100644 index 9332584..0000000 --- a/src/features/auth/oauth-login/ui/OAuthIcons.tsx +++ /dev/null @@ -1,56 +0,0 @@ -type IconProps = { className?: string }; - -export function GoogleIcon({ className }: IconProps) { - return ( - - - - - - - - ); -} - -export function YandexIcon({ className }: IconProps) { - return ( - - - - ); -} -export function GithubIcon({ className }: IconProps) { - return ( - - - - ); -} -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 9de4cd7..ab8a14e 100644 --- a/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx +++ b/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx @@ -1,38 +1,32 @@ 'use client'; -import { Button, Skeleton } from 'shared/ui'; -import { OAUTH_PROVIDERS, OAUTH_PROVIDERS_COUNT } from '../model/consts'; +import { Skeleton } from 'shared/ui'; import { cn } from 'shared/lib/utils'; -import Link from 'next/link'; import { useQuery } from '@tanstack/react-query'; import { AuthQueries } from 'entities/auth'; +import { routes } from 'shared/config'; +import { OAuthButton } from './OAuthButton'; +import { type TAuth } from 'entities/auth'; +import { type StartOauthParams } from '../model/types'; import { type Route } from 'next'; +export const getRoute = (provider: TAuth.OAuthProvider) => { + const params = new URLSearchParams({ + provider, + startOAuth: 'true', + } satisfies Record); + + return `${routes.auth.oauth()}?${params.toString()}`; +}; + export function OAuthLoginButtons({ className }: { className?: string }) { const { data, isLoading } = useQuery(AuthQueries.getOAuthProviders()); return (
- {isLoading && - Array.from({ length: OAUTH_PROVIDERS_COUNT }, (_v, i) => ( - - ))} + {isLoading && Array.from({ length: 3 }, (_v, i) => )} {data?.map((item) => { - const providerConfig = OAUTH_PROVIDERS[item.value]; - - return ( - - ); + return ; })}
); diff --git a/src/features/handle-query-params/index.ts b/src/features/handle-query-params/index.ts new file mode 100644 index 0000000..162963b --- /dev/null +++ b/src/features/handle-query-params/index.ts @@ -0,0 +1 @@ +export { QueryParamsHandler } from './ui/QueryParamsHandler'; diff --git a/src/features/handle-query-params/ui/QueryParamsHandler.tsx b/src/features/handle-query-params/ui/QueryParamsHandler.tsx new file mode 100644 index 0000000..d132422 --- /dev/null +++ b/src/features/handle-query-params/ui/QueryParamsHandler.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { type Route } from 'next'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +export function QueryParamsHandler() { + const params = useSearchParams(); + const isShowToast = useRef(false); + const router = useRouter(); + + useEffect(() => { + const success = params?.get('success'); + const message = params?.get('message'); + + if (isShowToast.current || !success) return; + + if (success === 'true') { + toast.success(message || 'Операция выполнена успешно'); + } else if (success === 'false') { + toast.error(message || 'Произошла ошибка. Пожалуйста, попробуйте позже'); + } + + isShowToast.current = true; + router.replace(location.pathname as Route); + }, [params, router]); + + return null; +} diff --git a/src/pages/auth/oauth/ui/OAuthPage.tsx b/src/pages/auth/oauth/ui/OAuthPage.tsx index 960533b..16613be 100644 --- a/src/pages/auth/oauth/ui/OAuthPage.tsx +++ b/src/pages/auth/oauth/ui/OAuthPage.tsx @@ -21,6 +21,8 @@ interface Props { export async function OAuthPage({ searchParams }: Props) { const { success, message, provider, startOAuth } = await searchParams; + // TODO: страница знает API + if (provider && startOAuth === 'true') { redirect(`${env.NEXT_PUBLIC_API_BASE_URL}/auth/oauth/${provider}` as Route); } @@ -30,11 +32,13 @@ export async function OAuthPage({ searchParams }: Props) { } if (success === 'true') { - redirect(routes.user.profile()); + redirect( + `${routes.user.profile()}?success=true&message=${encodeURIComponent(message || 'Вход успешен')}` + ); } const errorUrl = message - ? `${routes.auth.signin()}?oauth_error=1&message=${encodeURIComponent(message)}` + ? `${routes.auth.signin()}?success=true&message=${encodeURIComponent(message)}` : routes.auth.signin(); redirect(errorUrl as Route); diff --git a/src/pages/auth/signin/model/useAuthRedirectMessage.ts b/src/pages/auth/signin/model/useAuthRedirectMessage.ts deleted file mode 100644 index 8d64438..0000000 --- a/src/pages/auth/signin/model/useAuthRedirectMessage.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; -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; - toast.error(message ?? 'Authorization failed'); - router.replace(routes.auth.signin()); - }, [params, router]); -} diff --git a/src/pages/auth/signin/ui/AuthRedirectHandler.tsx b/src/pages/auth/signin/ui/AuthRedirectHandler.tsx deleted file mode 100644 index 2b5a8ee..0000000 --- a/src/pages/auth/signin/ui/AuthRedirectHandler.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; -import { useAuthRedirectMessage } from '../model/useAuthRedirectMessage'; - -export function AuthRedirectHandler() { - useAuthRedirectMessage(); - return null; -} diff --git a/src/pages/auth/signin/ui/SigninForm.tsx b/src/pages/auth/signin/ui/SigninForm.tsx index 0fa94d5..6fa91e4 100644 --- a/src/pages/auth/signin/ui/SigninForm.tsx +++ b/src/pages/auth/signin/ui/SigninForm.tsx @@ -24,9 +24,10 @@ import { cn, setFormErrors } from 'shared/lib/utils'; import { routes } from 'shared/config'; import { extractValidationIssues } from 'shared/api'; import { TAuth } from 'entities/auth'; -import { ComponentProps } from 'react'; +import { ComponentProps, Suspense } from 'react'; import { useSignin, UseSigninOptions } from '../model/useSignin'; import { OAuthLoginButtons, OAuthSeparator } from 'features/auth/oauth-login'; +import { QueryParamsHandler } from 'features/handle-query-params'; interface SigninFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseSigninOptions; @@ -59,70 +60,75 @@ export function SigninForm({ className, mutateOptions = {}, ...props }: SigninFo }; return ( - - - Вход в систему - Пожалуйста, введите ваши данные для входа. - - -
- - ( - - Email - - {fieldState.invalid && } - - )} - /> - ( - -
- Пароль - - Забыли пароль? - -
- - {fieldState.invalid && } -
- )} - /> - - - - - - Нет аккаунта? Зарегистрироваться - - -
-
- - -
-
+ <> + + + + + + Вход в систему + Пожалуйста, введите ваши данные для входа. + + +
+ + ( + + Email + + {fieldState.invalid && } + + )} + /> + ( + +
+ Пароль + + Забыли пароль? + +
+ + {fieldState.invalid && } +
+ )} + /> + + + + + + Нет аккаунта? Зарегистрироваться + + +
+
+ + +
+
+ ); } diff --git a/src/pages/auth/signin/ui/SigninPage.tsx b/src/pages/auth/signin/ui/SigninPage.tsx index 70cbad5..6d7f9da 100644 --- a/src/pages/auth/signin/ui/SigninPage.tsx +++ b/src/pages/auth/signin/ui/SigninPage.tsx @@ -7,14 +7,14 @@ import { AccessToken } from 'shared/api'; import { toast } from 'sonner'; import { useRouter } from 'next/navigation'; import { Suspense } from 'react'; -import { AuthRedirectHandler } from './AuthRedirectHandler'; +import { QueryParamsHandler } from 'features/handle-query-params'; function SigninPage() { const router = useRouter(); return ( <> - +
diff --git a/src/pages/auth/signup/ui/SignupForm.tsx b/src/pages/auth/signup/ui/SignupForm.tsx index 7d7b61b..dc74434 100644 --- a/src/pages/auth/signup/ui/SignupForm.tsx +++ b/src/pages/auth/signup/ui/SignupForm.tsx @@ -30,6 +30,7 @@ import { prepareFullName } from '../model/utils/prepare-fullname'; import { extractValidationIssues } from 'shared/api'; import { TAuth } from 'entities/auth'; import { useSignup, type UseSignupOptions } from '../model/useSignup'; +import { OAuthLoginButtons, OAuthSeparator } from 'features/auth/oauth-login'; interface SignupFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseSignupOptions; @@ -183,6 +184,8 @@ export function SignupForm({ className, mutateOptions = {}, ...props }: SignupFo + + ); diff --git a/src/pages/profile/api/useConnectOauthProvider.ts b/src/pages/profile/api/useConnectOauthProvider.ts new file mode 100644 index 0000000..dbf46dc --- /dev/null +++ b/src/pages/profile/api/useConnectOauthProvider.ts @@ -0,0 +1,21 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { AuthHttp, type TAuth } from 'entities/auth'; + +export type UseConnectOAuthProviderOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useConnectOAuthProvider({ ...rest }: UseConnectOAuthProviderOptions = {}) { + return useMutation< + Awaited, + DefaultError, + TAuth.OAuthProvider + >({ + ...rest, + mutationFn: AuthHttp.connectOAuthProvder, + meta: { + skipGlobalValidationToast: true, + }, + }); +} diff --git a/src/pages/profile/api/useConnectedAccounts.ts b/src/pages/profile/api/useConnectedAccounts.ts new file mode 100644 index 0000000..8518a9d --- /dev/null +++ b/src/pages/profile/api/useConnectedAccounts.ts @@ -0,0 +1,22 @@ +'use client'; +import { useQuery } from '@tanstack/react-query'; +import { AuthQueries } from 'entities/auth'; +import { useMemo } from 'react'; + +export function useConnectedAccounts() { + const available = useQuery(AuthQueries.getOAuthProviders()); + const connected = useQuery(AuthQueries.getConnectedOAuthProviders()); + + const providers = useMemo(() => { + if (!available.data) return []; + + const connectedSet = new Set(connected.data?.map((v) => v.provider)); + + return available.data.map((item) => ({ + ...item, + isConnected: connectedSet.has(item.value), + })); + }, [available.data, connected.data]); + + return { providers }; +} diff --git a/src/pages/profile/api/useDisconnectOauthProvider.ts b/src/pages/profile/api/useDisconnectOauthProvider.ts new file mode 100644 index 0000000..ca7464c --- /dev/null +++ b/src/pages/profile/api/useDisconnectOauthProvider.ts @@ -0,0 +1,19 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { AuthHttp, type TAuth } from 'entities/auth'; + +export type UseDisconnectOAuthProviderOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useDisconnectOAuthProvider({ ...rest }: UseDisconnectOAuthProviderOptions = {}) { + return useMutation, DefaultError, TAuth.OAuthProvider>( + { + ...rest, + mutationFn: AuthHttp.removeOAuthProvder, + meta: { + skipGlobalValidationToast: true, + }, + } + ); +} diff --git a/src/pages/profile/ui/me-page/MePage.tsx b/src/pages/profile/ui/me-page/MePage.tsx index 838e1ef..97952ff 100644 --- a/src/pages/profile/ui/me-page/MePage.tsx +++ b/src/pages/profile/ui/me-page/MePage.tsx @@ -12,6 +12,9 @@ import { import { IdentityItem } from './IdentityItem'; import { ProfileForm } from './ProfileForm'; import { useMePage } from '../../model/useMePage'; +import { AccountSection } from './account-section/AccountsSection'; +import { Suspense } from 'react'; +import { QueryParamsHandler } from 'features/handle-query-params'; function MePage() { const { form, profile, email, isDirty, isPending, onSubmit, onDiscard } = useMePage(); @@ -29,22 +32,28 @@ function MePage() { return ( <> - - - - - - + + + +
+ + + + + + + +
); } diff --git a/src/pages/profile/ui/me-page/account-section/AccountsSection.tsx b/src/pages/profile/ui/me-page/account-section/AccountsSection.tsx new file mode 100644 index 0000000..e505e4f --- /dev/null +++ b/src/pages/profile/ui/me-page/account-section/AccountsSection.tsx @@ -0,0 +1,26 @@ +import { OAuthManageButton } from './OAuthManageButton'; +import { useConnectedAccounts } from 'pages/profile/api/useConnectedAccounts'; +import { CardSection } from 'shared/ui'; + +export function AccountSection() { + const { providers } = useConnectedAccounts(); + + return ( + + {providers?.map((provider) => { + return ( + + ); + })} + + ); +} diff --git a/src/pages/profile/ui/me-page/account-section/OAuthManageButton.tsx b/src/pages/profile/ui/me-page/account-section/OAuthManageButton.tsx new file mode 100644 index 0000000..dd3f024 --- /dev/null +++ b/src/pages/profile/ui/me-page/account-section/OAuthManageButton.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { OAUTH_PROVIDERS, TAuth, authFabricKeys } from 'entities/auth'; +import { useCallback, type ComponentProps } from 'react'; +import { Button } from 'shared/ui'; +import { useConnectOAuthProvider } from '../../../api/useConnectOauthProvider'; +import { useDisconnectOAuthProvider } from '../../../api/useDisconnectOauthProvider'; +import { env } from 'shared/config'; +import { toast } from 'sonner'; +import Image from 'next/image'; + +type OAuthManageButtonProps = ComponentProps & { + provider: TAuth.OAuthProvider; + label: string; + isLinked: boolean; +}; + +export function OAuthManageButton({ provider, label, isLinked, ...props }: OAuthManageButtonProps) { + const connect = useConnectOAuthProvider(); + const disconnect = useDisconnectOAuthProvider(); + + const isLoading = connect.isPending || disconnect.isPending; + + const handleToggleConnect = useCallback(() => { + if (isLinked) { + disconnect.mutate(provider, { + onSuccess: (data, _v, _m, context) => { + context.client.invalidateQueries({ queryKey: authFabricKeys.connectedProviders() }); + toast.success(data.message); + }, + }); + } else { + connect.mutate(provider, { + onSuccess: (data) => { + const url = data.url.startsWith('http') + ? data.url + : new URL(data.url, env.NEXT_PUBLIC_API_BASE_URL).toString(); + window.location.href = url; + }, + }); + } + }, [connect, disconnect, isLinked, provider]); + + const meta = OAUTH_PROVIDERS[provider]; + + return ( + + ); +}