From db58e945409e044d1f55fd16fd861c969043d0df Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 1 Sep 2026 12:00:07 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend:=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=84=EC=A0=95=EC=9D=B4=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EB=8F=BC=20=EC=9E=88=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=EB=B6=99?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9b4fe40 이 개발 전용 자동 로그인을 지우면서 빌더 2단계가 막다른 길이 됐다. 로그인한 적도 없는 사람에게 "로그인이 만료되었습니다" 라고 쓰고, 그 화면에는 로그인으로 갈 링크가 없다. 실측(킹서버): 계정이 DB 에 0개라 아무도 통과 못 했다. - lib/autoSession.ts: 옛 devSession 을 되살리되 `import.meta.env.DEV` 게이트를 뺐다. 운영 번들에서도 돌아야 한다 — 대신 VITE_AUTO_LOGIN_ID·PW 가 **둘 다** 있을 때만 움직이고 기본값은 없다. 진행 중인 로그인을 하나의 약속으로 공유하는 구조는 그대로 가져왔다: 훅 안에만 두면 로그인 전에 누른 검색이 토큰 없이 나가 '만료'로 떨어진다. - usePlaceSearch: 검색 전에 그 약속을 기다린다(경합을 만료로 오인하던 자리). - Dockerfile·compose: VITE_* 는 번들에 구워지므로 build args 다. 값을 바꾸면 `./deploy.sh solution-site` 로 다시 굽는다. ⚠️ 계정이 번들에 그대로 들어간다. 내부 테스트 호스트 전용이고, 사장님에게 열기 전에 AUTO_LOGIN_* 을 비우고 재빌드해야 한다. tsc --noEmit · eslint 통과 --- .env.example | 7 +++ docker-compose.yml | 5 ++ nginx/Dockerfile | 8 ++- solution/frontend/src/app/router.tsx | 3 +- .../src/features/onboarding/usePlaceSearch.ts | 5 ++ solution/frontend/src/hooks/useAutoLogin.ts | 14 ++++++ solution/frontend/src/lib/autoSession.ts | 49 +++++++++++++++++++ solution/frontend/src/pages/BuilderPage.tsx | 4 +- solution/frontend/src/vite-env.d.ts | 3 ++ 9 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 solution/frontend/src/hooks/useAutoLogin.ts create mode 100644 solution/frontend/src/lib/autoSession.ts diff --git a/.env.example b/.env.example index 07efa03..f105000 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,10 @@ AZURE_STORAGE_PREFIX= # API_PORT=9800 # 사장님 API # ADMIN_PORT=3002 # 내부 화면 (bind 127.0.0.1) # ADMIN_API_PORT_PUBLIC=9801 # 내부 API (bind 127.0.0.1) + +# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다. +# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 — +# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts). +# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site +AUTO_LOGIN_ID= +AUTO_LOGIN_PW= diff --git a/docker-compose.yml b/docker-compose.yml index e49cdd5..55824d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,6 +188,8 @@ services: # 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다. VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} + VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} + VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} volumes: - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json @@ -254,6 +256,9 @@ services: VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} + # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. + VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} + VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} image: o2o-web4ai-solution-site container_name: o2o-web4ai-solution-site volumes: diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 08a7b8f..bb0b19a 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -27,9 +27,15 @@ COPY admin ./admin ARG VITE_API_BASE_URL ARG VITE_PUBLISH_HOST ARG VITE_SITE_PREVIEW_URL +# ⚠️ 자동 로그인 계정. **번들에 그대로 구워져** 페이지를 연 사람이 JS 에서 읽을 수 있다 — +# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession). +ARG VITE_AUTO_LOGIN_ID +ARG VITE_AUTO_LOGIN_PW ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ - VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL + VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ + VITE_AUTO_LOGIN_ID=$VITE_AUTO_LOGIN_ID \ + VITE_AUTO_LOGIN_PW=$VITE_AUTO_LOGIN_PW RUN npm run build -w @o2o/frontend FROM nginx:alpine diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index cbba7f3..b2206fb 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -17,7 +17,8 @@ export const router = createBrowserRouter([ * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 * 만들어 보기도 전에 막힌다. * - * 대신 이미 로그인돼 있으면 그대로 쓰고, 아니면 서버가 필요한 순간(2단계 검색)에만 알린다. + * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이 + * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다. * * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조. */ diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 37549e2..b9fd3cd 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -12,6 +12,7 @@ import { verifyPlaceByUrl, } from '@/api'; import type {ConfirmedIdentity} from '@/stores/builder'; +import {ensureAutoSession} from '@/lib/autoSession'; import {notify, notifyApiError} from '@/lib/notify'; /** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */ @@ -127,6 +128,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | const controller = new AbortController(); inflight.current = controller; + // ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자 + // 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다. + await ensureAutoSession(); + if (!getAccessToken()) { setState({ ...INITIAL, diff --git a/solution/frontend/src/hooks/useAutoLogin.ts b/solution/frontend/src/hooks/useAutoLogin.ts new file mode 100644 index 0000000..2830f5d --- /dev/null +++ b/solution/frontend/src/hooks/useAutoLogin.ts @@ -0,0 +1,14 @@ +import {useEffect} from 'react'; +import {ensureAutoSession} from '@/lib/autoSession'; + +/** + * 화면이 열리자마자 세션을 확보한다. + * + * 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을 + * 기다려야 하기 때문에, 훅 바깥에 두고 공유한다. + */ +export function useAutoLogin() { + useEffect(() => { + void ensureAutoSession(); + }, []); +} diff --git a/solution/frontend/src/lib/autoSession.ts b/solution/frontend/src/lib/autoSession.ts new file mode 100644 index 0000000..ca1e12d --- /dev/null +++ b/solution/frontend/src/lib/autoSession.ts @@ -0,0 +1,49 @@ +import {getAccessToken, login, me} from '@/api'; +import {UserRole} from '@/api'; +import {toAuthUser, useAuthStore} from '@/stores/auth'; + +/** + * 계정이 주입돼 있으면 화면을 열 때 조용히 세션을 확보한다. + * + * ★ 왜 훅이 아니라 모듈 싱글턴인가: 로그인은 비동기인데, 화면(2단계 검색)은 그것과 + * 상관없이 언제든 백엔드를 부를 수 있다. 훅 안에만 있으면 "로그인 끝나기 전에 누른 검색"이 + * 토큰 없이 나가 '로그인 만료' 화면으로 떨어진다 — 실제로는 만료가 아니라 경합이다. + * 그래서 진행 중인 로그인을 **하나의 약속으로 공유**하고, 서버를 부르는 쪽이 그걸 기다린다. + * + * ★ 계정이 없으면 즉시 끝난다. 켜는 유일한 방법은 VITE_AUTO_LOGIN_ID·PW 를 주는 것이고, + * 기본값은 없다. + * + * ⚠️ 이 값은 **번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽을 수 있다 — + * 내부 테스트 호스트에서만 켜고, 사장님에게 여는 순간 반드시 빼야 한다. + */ +let pending: Promise | null = null; + +export function ensureAutoSession(): Promise { + if (getAccessToken()) return Promise.resolve(); + if (pending) return pending; + + const id = import.meta.env.VITE_AUTO_LOGIN_ID; + const password = import.meta.env.VITE_AUTO_LOGIN_PW; + if (!id || !password) return Promise.resolve(); + + pending = (async () => { + try { + const res = await login({id, password}); + if (!res.access_token || !res.refresh_token) return; + const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; + + // 순서가 중요하다. signIn 이 토큰을 저장하고, 그 토큰으로 me() 가 나간다 — + // 먼저 me() 를 부르면 토큰이 아직 없어 401 로 떨어진다(로그인 화면과 같은 순서다). + useAuthStore.getState().signIn(tokens, {userId: '', id, role: UserRole.USER}); + const meRes = await me(); + if (meRes.user_id && meRes.id) useAuthStore.getState().signIn(tokens, toAuthUser(meRes)); + } catch { + // 편의 기능이다 — 실패해도 화면을 막지 않는다(2단계가 안내를 띄운다). + } finally { + // 실패했으면 다음 시도에서 다시 붙을 수 있게 비운다. + if (!getAccessToken()) pending = null; + } + })(); + + return pending; +} diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 81821f8..1146e6f 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -11,6 +11,7 @@ import { Step5Generating, } from '@/features/onboarding'; import {EditorLayout} from '@/features/builder'; +import {useAutoLogin} from '@/hooks/useAutoLogin'; import {usePlaceSync} from '@/hooks/usePlaceSync'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; @@ -31,6 +32,7 @@ function siteUrl(domain: string | null | undefined): string | null { } export function BuilderPage() { + useAutoLogin(); /** * 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=`. * @@ -39,8 +41,6 @@ export function BuilderPage() { * 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다. * placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다. */ - // 개발 서버에서는 세션을 조용히 확보한다 — 위저드 앞에 로그인 화면을 세우지 않기 위해서다. - const [searchParams, setSearchParams] = useSearchParams(); const urlPlaceId = searchParams.get('placeId'); diff --git a/solution/frontend/src/vite-env.d.ts b/solution/frontend/src/vite-env.d.ts index f9f092a..57f3747 100644 --- a/solution/frontend/src/vite-env.d.ts +++ b/solution/frontend/src/vite-env.d.ts @@ -4,6 +4,9 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; readonly VITE_PUBLISH_HOST?: string; readonly VITE_SITE_PREVIEW_URL?: string; + /** ⚠️ 번들에 구워진다 — 내부 테스트 호스트에서만 채운다(lib/autoSession). */ + readonly VITE_AUTO_LOGIN_ID?: string; + readonly VITE_AUTO_LOGIN_PW?: string; } interface ImportMeta {