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
1 change: 1 addition & 0 deletions app/(auth)/oauth/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OAuthPage as default } from 'pages/auth/oauth';
37 changes: 37 additions & 0 deletions src/entities/auth/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,43 @@ export class AuthHttp {
},
});
}
static oAuthProviders(signal: AbortSignal) {
return api<TAuth.OAuthProvidersResponse>({
url: '/auth/oauth/providers',
method: 'GET',
contracts: {
response: SAuth.OAuthProvidersResponse,
},
signal,
});
}
static connectedOAuthProviders() {
return api<TAuth.ConnectedOAuthProvidersResponse>({
url: '/auth/oauth/providers/connected',
method: 'GET',
contracts: {
response: SAuth.ConnectedOAuthProvidersResponse,
},
});
}
static connecteOAuthProvder(provider: TAuth.OAuthProvider) {
return api<TAuth.ConnectOAuthProviderResponse>({
url: `/auth/oauth/${provider}/connect`,
method: 'POST',
contracts: {
response: SAuth.ConnectOAuthProviderResponse,
},
});
}
static removeOAuthProvder(provider: TAuth.OAuthProvider) {
return api<TAuth.RemoveOAuthProviderResponse>({
url: `/auth/oauth/${provider}/connect`,
method: 'DELETE',
contracts: {
response: SAuth.RemoveOAuthProviderResponse,
},
});
}
static resendCode(data: TAuth.ResendCodeBody): Promise<TAuth.ResendCodeResponse> {
return api<TAuth.ResendCodeResponse>({
url: '/auth/resend',
Expand Down
12 changes: 12 additions & 0 deletions src/entities/auth/api/queries.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
1 change: 1 addition & 0 deletions src/entities/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
24 changes: 24 additions & 0 deletions src/entities/auth/model/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/entities/auth/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as SAuth from './schemas';
export type Email = z.infer<typeof SAuth.Email>;
export type Password = z.infer<typeof SAuth.Password>;
export type OTPCode = z.infer<typeof SAuth.OTPCode>;
export type OAuthProvider = z.infer<typeof SAuth.OAuthProvider>;

export type SigninBody = z.infer<typeof SAuth.SigninBody>;
export type SigninResponse = z.infer<typeof SAuth.SigninResponse>;
Expand All @@ -21,5 +22,9 @@ export type ResetPasswordVerifyResponse = z.infer<typeof SAuth.ResetPasswordVeri
export type ResetPasswordConfirmBody = z.infer<typeof SAuth.ResetPasswordConfirmBody>;
export type ResetPasswordConfirmResponse = z.infer<typeof SAuth.ResetPasswordConfirmResponse>;

export type OAuthProvidersResponse = z.infer<typeof SAuth.OAuthProvidersResponse>;
export type ConnectedOAuthProvidersResponse = z.infer<typeof SAuth.ConnectedOAuthProvidersResponse>;
export type ConnectOAuthProviderResponse = z.infer<typeof SAuth.ConnectOAuthProviderResponse>;
export type RemoveOAuthProviderResponse = z.infer<typeof SAuth.RemoveOAuthProviderResponse>;
export type ResendCodeBody = z.infer<typeof SAuth.ResendCodeBody>;
export type ResendCodeResponse = z.infer<typeof SAuth.ResendCodeResponse>;
3 changes: 3 additions & 0 deletions src/features/auth/oauth-login/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { OAuthLoginButtons } from './ui/OAuthLoginButtons';
export { OAuthSeparator } from './ui/OAuthSeparator';
export { type StartOauthParams } from './model/types';
39 changes: 39 additions & 0 deletions src/features/auth/oauth-login/model/consts.ts
Original file line number Diff line number Diff line change
@@ -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<keyof StartOauthParams, string>);

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

export type OAuthProviderConfig = {
label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
href: string;
color?: string;
};

export const OAUTH_PROVIDERS: Record<TAuth.OAuthProvider, OAuthProviderConfig> = {
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;
6 changes: 6 additions & 0 deletions src/features/auth/oauth-login/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { TAuth } from 'entities/auth';

export type StartOauthParams = {
provider: TAuth.OAuthProvider;
startOAuth: 'true' | 'false';
};
56 changes: 56 additions & 0 deletions src/features/auth/oauth-login/ui/OAuthIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type IconProps = { className?: string };

export function GoogleIcon({ className }: IconProps) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
<path d="M1 1h22v22H1z" fill="none" />
</svg>
);
}

export function YandexIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
fill="#fc3f1d"
d="M26.9 13.3v24.2h4.9v-28h-7.1c-7.1 0-10.9 3.7-10.9 9.1 0 4.3 2.1 6.8 5.7 9.5l-6.4 9.5h5.3L25.5 27 23 25.3c-3-2-4.4-3.6-4.4-7 0-3 2.1-5 6.1-5h2.2z"
/>
</svg>
);
}
export function GithubIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
fill="#fff"
d="M17.6 46.7c0 .7-.5 1.3-1.6 1.3h16.1c-1.5 0-1.9-.6-1.9-1.3v-6.9c0-2.4-.8-3.9-1.7-4.7 5.6-.6 11.5-2.8 11.5-12.5 0-2.8-1-5-2.6-6.8.2-.6 1.1-3.2-.2-6.7 0 0-2.1-.7-6.9 2.6-2-.6-4.2-.8-6.3-.8s-4.2.3-6.3.8c-4.8-3.3-6.9-2.6-6.9-2.6-1.4 3.5-.5 6.1-.2 6.7C9 17.6 8 19.8 8 22.6c0 9.7 5.9 11.9 11.4 12.5-.7.6-1.4 1.8-1.6 3.4-1.5.7-5.1 1.8-7.3-2.1 0 0-1.3-2.4-3.9-2.6 0 0-2.5 0-.2 1.6 0 0 1.7.8 2.8 3.7 0 0 1.5 4.5 8.4 3v4.6z"
/>
</svg>
);
}
export function VkontakteIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
fill="#fff"
d="M25.5 35C14.5 35 8.3 27.5 8 15h5.5c.2 9.2 4.2 13.1 7.4 13.9V15h5.2v7.9c3.2-.3 6.5-3.9 7.6-7.9h5.2c-.9 4.9-4.5 8.5-7 10 2.6 1.2 6.7 4.3 8.2 10h-5.7c-1.2-3.8-4.3-6.7-8.3-7.1V35h-.6z"
/>
</svg>
);
}
39 changes: 39 additions & 0 deletions src/features/auth/oauth-login/ui/OAuthLoginButtons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('flex items-center justify-center gap-2', className)}>
{isLoading &&
Array.from({ length: OAUTH_PROVIDERS_COUNT }, (_v, i) => (
<Skeleton className="size-8" key={i} />
))}

