Skip to content
5 changes: 4 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'rematedeploy-production.up.railway.app',
},
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
],
},
};
Expand Down
4 changes: 2 additions & 2 deletions src/apis/workspace/workspace.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,9 @@ export const workspaceService = {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (res.status === 403) throw new Error('강퇴 권한이 없습니다.');
if (res.status === 403) throw new Error('삭제 권한이 없습니다.');
if (res.status === 404) throw new Error('멤버를 찾을 수 없습니다.');
if (!res.ok) throw new Error('멤버 강퇴에 실패했습니다.');
if (!res.ok) throw new Error('멤버 삭제에 실패했습니다.');
return res.json();
},

Expand Down
9 changes: 7 additions & 2 deletions src/app/(after-login)/mypage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,17 +178,22 @@ export default function Page() {
className='hidden'
onChange={handleFileChange}
/>

<div
className='group relative h-24 w-24 cursor-pointer rounded-full border border-gray-300 bg-gray-100 md:h-28 md:w-28'
className='group relative h-24 w-24 cursor-pointer rounded-full md:h-28 md:w-28'
onClick={() => fileInputRef.current?.click()}
>
{previewUrl && (
{previewUrl ? (
<Image
src={previewUrl}
alt='프로필 이미지'
fill
className='rounded-full object-cover'
/>
) : (
<div className='flex h-full w-full items-center justify-center rounded-full bg-blue-100 text-4xl font-medium text-blue-200 md:text-5xl'>
{user?.name?.charAt(0) ?? ''}
</div>
)}
<div className='absolute inset-0 hidden items-center justify-center rounded-full bg-black/30 group-hover:flex'>
<Icon name='plus' size={28} className='text-white' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ export default function Page() {
deleteMember(member.userId, {
onSuccess: () => {
setKickedIds((prev) => [...prev, member.userId]);
toast.success(`${member.name}님을 강퇴했습니다.`);
toast.success(`${member.name}님을 삭제했습니다.`);
resolve();
},
onError: (error: Error) => {
toast.error(error.message || '멤버 강퇴에 실패했습니다.');
toast.error(error.message || '멤버 삭제에 실패했습니다.');
reject(error);
},
});
Expand Down
37 changes: 35 additions & 2 deletions src/app/(before-login)/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,39 @@ interface LoginErrors {

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function generateState() {
return Math.random().toString(36).substring(2);
}

function handleGoogleLogin() {
const state = generateState();
sessionStorage.setItem('oauth_state', state);

const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
redirect_uri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI!,
response_type: 'code',
scope: 'openid email profile',
state,
});

window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}

function handleKakaoLogin() {
const state = generateState();
sessionStorage.setItem('oauth_state', state);

const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY!,
redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!,
response_type: 'code',
state,
});

window.location.href = `https://kauth.kakao.com/oauth/authorize?${params}`;
}

export default function Page() {
const [form, setForm] = useState<LoginForm>({ email: '', password: '' });
const [errors, setErrors] = useState<LoginErrors>({});
Expand Down Expand Up @@ -131,15 +164,15 @@ export default function Page() {
<Button
className='text-black-200 w-full border border-gray-300 bg-white font-medium md:h-12.5 md:text-lg'
variant='secondary'
onClick={() => signIn('google', { callbackUrl: '/workspace' })}
onClick={handleGoogleLogin}
>
<Icon name='google' className='h-5 w-5 md:h-6 md:w-6' />
Google로 시작하기
</Button>
<Button
className='text-black-200 w-full border border-gray-300 bg-white font-medium md:h-12.5 md:text-lg'
variant='secondary'
onClick={() => signIn('kakao', { callbackUrl: '/workspace' })}
onClick={handleKakaoLogin}
>
<Icon name='kakao' className='h-5 w-5 md:h-6 md:w-6' />
Kakao로 시작하기
Expand Down
26 changes: 25 additions & 1 deletion src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { signinApi } from '@/lib/auth-api';
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
id: 'credentials',
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
Expand Down Expand Up @@ -33,14 +34,37 @@ export const authOptions: NextAuthOptions = {
}
},
}),

CredentialsProvider({
id: 'social',
name: 'Social',
credentials: {
accessToken: { label: 'Access Token', type: 'text' },
userId: { label: 'User ID', type: 'text' },
email: { label: 'Email', type: 'text' },
name: { label: 'Name', type: 'text' },
picture: { label: 'Picture', type: 'text' },
},
async authorize(credentials) {
if (!credentials?.accessToken) return null;

return {
id: credentials.userId ?? '',
email: credentials.email ?? '',
name: credentials.name ?? '',
picture: credentials.picture ?? null,
accessToken: credentials.accessToken,
};
},
}),
],

