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/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index fe7ecd1..3b5a245 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -86,6 +86,43 @@ export class AuthHttp { }, }); } + static oAuthProviders(signal: AbortSignal) { + return api({ + url: '/auth/oauth/providers', + method: 'GET', + contracts: { + response: SAuth.OAuthProvidersResponse, + }, + signal, + }); + } + 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, + }, + }); + } static resendCode(data: TAuth.ResendCodeBody): Promise { return api({ url: '/auth/resend', diff --git a/src/entities/auth/api/queries.ts b/src/entities/auth/api/queries.ts new file mode 100644 index 0000000..f1c3a2c --- /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: ['oauth-providers'], + queryFn: async ({ signal }) => AuthHttp.oAuthProviders(signal), + staleTime: 60_000 * 360 * 24, + }); + } +} 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 9ef3983..c56d2f3 100644 --- a/src/entities/auth/model/schemas.ts +++ b/src/entities/auth/model/schemas.ts @@ -77,6 +77,30 @@ 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; export const ResendCodeBody = z.object({ context: z.enum(['sign-up', 'reset-password']), email: Email, diff --git a/src/entities/auth/model/types.ts b/src/entities/auth/model/types.ts index 7cfe1e5..af7dfe6 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; @@ -21,5 +22,9 @@ export type ResetPasswordVerifyResponse = 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; export type ResendCodeBody = z.infer; export type ResendCodeResponse = z.infer; diff --git a/src/features/auth/oauth-login/index.ts b/src/features/auth/oauth-login/index.ts new file mode 100644 index 0000000..efc440b --- /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/types'; 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..d611103 --- /dev/null +++ b/src/features/auth/oauth-login/model/consts.ts @@ -0,0 +1,39 @@ +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/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 new file mode 100644 index 0000000..9332584 --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthIcons.tsx @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..9de4cd7 --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx @@ -0,0 +1,39 @@ +'use client'; +import { Button, Skeleton } from 'shared/ui'; +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'; +import { AuthQueries } from 'entities/auth'; +import { type Route } from 'next'; + +export function OAuthLoginButtons({ className }: { className?: string }) { + const { data, isLoading } = useQuery(AuthQueries.getOAuthProviders()); + + return ( +
+ {isLoading && + Array.from({ length: OAUTH_PROVIDERS_COUNT }, (_v, i) => ( + + ))} + + {data?.map((item) => { + 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 new file mode 100644 index 0000000..7522746 --- /dev/null +++ b/src/features/auth/oauth-login/ui/OAuthSeparator.tsx @@ -0,0 +1,16 @@ +import { cn } from 'shared/lib/utils'; + +interface OAuthSeparatorProps { + className?: string; + label?: string; +} + +export function OAuthSeparator({ className, label = 'или' }: OAuthSeparatorProps) { + return ( +
+ + {label} + +
+ ); +} 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'; 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/model/useAuthRedirectMessage.ts b/src/pages/auth/signin/model/useAuthRedirectMessage.ts new file mode 100644 index 0000000..8d64438 --- /dev/null +++ b/src/pages/auth/signin/model/useAuthRedirectMessage.ts @@ -0,0 +1,19 @@ +'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 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/SigninForm.tsx b/src/pages/auth/signin/ui/SigninForm.tsx index a004d37..0fa94d5 100644 --- a/src/pages/auth/signin/ui/SigninForm.tsx +++ b/src/pages/auth/signin/ui/SigninForm.tsx @@ -26,6 +26,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 +120,8 @@ export function SigninForm({ className, mutateOptions = {}, ...props }: SigninFo + + ); diff --git a/src/pages/auth/signin/ui/SigninPage.tsx b/src/pages/auth/signin/ui/SigninPage.tsx index cfe7c5c..70cbad5 100644 --- a/src/pages/auth/signin/ui/SigninPage.tsx +++ b/src/pages/auth/signin/ui/SigninPage.tsx @@ -2,36 +2,41 @@ 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'; 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); + } } - } - }, - }} - /> + }, + }} + /> +
-
+ ); } 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; 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 ( +
    + {teams.map((team) => ( +
  • + + {team.permissions.isOwner ? ( + + Owner + + ) : null} + + + + + {team.name} + {team.description} + + +
    + + + + +
    +
    +
    +
  • + ))} +
+ ); +} 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'; 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;