{data?.map((item) => {
const providerConfig = OAUTH_PROVIDERS[item.value];

return (
<Button
asChild
style={{ backgroundColor: providerConfig.color }}
key={item.value}
size={'icon'}
variant={'outline'}
>
<Link href={providerConfig.href as Route}>
<providerConfig.icon className="size-6" />
</Link>
</Button>
);
})}
</div>
);
}
16 changes: 16 additions & 0 deletions src/features/auth/oauth-login/ui/OAuthSeparator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { cn } from 'shared/lib/utils';

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>
);
}
1 change: 1 addition & 0 deletions src/pages/auth/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OAuthPage } from './ui/OAuthPage';
41 changes: 41 additions & 0 deletions src/pages/auth/oauth/ui/OAuthPage.tsx
Original file line number Diff line number Diff line change
@@ -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<Partial<OAuthParams>>;
}

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);
}
19 changes: 19 additions & 0 deletions src/pages/auth/signin/model/useAuthRedirectMessage.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
7 changes: 7 additions & 0 deletions src/pages/auth/signin/ui/AuthRedirectHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';
import { useAuthRedirectMessage } from '../model/useAuthRedirectMessage';

export function AuthRedirectHandler() {
useAuthRedirectMessage();
return null;
}
3 changes: 3 additions & 0 deletions src/pages/auth/signin/ui/SigninForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentProps<'form'>, 'children' | 'onSubmit'> {
mutateOptions?: UseSigninOptions;
Expand Down Expand Up @@ -119,6 +120,8 @@ export function SigninForm({ className, mutateOptions = {}, ...props }: SigninFo
</Field>
</FieldGroup>
</form>
<OAuthSeparator />
<OAuthLoginButtons />
</CardContent>
</Card>
);
Expand Down
Loading
Loading