Skip to content
Open
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
1 change: 0 additions & 1 deletion app/(auth)/oauth/page.tsx

This file was deleted.

85 changes: 85 additions & 0 deletions app/(auth)/oauth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { type TAuth } from 'entities/auth';
import { StartOauthParams } from 'features/auth/oauth-login';
import { NextRequest, NextResponse } from 'next/server';
import { env, routes } from 'shared/config';

type OAuthParams =
| {
success: 'false' | 'true';
token?: string;
provider: TAuth.OAuthProvider;
}
| StartOauthParams;

const ERROR_MESSAGE = 'Не удалось выполнить авторизацию';

export async function GET(request: NextRequest) {
const params: Partial<OAuthParams> = Object.fromEntries(request.nextUrl.searchParams);

//start oauth
if (
'provider' in params &&
'startOAuth' in params &&
params.startOAuth === 'true' &&
params.provider
) {
const { provider } = params;
const redirectUrl = `${env.NEXT_PUBLIC_API_BASE_URL}/oauth/${provider}`;

return NextResponse.redirect(redirectUrl);
}

//exchange token
if ('token' in params && params.token) {
try {
const { token, success, provider } = params;

if (success === 'false') {
throw new Error(ERROR_MESSAGE);
}

if (!provider) {
throw new Error('OAuth provider не найден в query параметрах');
}

const response = await fetch(`${env.NEXT_PUBLIC_API_BASE_URL}/oauth/exchange`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token, provider } satisfies TAuth.ExchangeTokenBody),
});

const data = (await response.json()) as TAuth.ExchangeTokenResponse;

if (!response.ok || !data.success) {
throw new Error(
data.message || `OAuth exchange завершился с ошибкой (status ${response.status})`
);
}

const successUrl = new URL(routes.user.profile(), request.url);

successUrl.searchParams.set('success', 'true');
successUrl.searchParams.set('message', 'Операция выполнена успешно');

const res = NextResponse.redirect(successUrl);
const cookies = response.headers.getSetCookie() ?? [];

cookies.forEach((cookie) => {
res.headers.append('Set-Cookie', cookie);
});

return res;
} catch (error) {
console.error(error instanceof Error ? error.message : ERROR_MESSAGE);
}
}

const errorUrl = new URL(routes.auth.signin(), request.url);

errorUrl.searchParams.set('success', 'false');
errorUrl.searchParams.set('message', ERROR_MESSAGE);

