From 969fb6773cc45f6e651039b23d814d0d3e2410c0 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 09:03:17 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend:=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9D=84=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=EC=97=90=EC=84=9C=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위저드를 걷는 동안 '로그인이 만료되었습니다' 가 떴다. 만료가 아니라 **한 번도 로그인한 적이 없는 것**이었다(자동 로그인 계정이 없으면 ensureAutoSession 이 즉시 끝난다). 문구가 사실과 달라 고장으로 읽혔다. - features/auth/EditorSignInGate: 에디터에 들어갈 때만 로그인을 받는다. 위저드 1~5단계는 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. /login 으로 튕기지 않는 이유는 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없기 때문이다 - features/auth/SignInForm: 로그인 화면과 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서(signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다 - usePlaceSearch: 토큰이 없을 때의 문구에서 '만료' 를 걷어낸다. 검색은 서버가 토큰을 요구하므로 (place.py 전 엔드포인트가 IsValidAccessToken) 프론트가 없앨 수 있는 제약이 아니다 tsc·eslint·vite build 통과 --- .../src/features/auth/EditorSignInGate.tsx | 37 ++++++ .../frontend/src/features/auth/SignInForm.tsx | 104 +++++++++++++++ .../src/features/onboarding/usePlaceSearch.ts | 6 +- solution/frontend/src/pages/BuilderPage.tsx | 12 ++ solution/frontend/src/pages/LoginPage.tsx | 125 ++++-------------- 5 files changed, 183 insertions(+), 101 deletions(-) create mode 100644 solution/frontend/src/features/auth/EditorSignInGate.tsx create mode 100644 solution/frontend/src/features/auth/SignInForm.tsx diff --git a/solution/frontend/src/features/auth/EditorSignInGate.tsx b/solution/frontend/src/features/auth/EditorSignInGate.tsx new file mode 100644 index 0000000..c432851 --- /dev/null +++ b/solution/frontend/src/features/auth/EditorSignInGate.tsx @@ -0,0 +1,37 @@ +/** + * 에디터 앞의 로그인 관문. + * + * ★ 위저드(1~5단계)는 로그인을 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. + * 에디터부터는 편집한 것을 저장하고 발행해야 하는데 그게 전부 토큰을 쓴다. 토큰 없이 들여보내면 + * 저장이 조용히 실패하고 사장님은 발행하고 나서야 안다. + * ★ /login 으로 튕기지 않는다. 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없다. + */ +import {SignInForm} from './SignInForm'; + +export function EditorSignInGate({onBack}: {onBack: () => void}) { + return ( +
+ + Web4Ai +

편집을 시작하려면 로그인해 주세요

+

+ 여기까지 만든 내용은 그대로 있습니다. 편집한 것을 저장하고 발행하는 데 계정이 필요합니다. +

+
+ } + footer={ + + } + /> + + ); +} diff --git a/solution/frontend/src/features/auth/SignInForm.tsx b/solution/frontend/src/features/auth/SignInForm.tsx new file mode 100644 index 0000000..b9d04de --- /dev/null +++ b/solution/frontend/src/features/auth/SignInForm.tsx @@ -0,0 +1,104 @@ +/** + * 로그인 폼 한 벌. + * + * ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서 + * (signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다. + */ +import {useState, type FormEvent, type ReactNode} from 'react'; +import {LogIn} from 'lucide-react'; +import {login, me, UserRole} from '@/api'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {notifyApiError} from '@/lib/notify'; +import {toAuthUser, useAuthStore} from '@/stores/auth'; + +interface SignInFormProps { + /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ + header: ReactNode; + /** 폼 아래 각주(빌더로 돌아가기 등). */ + footer?: ReactNode; + submitLabel?: string; + onSignedIn?: () => void; +} + +export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) { + const signIn = useAuthStore((s) => s.signIn); + const [id, setId] = useState(''); + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + try { + const res = await login({id, password}); + if (res.result?.success === false) { + notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); + return; + } + // ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. + // 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. + if (!res.access_token || !res.refresh_token) { + notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); + return; + } + const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; + + // 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다. + signIn(tokens, {userId: '', id, role: UserRole.USER}); + + const meRes = await me(); + // 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다. + if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes)); + onSignedIn?.(); + } catch (error) { + notifyApiError(error, '로그인에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {header} + +
+
+ + setId(e.target.value)} + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + + + {footer} +
+ ); +} diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index b9fd3cd..ccabb25 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -132,11 +132,15 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | // 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다. await ensureAutoSession(); + // ★ 로그인 없이도 여기까지는 온다. 예전엔 '로그인이 만료되었습니다' 를 띄웠는데, + // 만료가 아니라 **아직 로그인한 적이 없는 것**이라 사장님에게는 고장으로 읽혔다. + // 지도 검색만 못 할 뿐이니 직접 입력으로 계속 가게 한다 — 로그인은 에디터에서 한 번 받는다. if (!getAccessToken()) { setState({ ...INITIAL, phase: 'unavailable', - unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.', + unavailableReason: + '지도 검색은 로그인한 뒤에 쓸 수 있습니다. 지금은 상호와 주소를 직접 넣고 계속 진행하세요.', }); return; } diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 1146e6f..e2d6c33 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -2,7 +2,9 @@ import {useEffect, useRef} from 'react'; import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react'; import {Link, useSearchParams} from 'react-router'; import {SiteStatus} from '@o2o/shared'; +import {getAccessToken} from '@/api'; import {AppShell} from '@/components/layout/AppShell'; +import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import { Step1Industry, Step2PlaceSearch, @@ -13,6 +15,7 @@ import { import {EditorLayout} from '@/features/builder'; import {useAutoLogin} from '@/hooks/useAutoLogin'; import {usePlaceSync} from '@/hooks/usePlaceSync'; +import {useAuthStore} from '@/stores/auth'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ @@ -81,7 +84,11 @@ export function BuilderPage() { const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace}); const step = useBuilderStore((s) => s.step); + const goToStep = useBuilderStore((s) => s.goToStep); const storeName = useBuilderStore((s) => s.storeName); + // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. + const authUser = useAuthStore((s) => s.user); + const isSignedIn = Boolean(authUser) || Boolean(getAccessToken()); // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. const wiredPlaceId = useBuilderStore((s) => s.placeId); @@ -118,6 +125,11 @@ export function BuilderPage() { ); } + // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다. + if (step === EDITOR_STEP && !isSignedIn) { + return goToStep(4)} />; + } + if (step === EDITOR_STEP) { return (
diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index 88a4183..4ff1a0f 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -1,117 +1,42 @@ -import {useState, type FormEvent} from 'react'; import {Navigate, useLocation, useNavigate} from 'react-router'; -import {LogIn} from 'lucide-react'; -import {login, me, UserRole} from '@/api'; -import {Button} from '@/components/ui/button'; -import {Input} from '@/components/ui/input'; -import {notifyApiError} from '@/lib/notify'; -import {toAuthUser, useAuthStore} from '@/stores/auth'; +import {SignInForm} from '@/features/auth/SignInForm'; +import {useAuthStore} from '@/stores/auth'; export function LoginPage() { const navigate = useNavigate(); const location = useLocation(); const user = useAuthStore((s) => s.user); - const signIn = useAuthStore((s) => s.signIn); - - const [id, setId] = useState(''); - const [password, setPassword] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) -// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. -const from = (location.state as {from?: string} | null)?.from ?? '/'; + // 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. + const from = (location.state as {from?: string} | null)?.from ?? '/'; if (user) return ; - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - setIsSubmitting(true); - try { - const res = await login({id, password}); - if (res.result?.success === false) { - notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); - return; - } - // ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. - // 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. - if (!res.access_token || !res.refresh_token) { - notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); - return; - } - const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; - - // 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다. - signIn(tokens, {userId: '', id, role: UserRole.USER}); - - const meRes = await me(); - // 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다. - if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes)); - navigate(from, {replace: true}); - } catch (error) { - notifyApiError(error, '로그인에 실패했습니다.'); - } finally { - setIsSubmitting(false); - } - }; - return (
-
-
- Web4Ai -

관리자

-

- 사업장·정보 확인·발행을 관리합니다. + navigate(from, {replace: true})} + header={ +

+ Web4Ai +

관리자

+

사업장·정보 확인·발행을 관리합니다.

+
+ } + footer={ +

+ 사이트는 로그인 없이 만들어 볼 수 있습니다. 로그인은 편집 화면에서 한 번만 필요합니다.{' '} + + 빌더로 이동 +

-
- -
-
- - setId(e.target.value)} - autoComplete="username" - required - /> -
-
- - setPassword(e.target.value)} - autoComplete="current-password" - required - /> -
-
- - - -

- 빌더는 로그인 없이도 사용할 수 있습니다.{' '} - - 빌더로 이동 - -

-
+ } + />
); }