page.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. 'use client';
  2. import './style.scss';
  3. import Link from 'next/link';
  4. import { Checkbox } from '@/components/ui/checkbox';
  5. import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
  6. import { useState, useEffect, useRef } from 'react';
  7. import { useSearchParams } from 'next/navigation';
  8. import { GoogleLogin } from '@react-oauth/google';
  9. import { fetchApi } from '@/lib/utils/client';
  10. import { LoginRequest } from '@/types/request/auth';
  11. import { LoginResponse } from '@/types/response/auth';
  12. import useAuth from '@/hooks/useAuth';
  13. export default function Page()
  14. {
  15. const { login } = useAuth();
  16. const searchParams = useSearchParams();
  17. const [error, setError] = useState<string>('');
  18. const [loading, setLoading] = useState<boolean>(false);
  19. const [email, setEmail] = useState<string>('');
  20. const [password, setPassword] = useState<string>('');
  21. const [rememberMe, setRememberMe] = useState<boolean>(false);
  22. const emailRef = useRef<HTMLInputElement>(null);
  23. const passwordRef = useRef<HTMLInputElement>(null);
  24. const googleBtnRef = useRef<HTMLDivElement>(null);
  25. const [googleBtnWidth, setGoogleBtnWidth] = useState<number>(0);
  26. useEffect(() => {
  27. if (error) {
  28. alert(error);
  29. setError('');
  30. }
  31. }, [error]);
  32. // URL ?error=... 처리 — Google OAuth callback 등 외부에서 리다이렉트된 에러 메시지 표시
  33. useEffect(() => {
  34. const urlError = searchParams.get('error');
  35. if (urlError) {
  36. setError(urlError);
  37. // 뒤로가기/새로고침 시 alert 중복 방지를 위해 query 정리
  38. const url = new URL(window.location.href);
  39. url.searchParams.delete('error');
  40. window.history.replaceState({}, '', url.toString());
  41. }
  42. }, [searchParams]);
  43. useEffect(() => {
  44. if (googleBtnRef.current) {
  45. const observer = new ResizeObserver(entries => {
  46. for (const entry of entries) {
  47. setGoogleBtnWidth(Math.floor(entry.contentRect.width));
  48. }
  49. });
  50. observer.observe(googleBtnRef.current);
  51. return () => observer.disconnect();
  52. }
  53. }, []);
  54. // 로그인 검증
  55. const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  56. e.preventDefault();
  57. try {
  58. if (email.length < 1) {
  59. emailRef.current?.focus();
  60. throw new Error('이메일을 입력하세요.');
  61. }
  62. if (password.length < 1) {
  63. passwordRef.current?.focus();
  64. throw new Error('비밀번호를 입력하세요.');
  65. }
  66. await fetchApi<LoginResponse>('/api/auth/login', {
  67. method: 'POST',
  68. body: { Email: email, Password: password } as LoginRequest
  69. });
  70. login(rememberMe);
  71. } catch (err) {
  72. if (err instanceof Error) {
  73. setError(err.message);
  74. }
  75. } finally {
  76. setLoading(false);
  77. }
  78. }
  79. // 구글 로그인
  80. const handleGoogleLogin = async (credentialResponse: { credential?: string }) => {
  81. try {
  82. await fetchApi<LoginResponse>('/api/auth/google-login', {
  83. method: 'POST',
  84. body: {
  85. credential: credentialResponse.credential
  86. }
  87. });
  88. login(rememberMe);
  89. } catch (err) {
  90. if (err instanceof Error) {
  91. setError(err.message);
  92. }
  93. }
  94. };
  95. const handleGoogleLoginFailed = () => {
  96. setError('Google 로그인에 실패했습니다.');
  97. };
  98. return (
  99. <>
  100. <div id="loginForm" className="row-start-2 flex flex-col sm:flex-row gap-2">
  101. <fieldset className="grow min-w-0 sm:basis-1/2">
  102. <legend>로그인</legend>
  103. <form method="post" acceptCharset="utf-8" autoComplete="off" className="grid min-w-0 p-4" onSubmit={handleSubmit}>
  104. <label htmlFor="email">이메일</label>
  105. <input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" autoFocus />
  106. <label htmlFor="password">비밀번호</label>
  107. <input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
  108. <button type="submit" className="btn btn-primary" disabled={loading}>
  109. {loading ? "로그인 중..." : "로그인"}
  110. </button>
  111. <div ref={googleBtnRef} className='w-full mt-2 pb-1'>
  112. {googleBtnWidth > 0 && (
  113. <GoogleLogin
  114. onSuccess={handleGoogleLogin}
  115. onError={handleGoogleLoginFailed}
  116. width={googleBtnWidth}
  117. size="large"
  118. shape="rectangular"
  119. context="signin"
  120. ux_mode="redirect"
  121. login_uri={`${window.location.origin}/login/google/callback`}
  122. logo_alignment="center"
  123. />
  124. )}
  125. </div>
  126. {/* Naver/Kakao 소셜 로그인 — start 라우트가 state 생성 후 각사 authorize 로 리다이렉트.
  127. route handler 라 Link prefetch 시 OAuth 시작이 실행되므로 일반 <a> 사용 */}
  128. <div className="social-login" role="group" aria-label="소셜 로그인">
  129. <a href="/login/naver/start" className="social-login__button social-login__button--naver">
  130. <span className="social-login__icon social-login__icon--naver" aria-hidden="true">N</span>
  131. <span>네이버 로그인</span>
  132. </a>
  133. <a href="/login/kakao/start" className="social-login__button social-login__button--kakao">
  134. <svg className="social-login__icon social-login__icon--kakao" viewBox="0 0 24 24" aria-hidden="true">
  135. <path fill="currentColor" d="M12 3C6.48 3 2 6.54 2 10.9c0 2.8 1.86 5.25 4.64 6.65-.2.75-.74 2.72-.85 3.14-.13.53.2.52.41.38.17-.11 2.65-1.8 3.72-2.53.67.1 1.37.15 2.08.15 5.52 0 10-3.53 10-7.89S17.52 3 12 3z"/>
  136. </svg>
  137. <span>카카오 로그인</span>
  138. </a>
  139. </div>
  140. </form>
  141. <hr hidden/>
  142. </fieldset>
  143. <fieldset className="grow min-w-0 sm:basis-1/2">
  144. <dl>
  145. <dt>아직 회원이 아니신가요?</dt>
  146. <dd>회원가입 한번으로 커뮤니티에 참여하세요!</dd>
  147. <dd>
  148. <Link href="/register">
  149. <small>></small> 회원가입
  150. </Link>
  151. </dd>
  152. </dl>
  153. <hr />
  154. <dl>
  155. <dt>비밀번호를 잊으셨나요?</dt>
  156. <dd>비밀번호를 깜박했다면 다시 설정할 수 있어요!</dd>
  157. <dd>
  158. <Link href="/forgot-password">
  159. <small>></small> 비밀번호 재설정
  160. </Link>
  161. </dd>
  162. </dl>
  163. <section className="mt-3">
  164. <FieldGroup>
  165. <Field orientation="horizontal">
  166. <Checkbox name="remember_me" id="rememberMe" checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
  167. <FieldLabel htmlFor="rememberMe">로그인 상태 유지</FieldLabel>
  168. </Field>
  169. </FieldGroup>
  170. </section>
  171. </fieldset>
  172. </div>
  173. </>
  174. );
  175. }