diff --git a/public/images/auth/icon-github.svg b/public/images/auth/icon-github.svg new file mode 100644 index 0000000..4188a28 --- /dev/null +++ b/public/images/auth/icon-github.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/auth/icon-google.svg b/public/images/auth/icon-google.svg new file mode 100644 index 0000000..cfb057b --- /dev/null +++ b/public/images/auth/icon-google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/auth/login-symbol.png b/public/images/auth/login-symbol.png new file mode 100644 index 0000000..5a7fccc Binary files /dev/null and b/public/images/auth/login-symbol.png differ diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 0000000..84b46d8 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/shared/lib/server'; + +export async function GET(request: NextRequest) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get('code'); + const rawNext = searchParams.get('next') ?? '/workspaces'; + let next = '/workspaces'; + try { + const parsed = new URL(rawNext, origin); + if (parsed.origin === origin) next = parsed.pathname + parsed.search; + } catch { + // invalid URL, keep default + } + + if (code) { + const supabase = await createClient(); + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + + if (!error && data.user) { + if (!data.user.email) { + return NextResponse.redirect(`${origin}/login?error=email_missing`); + } + const meta = data.user.user_metadata ?? {}; + const realName = + [meta.full_name, meta.name, meta.user_name].find((n) => n && n !== '-') ?? + data.user.email.split('@')[0]; + + const { error: upsertError } = await supabase.from('profiles').upsert( + { + id: data.user.id, + email: data.user.email, + real_name: realName, + avatar_url: meta.avatar_url ?? null, + }, + { onConflict: 'id', ignoreDuplicates: true }, + ); + + if (upsertError) { + return NextResponse.redirect(`${origin}/login?error=auth_failed`); + } + + return NextResponse.redirect(`${origin}${next}`); + } + } + + return NextResponse.redirect(`${origin}/login?error=auth_failed`); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..225c5e3 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { LoginView } from '@/views/login'; + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..7b542f7 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,5 @@ +import { SignupView } from '@/views/signup'; + +export default function SignupPage() { + return ; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b54c291 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,10 @@ +import { updateSession } from '@/shared/lib/middleware'; +import { type NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + return await updateSession(request); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'], +}; diff --git a/src/shared/lib/middleware.ts b/src/shared/lib/middleware.ts index 2a14462..e907527 100644 --- a/src/shared/lib/middleware.ts +++ b/src/shared/lib/middleware.ts @@ -1,6 +1,9 @@ import { createServerClient } from '@supabase/ssr'; import { NextResponse, type NextRequest } from 'next/server'; +const PUBLIC_PATHS = ['/', '/login', '/signup', '/auth']; +const AUTH_PAGES = ['/login', '/signup']; + export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ request, @@ -38,14 +41,20 @@ export async function updateSession(request: NextRequest) { const { data } = await supabase.auth.getClaims(); const user = data?.claims; - if ( - !user && - !request.nextUrl.pathname.startsWith('/login') && - !request.nextUrl.pathname.startsWith('/auth') - ) { - // no user, potentially respond by redirecting the user to the login page + const isPublic = PUBLIC_PATHS.some( + (p) => request.nextUrl.pathname === p || request.nextUrl.pathname.startsWith(p + '/'), + ); + + if (!user && !isPublic) { + const url = request.nextUrl.clone(); + url.pathname = '/login'; + url.search = new URLSearchParams({ redirect: request.nextUrl.pathname }).toString(); + return NextResponse.redirect(url); + } + + if (user && AUTH_PAGES.includes(request.nextUrl.pathname)) { const url = request.nextUrl.clone(); - url.pathname = '/auth/login'; + url.pathname = '/workspaces'; return NextResponse.redirect(url); } diff --git a/src/shared/lib/use-oauth-sign-in.ts b/src/shared/lib/use-oauth-sign-in.ts new file mode 100644 index 0000000..e66979a --- /dev/null +++ b/src/shared/lib/use-oauth-sign-in.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import { createClient } from '@/shared/lib/client'; + +const OAUTH_ERROR_MAP: Record = { + 'Email rate limit exceeded': '잠시 후 다시 시도해주세요', + 'too many requests': '잠시 후 다시 시도해주세요', + over_email_send_rate_limit: '잠시 후 다시 시도해주세요', +}; + +function toKoreanOAuthError(message: string): string { + const matched = Object.entries(OAUTH_ERROR_MAP).find(([key]) => + message.toLowerCase().includes(key.toLowerCase()), + ); + return matched ? matched[1] : '소셜 로그인 중 오류가 발생했어요. 다시 시도해주세요'; +} + +export function useOAuthSignIn() { + const supabase = createClient(); + const [oauthError, setOauthError] = useState(null); + const [oauthLoading, setOauthLoading] = useState(false); + + const signInWithOAuth = async (provider: 'google' | 'github') => { + setOauthError(null); + setOauthLoading(true); + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: `${window.location.origin}/auth/callback` }, + }); + if (error) { + setOauthError(toKoreanOAuthError(error.message)); + setOauthLoading(false); + } + }; + + return { signInWithOAuth, oauthError, oauthLoading }; +} diff --git a/src/views/login/index.ts b/src/views/login/index.ts new file mode 100644 index 0000000..6942ed1 --- /dev/null +++ b/src/views/login/index.ts @@ -0,0 +1 @@ +export { default as LoginView } from './ui/LoginView'; diff --git a/src/views/login/ui/LoginView.tsx b/src/views/login/ui/LoginView.tsx new file mode 100644 index 0000000..9a72308 --- /dev/null +++ b/src/views/login/ui/LoginView.tsx @@ -0,0 +1,219 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import { Eye, EyeOff, Loader2 } from 'lucide-react'; + +import { createClient } from '@/shared/lib/client'; +import { useOAuthSignIn } from '@/shared/lib/use-oauth-sign-in'; +import { Input } from '@/shared/ui/input'; + +const AUTH_ERROR_MAP: Record = { + 'Invalid login credentials': '이메일 또는 비밀번호가 올바르지 않아요', + 'Email not confirmed': '이메일 인증이 필요해요. 메일함을 확인해주세요', + 'User already registered': '이미 가입된 이메일이에요', + 'Password should be at least 6 characters': '비밀번호는 6자 이상으로 입력해주세요', + 'Unable to validate email address: invalid format': '올바른 이메일 형식이 아니에요', + 'signup requires a valid password': '비밀번호를 입력해주세요', + 'Email rate limit exceeded': '잠시 후 다시 시도해주세요', + over_email_send_rate_limit: '잠시 후 다시 시도해주세요', + 'too many requests': '잠시 후 다시 시도해주세요', +}; + +function toKoreanError(message: string): string { + const matched = Object.entries(AUTH_ERROR_MAP).find(([key]) => + message.toLowerCase().includes(key.toLowerCase()), + ); + return matched ? matched[1] : '로그인 중 오류가 발생했어요. 다시 시도해주세요'; +} + +const CALLBACK_ERROR_MAP: Record = { + auth_failed: '소셜 로그인에 실패했어요. 다시 시도해주세요', + session_exchange_failed: '인증 처리 중 오류가 발생했어요. 다시 시도해주세요', + email_missing: 'OAuth 계정에서 이메일 정보를 확인할 수 없어요. 다른 방법으로 로그인해 주세요', +}; + +const SAVED_EMAIL_KEY = 'syncly_saved_email'; + +export default function LoginView() { + const supabase = createClient(); + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackError = searchParams.get('error'); + const [email, setEmail] = useState(() => + typeof window !== 'undefined' ? (localStorage.getItem(SAVED_EMAIL_KEY) ?? '') : '', + ); + const [password, setPassword] = useState(''); + const [rememberEmail, setRememberEmail] = useState(() => + typeof window !== 'undefined' ? !!localStorage.getItem(SAVED_EMAIL_KEY) : false, + ); + const [emailError, setEmailError] = useState(null); + const [passwordError, setPasswordError] = useState(null); + const [error, setError] = useState( + callbackError ? (CALLBACK_ERROR_MAP[callbackError] ?? '로그인 중 오류가 발생했어요') : null, + ); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const { signInWithOAuth, oauthError, oauthLoading } = useOAuthSignIn(); + + const signInWithEmail = async () => { + setEmailError(null); + setPasswordError(null); + setError(null); + + if (!email) { + setEmailError('이메일을 입력해주세요'); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setEmailError('올바른 이메일 형식이 아니에요'); + return; + } + if (!password) { + setPasswordError('비밀번호를 입력해주세요'); + return; + } + + if (rememberEmail) { + localStorage.setItem(SAVED_EMAIL_KEY, email); + } else { + localStorage.removeItem(SAVED_EMAIL_KEY); + } + + setLoading(true); + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) { + setError(toKoreanError(error.message)); + } else { + const redirect = searchParams.get('redirect'); + router.push(redirect ?? '/workspaces'); + } + setLoading(false); + }; + + return ( +
+
+
+ Syncly +
+

Syncly에 오신 걸 환영해요

+

소규모 팀을 위한 가벼운 협업 공간

+
+
+ +
+ + + + + {oauthError &&

{oauthError}

} + +
+
+ 또는 +
+
+ +
{ + e.preventDefault(); + signInWithEmail(); + }} + noValidate + className="flex flex-col gap-3" + > +
+ { + setEmail(e.target.value); + setEmailError(null); + }} + className={`bg-brand-secondary text-brand-ink placeholder:text-brand-ink/50 h-11 rounded-[18px] border-2 px-4 text-sm transition-colors focus-visible:ring-0 ${emailError ? 'border-red-400 focus-visible:border-red-400' : 'focus-visible:border-brand border-transparent'}`} + /> + {emailError &&

{emailError}

} +
+
+
+ { + setPassword(e.target.value); + setPasswordError(null); + }} + className={`bg-brand-secondary text-brand-ink placeholder:text-brand-ink/50 h-11 w-full rounded-[18px] border-2 px-4 pr-11 text-sm transition-colors focus-visible:ring-0 ${passwordError ? 'border-red-400 focus-visible:border-red-400' : 'focus-visible:border-brand border-transparent'}`} + /> + +
+ {passwordError &&

{passwordError}

} +
+ + + + {error &&

{error}

} + +
+
+ +
+ + ← 홈으로 + +

