page.tsx 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. 'use client';
  2. import { useEffect, useRef, useState } from 'react';
  3. import { useRouter, useSearchParams } from 'next/navigation';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import { SnsConnectResponse } from '@/types/sns';
  6. import './style.scss';
  7. const STATE_STORAGE_KEY = 'antooza-sns-google-state';
  8. /**
  9. * Google OAuth Authorization Code 콜백.
  10. *
  11. * 1. URL ?code= ?state= ?error= 수신
  12. * 2. state 검증 (sessionStorage 저장값과 비교)
  13. * 3. POST /api/mypage/sns/google/connect (code, redirectUri)
  14. * 4. 성공 → /sns-link?connected=google
  15. * 5. 실패 → /sns-link?error={message}
  16. */
  17. export default function GoogleSnsCallbackPage()
  18. {
  19. const router = useRouter();
  20. const searchParams = useSearchParams();
  21. const [phase, setPhase] = useState<'verifying' | 'success' | 'error'>('verifying');
  22. const [errorMessage, setErrorMessage] = useState<string|null>(null);
  23. const handledRef = useRef(false);
  24. useEffect(() => {
  25. // React StrictMode 등에서 effect 가 두 번 호출돼 code 가 재사용될 수 있음 → ref 가드
  26. if (handledRef.current) {
  27. return;
  28. }
  29. handledRef.current = true;
  30. const code = searchParams.get('code');
  31. const state = searchParams.get('state');
  32. const oauthError = searchParams.get('error');
  33. const finishWithError = (message: string) => {
  34. setErrorMessage(message);
  35. setPhase('error');
  36. window.setTimeout(() => {
  37. router.replace(`/sns-link?error=${encodeURIComponent(message)}`);
  38. }, 1200);
  39. };
  40. if (oauthError) {
  41. finishWithError(oauthError);
  42. return;
  43. }
  44. if (!code) {
  45. finishWithError('Authorization code 가 없습니다.');
  46. return;
  47. }
  48. // state 검증
  49. let savedState: string|null = null;
  50. try {
  51. savedState = window.sessionStorage.getItem(STATE_STORAGE_KEY);
  52. window.sessionStorage.removeItem(STATE_STORAGE_KEY);
  53. } catch {
  54. // silent
  55. }
  56. if (!state || !savedState || state !== savedState) {
  57. finishWithError('state 가 일치하지 않습니다. 다시 시도해주세요.');
  58. return;
  59. }
  60. const redirectUri = `${window.location.origin}/auth/sns/google/callback`;
  61. (async () => {
  62. try {
  63. const res = await fetchApi<SnsConnectResponse>('/api/mypage/sns/google/connect', {
  64. method: 'POST',
  65. body: { code, redirectUri },
  66. silent: true
  67. });
  68. if (res.success) {
  69. setPhase('success');
  70. window.setTimeout(() => {
  71. router.replace('/sns-link?connected=google');
  72. }, 600);
  73. } else {
  74. finishWithError(res.message || '연동에 실패했습니다.');
  75. }
  76. } catch {
  77. finishWithError('네트워크 오류로 연동에 실패했습니다.');
  78. }
  79. })();
  80. }, [router, searchParams]);
  81. return (
  82. <div className="sns-callback">
  83. <div className="sns-callback__panel" role="status" aria-live="polite">
  84. {phase === 'verifying' && (
  85. <>
  86. <div className="sns-callback__spinner" aria-hidden="true" />
  87. <p className="sns-callback__title">Google 계정 연동 처리 중...</p>
  88. <p className="sns-callback__desc">잠시만 기다려주세요.</p>
  89. </>
  90. )}
  91. {phase === 'success' && (
  92. <>
  93. <p className="sns-callback__title">연동이 완료되었습니다!</p>
  94. <p className="sns-callback__desc">SNS 연결 관리 페이지로 이동합니다.</p>
  95. </>
  96. )}
  97. {phase === 'error' && (
  98. <>
  99. <p className="sns-callback__title">연동에 실패했습니다</p>
  100. <p className="sns-callback__desc">{errorMessage}</p>
  101. </>
  102. )}
  103. </div>
  104. </div>
  105. );
  106. }