callbacks: {
async jwt({ token, user }) {
if (user) {
token.accessToken = user.accessToken;
token.userId = user.id;
token.picture = null;
token.picture = user.picture ?? null;
}
return token;
},
Expand Down
81 changes: 81 additions & 0 deletions src/app/oauth/google/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { Suspense, useEffect, useRef } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Icon from '@/components/Icon';
import { socialLoginApi } from '@/lib/auth-api';

function GoogleCallbackInner() {
const searchParams = useSearchParams();
const router = useRouter();
const called = useRef(false);

useEffect(() => {
if (called.current) return;
called.current = true;

const code = searchParams.get('code');
const state = searchParams.get('state');
const savedState = sessionStorage.getItem('oauth_state');

if (!code || !state || state !== savedState) {
router.replace('/login');
return;
}

sessionStorage.removeItem('oauth_state');

(async () => {
try {
const res = await socialLoginApi('google', {
state,
redirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI!,
token: code,
});

const { accessToken, user } = res.data;

const result = await signIn('social', {
accessToken,
userId: String(user.id),
email: user.email,
name: user.nickname,
picture: user.image ?? '',
redirect: false,
});

if (result?.error) {
router.replace('/login');
return;
}

router.replace('/workspace');
} catch {
router.replace('/login');
}
})();
}, [searchParams, router]);

return (
<div className='flex min-h-dvh items-center justify-center bg-gray-100'>
<div className='flex flex-col items-center justify-center gap-8'>
<div className='relative'>
<div className='relative z-10 flex aspect-square w-20 items-center justify-center rounded-full bg-white shadow-md'>
<Icon name='google' className='h-10 w-10' />
</div>
<span className='absolute top-1/2 left-1/2 h-[80%] w-[80%] -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-blue-100 opacity-75' />
</div>
<p className='text-center text-gray-400'>Google 로그인 중</p>
</div>
</div>
);
}

export default function GoogleCallbackPage() {
return (
<Suspense>
<GoogleCallbackInner />
</Suspense>
);
}
81 changes: 81 additions & 0 deletions src/app/oauth/kakao/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { Suspense, useEffect, useRef } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Icon from '@/components/Icon';
import { socialLoginApi } from '@/lib/auth-api';

function KakaoCallbackInner() {
const searchParams = useSearchParams();
const router = useRouter();
const called = useRef(false);

useEffect(() => {
if (called.current) return;
called.current = true;

const code = searchParams.get('code');
const state = searchParams.get('state');
const savedState = sessionStorage.getItem('oauth_state');

if (!code || !state || state !== savedState) {
router.replace('/login');
return;
}

sessionStorage.removeItem('oauth_state');

(async () => {
try {
const res = await socialLoginApi('kakao', {
state,
redirectUri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!,
token: code,
});

const { accessToken, user } = res.data;

const result = await signIn('social', {
accessToken,
userId: String(user.id),
email: user.email,
name: user.nickname,
picture: user.image ?? '',
redirect: false,
});

if (result?.error) {
router.replace('/login');
return;
}

router.replace('/workspace');
} catch {
router.replace('/login');
}
})();
}, [searchParams, router]);

return (
<div className='flex min-h-dvh items-center justify-center bg-gray-100'>
<div className='flex flex-col items-center justify-center gap-8'>
<div className='relative'>
<div className='relative z-10 flex aspect-square w-20 items-center justify-center rounded-full bg-white shadow-md'>
<Icon name='kakao' className='h-10 w-10' />
</div>
<span className='absolute top-1/2 left-1/2 h-[80%] w-[80%] -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-blue-100 opacity-75' />
</div>
<p className='text-center text-gray-400'>Kakao 로그인 중</p>
</div>
</div>
);
}

export default function KakaoCallbackPage() {
return (
<Suspense>
<KakaoCallbackInner />
</Suspense>
);
}
38 changes: 38 additions & 0 deletions src/lib/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,41 @@ export async function signinApi(body: SigninRequest): Promise<AuthResponse> {

return data;
}

export interface SocialLoginRequest {
state: string;
redirectUri: string;
token: string;
}

export interface SocialAuthResponse {
success: boolean;
data: {
accessToken: string;
user: {
id: number;
email: string;
nickname: string;
image: string | null;
};
};
}

export async function socialLoginApi(
provider: 'google' | 'kakao',
body: SocialLoginRequest,
): Promise<SocialAuthResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/social/login/${provider}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

const data: SocialAuthResponse = await res.json();

if (!res.ok || !data.success) {
throw new ApiError(res.status, '소셜 로그인에 실패했습니다.');
}

return data;
}
2 changes: 1 addition & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ export async function middleware(req: NextRequest) {
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|oauth).*)'],
};
1 change: 1 addition & 0 deletions src/types/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ declare module 'next-auth/jwt' {
interface JWT {
accessToken: string;
userId: string;
picture?: string | null;
}
}
Loading