+ 아직 회원이 아니신가요?{' '} + + 가입하기 + +

+
+
+
+ ); +} diff --git a/src/views/signup/index.ts b/src/views/signup/index.ts new file mode 100644 index 0000000..71261bc --- /dev/null +++ b/src/views/signup/index.ts @@ -0,0 +1 @@ +export { default as SignupView } from './ui/SignupView'; diff --git a/src/views/signup/ui/SignupView.tsx b/src/views/signup/ui/SignupView.tsx new file mode 100644 index 0000000..cf6abc3 --- /dev/null +++ b/src/views/signup/ui/SignupView.tsx @@ -0,0 +1,414 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +import { Eye, EyeOff, Loader2 } from 'lucide-react'; + +import { createClient } from '@/shared/lib/client'; +import { useOAuthSignIn } from '@/shared/lib/use-oauth-sign-in'; +import { Input } from '@/shared/ui/input'; + +const INPUT_CLASS = + 'h-11 rounded-[18px] border-2 border-transparent bg-brand-secondary px-4 text-sm text-brand-ink placeholder:text-brand-ink/50 transition-colors focus-visible:border-brand focus-visible:ring-0'; + +type Step = 'email' | 'otp' | 'info' | 'done'; + +const STEP_TITLE: Record = { + email: 'Syncly 시작하기', + otp: '인증번호 입력', + info: '사용자 정보 입력', + done: '가입이 완료됐어요', +}; + +const OTP_SECONDS = 180; + +const AUTH_ERROR_MAP: Record = { + 'User already registered': '이미 가입된 이메일이에요', + 'Password should be at least 6 characters': '비밀번호는 6자 이상으로 입력해주세요', + 'Unable to validate email address': '올바른 이메일 형식이 아니에요', + 'Token has expired or is invalid': '인증번호가 만료됐거나 올바르지 않아요', + 'Email rate limit exceeded': '잠시 후 다시 시도해주세요', + 'too many requests': '잠시 후 다시 시도해주세요', + over_email_send_rate_limit: '잠시 후 다시 시도해주세요', +}; + +function toKoreanError(message: string): string { + const matched = Object.entries(AUTH_ERROR_MAP).find(([key]) => + message.toLowerCase().includes(key.toLowerCase()), + ); + return matched ? matched[1] : '오류가 발생했어요. 다시 시도해주세요'; +} + +function formatTimer(seconds: number) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +export default function SignupView() { + const supabase = createClient(); + const router = useRouter(); + + const [step, setStep] = useState('email'); + const [email, setEmail] = useState(''); + const [otp, setOtp] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [timer, setTimer] = useState(OTP_SECONDS); + const [checkingSession, setCheckingSession] = useState(true); + + const timerRef = useRef | null>(null); + const { signInWithOAuth, oauthError, oauthLoading } = useOAuthSignIn(); + + useEffect(() => { + const checkSession = async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + setCheckingSession(false); + return; + } + const { data: profile } = await supabase + .from('profiles') + .select('real_name') + .eq('id', user.id) + .maybeSingle(); + if (profile?.real_name) { + router.push('/workspaces'); + } else { + setStep('info'); + setCheckingSession(false); + } + }; + checkSession(); + }, []); + + const startTimer = () => { + setTimer(OTP_SECONDS); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setTimer((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current!); + return 0; + } + return prev - 1; + }); + }, 1000); + }; + + useEffect( + () => () => { + if (timerRef.current) clearInterval(timerRef.current); + }, + [], + ); + + const handleEmailNext = async () => { + setError(null); + setLoading(true); + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { shouldCreateUser: true }, + }); + if (error) { + setError(toKoreanError(error.message)); + } else { + setStep('otp'); + startTimer(); + } + setLoading(false); + }; + + const handleOtpVerify = async () => { + setError(null); + setLoading(true); + const { error } = await supabase.auth.verifyOtp({ + email, + token: otp, + type: 'email', + }); + if (error) { + setError(toKoreanError(error.message)); + } else { + setStep('info'); + } + setLoading(false); + }; + + const handleResendOtp = async () => { + setError(null); + setLoading(true); + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { shouldCreateUser: true }, + }); + if (error) { + setError(toKoreanError(error.message)); + } else { + startTimer(); + } + setLoading(false); + }; + + const handleInfoSubmit = async () => { + setError(null); + const realName = name.trim(); + if (!realName) { + setError('이름을 입력해주세요'); + return; + } + setLoading(true); + const { error } = await supabase.auth.updateUser({ + password, + data: { full_name: realName }, + }); + if (error) { + setError(toKoreanError(error.message)); + } else { + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + setError('가입 정보를 확인하지 못했어요. 다시 시도해주세요'); + } else { + const { error: profileError } = await supabase.from('profiles').upsert({ + id: user.id, + email: user.email!, + real_name: realName, + avatar_url: null, + }); + if (profileError) { + setError('프로필 저장에 실패했어요. 다시 시도해주세요'); + } else { + setStep('done'); + } + } + } + setLoading(false); + }; + + if (checkingSession) return null; + + return ( +
+
+
+ Syncly +
+

{STEP_TITLE[step]}

+

+ {step === 'email' && '소규모 팀을 위한 가벼운 협업 공간'} + {step === 'otp' && ( + <> + {email}로
+ 인증번호를 보냈어요 + + )} + {step === 'info' && email} + {step === 'done' && '이제 워크스페이스를 만들어보세요'} +

+
+
+ + {step === 'email' && ( +
+
{ + e.preventDefault(); + handleEmailNext(); + }} + className="flex flex-col gap-3" + > + setEmail(e.target.value)} + required + className={INPUT_CLASS} + /> + {error &&

{error}

} + +
+ +
+
+ 또는 +
+
+ + + + {oauthError &&

{oauthError}

} +
+ )} + + {step === 'otp' && ( +
+
{ + e.preventDefault(); + handleOtpVerify(); + }} + noValidate + className="flex flex-col gap-3" + > +
+
+ setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))} + required + className={`${INPUT_CLASS} pr-16`} + /> + + {formatTimer(timer)} + +
+ +
+ {error &&

{error}

} + +
+
+ )} + + {step === 'info' && ( +
+
{ + e.preventDefault(); + handleInfoSubmit(); + }} + noValidate + className="flex flex-col gap-3" + > + setName(e.target.value)} + required + className={INPUT_CLASS} + /> +
+ setPassword(e.target.value)} + required + minLength={6} + className={`${INPUT_CLASS} pr-11`} + /> + +
+ {error &&

{error}

} + +
+
+ )} + + {step === 'done' && ( + + 시작하기 + + )} + + {step !== 'done' && ( +
+ {step === 'email' && ( + + ← 홈으로 + + )} + {(step === 'otp' || step === 'info') && ( + + )} + {step === 'email' && ( +

+ 이미 계정이 있으신가요?{' '} + + 로그인 + +

+ )} +
+ )} +
+
+ ); +} diff --git a/src/widgets/landing/landing-header/ui/LandingHeader.tsx b/src/widgets/landing/landing-header/ui/LandingHeader.tsx index 8e4ba37..ecf0192 100644 --- a/src/widgets/landing/landing-header/ui/LandingHeader.tsx +++ b/src/widgets/landing/landing-header/ui/LandingHeader.tsx @@ -44,7 +44,7 @@ export default function LandingHeader() { 로그인 무료로 시작하기