diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 42f415c..a82ccea 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -19,6 +19,24 @@ "로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이 없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다. +**관문이 두 개였다 — 문 앞 하나로 합쳤다** +같은 날 두 자리에서 같은 문제를 풀었다. main 은 **에디터 진입(6단계)** 에서 받았고 +(`EditorSignInGate`·`SignInForm`), 이쪽은 **`/builder` 문 앞**에서 받았다. 둘 다 두면 문 앞이 +먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드가 된다. 문 앞을 남긴 이유: + +- 에디터 관문을 쓰려면 **2단계가 토큰 없이 지나가야** 했고, 그래서 토큰이 없을 때 서버를 부르지 + 않고 입력값으로 신원을 세우는 우회로가 생겼다(`confirmManual`). 그건 이 레포의 단 하나의 + 규칙(**검증 전에는 수집·발행 금지**)을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을 + 다시" 다. 열어 둔 값이 공짜가 아니었다. +- 이제 가입이 그 자리에서 끝난다(가입 응답에 토큰이 실린다). 구글이면 클릭 두 번이다 — + 문 앞 로그인의 마찰이 "만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다. +- 관문이 하나면 로그인 폼도 하나다. `SignInForm` 이 경고하던 "`signIn → me` 순서를 두 벌로 + 들고 있다"는 `lib/session.establishSession` 한 곳으로 모았다. + +지운 것: `features/auth/EditorSignInGate`·`SignInForm`, `usePlaceSearch.confirmManual`, +Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `RequireAuth` 를 벗기고 +그 셋을 되살리면 된다(커밋 `969fb67`·`22b7623`). + **왜 가입까지 만들었나** 빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가 아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개** diff --git a/solution/frontend/src/features/auth/EditorSignInGate.tsx b/solution/frontend/src/features/auth/EditorSignInGate.tsx deleted file mode 100644 index c432851..0000000 --- a/solution/frontend/src/features/auth/EditorSignInGate.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * 에디터 앞의 로그인 관문. - * - * ★ 위저드(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 deleted file mode 100644 index b9d04de..0000000 --- a/solution/frontend/src/features/auth/SignInForm.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 로그인 폼 한 벌. - * - * ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서 - * (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/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx index 00f42d4..e75cb7b 100644 --- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx +++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import {useSearchParams} from 'react-router'; import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react'; -import {getAccessToken, type PlaceCandidate} from '@/api'; +import type {PlaceCandidate} from '@/api'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; @@ -83,14 +83,6 @@ export function Step2PlaceSearch() { const runSearch = () => { clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 setPickedIndex(null); - // ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데, - // 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다. - // 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다. - if (!getAccessToken()) { - confirmIdentity(search.confirmManual(storeName, location)); - goToStep(3); - return; - } void search.search(storeName, location); }; @@ -121,12 +113,6 @@ export function Step2PlaceSearch() { const pickByUrl = async () => { const url = placeUrl.trim(); if (!url) return; - // URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다. - if (!getAccessToken()) { - confirmIdentity(search.confirmManual(storeName, location)); - goToStep(3); - return; - } const identity = await search.confirmByUrl(url); if (!identity) return; confirmIdentity(identity); diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 609cd07..3262e62 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -132,10 +132,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | // 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다. await ensureAutoSession(); - // ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다. - // 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다. + // ★ /builder 가 RequireAuth 뒤라 여기까지 왔으면 토큰이 있어야 한다. 없다면 위저드를 + // 걷는 도중에 세션이 끊긴 것이다 — 그건 실제로 '만료' 이므로 그렇게 말한다. if (!getAccessToken()) { - setState({...INITIAL}); + setState({ + ...INITIAL, + phase: 'unavailable', + unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.', + }); return; } @@ -266,15 +270,5 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | * ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을 * 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다. */ - const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => { - return { - placeId: placeIdRef.current, - name: name.trim(), - address: address.trim(), - origin: 'owner', - sourceLabel: '직접 입력', - }; - }, []); - - return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset}; + return {...state, isConfirming, search, confirm, confirmByUrl, reset}; } diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index b487b07..1f1d4b0 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -2,9 +2,7 @@ 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, @@ -14,7 +12,6 @@ import { } from '@/features/onboarding'; import {EditorLayout} from '@/features/builder'; import {usePlaceSync} from '@/hooks/usePlaceSync'; -import {useAuthStore} from '@/stores/auth'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ @@ -83,9 +80,6 @@ export function BuilderPage() { 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); @@ -122,11 +116,6 @@ export function BuilderPage() { ); } - // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다. - if (step === EDITOR_STEP && !isSignedIn) { - return goToStep(4)} />; - } - if (step === EDITOR_STEP) { return (