| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- 'use client';
- import './style.scss';
- import Link from 'next/link';
- import { Checkbox } from '@/components/ui/checkbox';
- import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
- import { useState, useEffect, useRef } from 'react';
- import { useSearchParams } from 'next/navigation';
- import { GoogleLogin } from '@react-oauth/google';
- import { fetchApi } from '@/lib/utils/client';
- import { LoginRequest } from '@/types/request/auth';
- import { LoginResponse } from '@/types/response/auth';
- import useAuth from '@/hooks/useAuth';
- import { useConfigContext } from '@/contexts/configProvider';
- export default function Page()
- {
- const { login } = useAuth();
- const config = useConfigContext();
- // 소셜 로그인 on/off (Admin /Config/External) — 값이 없으면(구버전 config) 노출(fail-open), 백엔드가 최종 게이트
- const naverEnabled = config?.external?.naverLoginEnabled !== false;
- const kakaoEnabled = config?.external?.kakaoLoginEnabled !== false;
- const googleEnabled = config?.external?.googleLoginEnabled !== false;
- const searchParams = useSearchParams();
- const [error, setError] = useState<string>('');
- const [loading, setLoading] = useState<boolean>(false);
- const [email, setEmail] = useState<string>('');
- const [password, setPassword] = useState<string>('');
- const [rememberMe, setRememberMe] = useState<boolean>(false);
- const emailRef = useRef<HTMLInputElement>(null);
- const passwordRef = useRef<HTMLInputElement>(null);
- const googleBtnRef = useRef<HTMLDivElement>(null);
- const [googleBtnWidth, setGoogleBtnWidth] = useState<number>(0);
- useEffect(() => {
- if (error) {
- alert(error);
- setError('');
- }
- }, [error]);
- // URL ?error=... 처리 — Google OAuth callback 등 외부에서 리다이렉트된 에러 메시지 표시
- useEffect(() => {
- const urlError = searchParams.get('error');
- if (urlError) {
- setError(urlError);
- // 뒤로가기/새로고침 시 alert 중복 방지를 위해 query 정리
- const url = new URL(window.location.href);
- url.searchParams.delete('error');
- window.history.replaceState({}, '', url.toString());
- }
- }, [searchParams]);
- useEffect(() => {
- if (googleBtnRef.current) {
- const observer = new ResizeObserver(entries => {
- for (const entry of entries) {
- setGoogleBtnWidth(Math.floor(entry.contentRect.width));
- }
- });
- observer.observe(googleBtnRef.current);
- return () => observer.disconnect();
- }
- }, []);
- // 로그인 검증
- const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
- e.preventDefault();
- try {
- if (email.length < 1) {
- emailRef.current?.focus();
- throw new Error('이메일을 입력하세요.');
- }
- if (password.length < 1) {
- passwordRef.current?.focus();
- throw new Error('비밀번호를 입력하세요.');
- }
- await fetchApi<LoginResponse>('/api/auth/login', {
- method: 'POST',
- body: { Email: email, Password: password } as LoginRequest
- });
- login(rememberMe);
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message);
- }
- } finally {
- setLoading(false);
- }
- }
- // 구글 로그인
- const handleGoogleLogin = async (credentialResponse: { credential?: string }) => {
- try {
- await fetchApi<LoginResponse>('/api/auth/google-login', {
- method: 'POST',
- body: {
- credential: credentialResponse.credential
- }
- });
- login(rememberMe);
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message);
- }
- }
- };
- const handleGoogleLoginFailed = () => {
- setError('Google 로그인에 실패했습니다.');
- };
- return (
- <>
- <div id="loginForm" className="row-start-2 flex flex-col sm:flex-row gap-2">
- <fieldset className="grow min-w-0 sm:basis-1/2">
- <legend>로그인</legend>
- <form method="post" acceptCharset="utf-8" autoComplete="off" className="grid min-w-0 p-4" onSubmit={handleSubmit}>
- <label htmlFor="email">이메일</label>
- <input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" autoFocus />
- <label htmlFor="password">비밀번호</label>
- <input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
- <button type="submit" className="btn btn-primary" disabled={loading}>
- {loading ? "로그인 중..." : "로그인"}
- </button>
- {googleEnabled && (
- <div ref={googleBtnRef} className='w-full mt-2 pb-1'>
- {googleBtnWidth > 0 && (
- <GoogleLogin
- onSuccess={handleGoogleLogin}
- onError={handleGoogleLoginFailed}
- width={googleBtnWidth}
- size="large"
- shape="rectangular"
- context="signin"
- ux_mode="redirect"
- login_uri={`${window.location.origin}/login/google/callback`}
- logo_alignment="center"
- />
- )}
- </div>
- )}
- {/* Naver/Kakao 소셜 로그인 — start 라우트가 state 생성 후 각사 authorize 로 리다이렉트.
- route handler 라 Link prefetch 시 OAuth 시작이 실행되므로 일반 <a> 사용.
- 노출 여부는 Admin /Config/External 의 사용 체크박스(config.external.*LoginEnabled)로 제어 */}
- {(naverEnabled || kakaoEnabled) && (
- <div className="social-login" role="group" aria-label="소셜 로그인">
- {naverEnabled && (
- <a href="/login/naver/start" className="social-login__button social-login__button--naver">
- <span className="social-login__icon social-login__icon--naver" aria-hidden="true">N</span>
- <span>네이버 로그인</span>
- </a>
- )}
- {kakaoEnabled && (
- <a href="/login/kakao/start" className="social-login__button social-login__button--kakao">
- <svg className="social-login__icon social-login__icon--kakao" viewBox="0 0 24 24" aria-hidden="true">
- <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"/>
- </svg>
- <span>카카오 로그인</span>
- </a>
- )}
- </div>
- )}
- </form>
- <hr hidden/>
- </fieldset>
- <fieldset className="grow min-w-0 sm:basis-1/2">
- <dl>
- <dt>아직 회원이 아니신가요?</dt>
- <dd>회원가입 한번으로 커뮤니티에 참여하세요!</dd>
- <dd>
- <Link href="/register">
- <small>></small> 회원가입
- </Link>
- </dd>
- </dl>
- <hr />
- <dl>
- <dt>비밀번호를 잊으셨나요?</dt>
- <dd>비밀번호를 깜박했다면 다시 설정할 수 있어요!</dd>
- <dd>
- <Link href="/forgot-password">
- <small>></small> 비밀번호 재설정
- </Link>
- </dd>
- </dl>
- <section className="mt-3">
- <FieldGroup>
- <Field orientation="horizontal">
- <Checkbox name="remember_me" id="rememberMe" checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
- <FieldLabel htmlFor="rememberMe">로그인 상태 유지</FieldLabel>
- </Field>
- </FieldGroup>
- </section>
- </fieldset>
- </div>
- </>
- );
- }
|