return NextResponse.redirect(errorUrl);
}
17 changes: 12 additions & 5 deletions src/entities/auth/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export class AuthHttp {
}
static oAuthProviders(signal: AbortSignal) {
return api<TAuth.OAuthProvidersResponse>({
url: '/auth/oauth/providers',
url: '/oauth/providers',
method: 'GET',
contracts: {
response: SAuth.OAuthProvidersResponse,
Expand All @@ -98,7 +98,7 @@ export class AuthHttp {
}
static connectedOAuthProviders(signal: AbortSignal) {
return api<TAuth.ConnectedOAuthProvidersResponse>({
url: '/auth/oauth/providers/connected',
url: '/oauth/providers/connected',
method: 'GET',
contracts: {
response: SAuth.ConnectedOAuthProvidersResponse,
Expand All @@ -108,7 +108,7 @@ export class AuthHttp {
}
static connectOAuthProvder(provider: TAuth.OAuthProvider) {
return api<TAuth.ConnectOAuthProviderResponse>({
url: `/auth/oauth/${provider}/connect`,
url: `/oauth/${provider}/connect`,
method: 'POST',
contracts: {
response: SAuth.ConnectOAuthProviderResponse,
Expand All @@ -117,7 +117,7 @@ export class AuthHttp {
}
static removeOAuthProvder(provider: TAuth.OAuthProvider) {
return api<TAuth.RemoveOAuthProviderResponse>({
url: `/auth/oauth/${provider}/connect`,
url: `/oauth/${provider}/connect`,
method: 'DELETE',
contracts: {
response: SAuth.RemoveOAuthProviderResponse,
Expand All @@ -126,7 +126,7 @@ export class AuthHttp {
}
static resendCode(data: TAuth.ResendCodeBody): Promise<TAuth.ResendCodeResponse> {
return api<TAuth.ResendCodeResponse>({
url: '/auth/resend',
url: '/oauth/resend',
method: 'POST',
data: data,
contracts: {
Expand All @@ -135,4 +135,11 @@ export class AuthHttp {
},
});
}
static exchangeToken(data: TAuth.ExchangeTokenBody): Promise<TAuth.ExchangeTokenResponse> {
return api<TAuth.ExchangeTokenResponse>({
url: '/oauth/exchange',
method: 'POST',
data: data,
});
}
}
20 changes: 10 additions & 10 deletions src/entities/auth/config/oauth-providers.ts
Original file line number Diff line number Diff line change
@@ -1,26 +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';
import GithubIcon from 'github-logo.svg';
Comment thread
kapitulin24 marked this conversation as resolved.
import GoogleIcon from 'google-logo.svg';
import VkontakteIcon from 'vkontakte-logo.svg';
import YandexIcon from 'yandex-logo.svg';
import { OAuthProvider } from '../model/types';

export type OAuthProviderMeta = {
iconSrc: string;
buttonClassName?: string;
className?: string;
};

export const OAUTH_PROVIDERS: Record<TAuth.OAuthProvider, OAuthProviderMeta> = {
export const OAUTH_PROVIDERS: Record<OAuthProvider, OAuthProviderMeta> = {
yandex: {
iconSrc: YandexIcon,
buttonClassName: 'text-[#fc3f1d] hover:text-[#fc3f1d]',
className: 'text-[#fc3f1d] hover:text-[#fc3f1d]',
},
vkontakte: {
iconSrc: VkontakteIcon,
buttonClassName: 'bg-[#07f] hover:bg-[#07f]',
className: 'bg-[#07f] hover:bg-[#07f]',
},
google: { iconSrc: GoogleIcon },
github: {
iconSrc: GithubIcon,
buttonClassName: 'bg-[#24292f] hover:bg-[#24292f] text-white hover:text-white ',
className: 'bg-[#24292f] hover:bg-[#24292f] text-white hover:text-white ',
},
} as const;
10 changes: 10 additions & 0 deletions src/entities/auth/model/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,13 @@ export const ResendCodeResponse = GlobalSuccess.extend({
retryAfterSeconds: z.number(),
retries: z.number(),
});

export const ExchangeTokenResponse = GlobalSuccess.extend({
access: z.string(),
isNewUser: z.boolean(),
provider: OAuthProvider,
});
export const ExchangeTokenBody = z.object({
token: z.string(),
provider: OAuthProvider,
});
3 changes: 3 additions & 0 deletions src/entities/auth/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ export type ConnectOAuthProviderResponse = z.infer<typeof SAuth.ConnectOAuthProv
export type RemoveOAuthProviderResponse = z.infer<typeof SAuth.RemoveOAuthProviderResponse>;
export type ResendCodeBody = z.infer<typeof SAuth.ResendCodeBody>;
export type ResendCodeResponse = z.infer<typeof SAuth.ResendCodeResponse>;

export type ExchangeTokenBody = z.infer<typeof SAuth.ExchangeTokenBody>;
export type ExchangeTokenResponse = z.infer<typeof SAuth.ExchangeTokenResponse>;
2 changes: 1 addition & 1 deletion src/features/auth/oauth-login/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TAuth } from 'entities/auth';
import { type TAuth } from 'entities/auth';

export type StartOauthParams = {
provider: TAuth.OAuthProvider;
Expand Down
48 changes: 0 additions & 48 deletions src/features/auth/oauth-login/ui/OAuthButton.tsx

This file was deleted.

45 changes: 14 additions & 31 deletions src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,16 @@
'use client';
import dynamic from 'next/dynamic';
import { Skeleton } from 'shared/ui';
import { cn } from 'shared/lib/utils';
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<keyof StartOauthParams, string>);

return `${routes.auth.oauth()}?${params.toString()}`;
};

export function OAuthLoginButtons({ className }: { className?: string }) {
const { data, isLoading } = useQuery(AuthQueries.getOAuthProviders());

return (
<div className={cn('flex items-center justify-center gap-2', className)}>
{isLoading && Array.from({ length: 3 }, (_v, i) => <Skeleton className="size-8" key={i} />)}

{data?.map((item) => {
return <OAuthButton key={item.value} data={item} href={getRoute(item.value) as Route} />;
})}
</div>
);
}
export const OAuthLoginButtons = dynamic(
() => import('./OAuthLoginButtonsContent').then((module) => module.OAuthLoginButtonsContent),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center gap-2">
{Array.from({ length: 3 }, (_v, i) => (
<Skeleton className="size-8" key={i} />
))}
</div>
),
}
);
47 changes: 47 additions & 0 deletions src/features/auth/oauth-login/ui/OAuthLoginButtonsContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import { useSuspenseQuery } from '@tanstack/react-query';
import { AuthQueries, OAUTH_PROVIDERS, type TAuth } from 'entities/auth';
import Image from 'next/image';
import { useCallback } from 'react';
import { routes } from 'shared/config';
import { cn } from 'shared/lib/utils';
import { Button } from 'shared/ui';

export interface OAuthLoginButtonsContentProps {
className?: string;
}

export function OAuthLoginButtonsContent({ className }: OAuthLoginButtonsContentProps) {
const { data } = useSuspenseQuery(AuthQueries.getOAuthProviders());

const startOAuth = useCallback((provider: TAuth.OAuthProvider) => {
const route = new URL(routes.auth.oauth(), window.location.origin);

route.searchParams.set('provider', provider);
route.searchParams.set('startOAuth', 'true');

window.location.assign(route.href);
}, []);

return (
<div className={cn('flex items-center justify-center gap-2', className)}>
{data?.map(({ label, value }) => {
const data = OAUTH_PROVIDERS[value];

return (
<Button
type="button"
className={data.className}
key={value}
variant={'outline'}
size="icon"
onClick={() => startOAuth(value)}
>
<Image src={data.iconSrc} alt={label} width={24} height={24} className="size-6" />
</Button>
);
})}
</div>
);
}
30 changes: 15 additions & 15 deletions src/features/auth/oauth-login/ui/OAuthSeparator.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { cn } from 'shared/lib/utils';
import dynamic from 'next/dynamic';
import { Skeleton } from 'shared/ui';

interface OAuthSeparatorProps {
className?: string;
label?: string;
}

export function OAuthSeparator({ className, label = 'или' }: OAuthSeparatorProps) {
return (
<div className={cn('text-muted-foreground my-3 flex items-center', className)}>
<span className="bg-border h-px w-full" />
<span className="block px-2">{label}</span>
<span className="bg-border h-px w-full" />
</div>
);
}
export const OAuthSeparator = dynamic(
() => import('./OAuthSeparatorContent').then((module) => module.OAuthSeparatorContent),
{
ssr: false,
loading: () => (
<div className="my-3 flex items-center px-1">
<Skeleton className="h-px w-full" />
<Skeleton className="mx-2 h-4 w-16" />
<Skeleton className="h-px w-full" />
</div>
),
}
);
Loading
Loading