[feat] solution/frontend: 자동 로그인 복원 — 계정이 주입돼 있을 때만 붙는다

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 통과
This commit is contained in:
Mina Choi 2026-09-01 12:00:07 +09:00
parent 43483c6c38
commit db58e94540
9 changed files with 94 additions and 4 deletions

View File

@ -49,3 +49,10 @@ AZURE_STORAGE_PREFIX=
# API_PORT=9800 # 사장님 API # API_PORT=9800 # 사장님 API
# ADMIN_PORT=3002 # 내부 화면 (bind 127.0.0.1) # ADMIN_PORT=3002 # 내부 화면 (bind 127.0.0.1)
# ADMIN_API_PORT_PUBLIC=9801 # 내부 API (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=

View File

@ -188,6 +188,8 @@ services:
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다. # 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} 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: volumes:
- ./package.json:/app/package.json - ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.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_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} 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 image: o2o-web4ai-solution-site
container_name: o2o-web4ai-solution-site container_name: o2o-web4ai-solution-site
volumes: volumes:

View File

@ -27,9 +27,15 @@ COPY admin ./admin
ARG VITE_API_BASE_URL ARG VITE_API_BASE_URL
ARG VITE_PUBLISH_HOST ARG VITE_PUBLISH_HOST
ARG VITE_SITE_PREVIEW_URL 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 \ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ 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 RUN npm run build -w @o2o/frontend
FROM nginx:alpine FROM nginx:alpine

View File

@ -17,7 +17,8 @@ export const router = createBrowserRouter([
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
* 만들어 보기도 전에 막힌다. * 만들어 보기도 전에 막힌다.
* *
* 대신 이미 로그인돼 있으면 그대로 쓰고, 아니면 서버가 필요한 순간(2단계 검색)에만 알린다. * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
* 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
* *
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조. * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
*/ */

View File

@ -12,6 +12,7 @@ import {
verifyPlaceByUrl, verifyPlaceByUrl,
} from '@/api'; } from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder'; import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureAutoSession} from '@/lib/autoSession';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */ /** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */
@ -127,6 +128,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
const controller = new AbortController(); const controller = new AbortController();
inflight.current = controller; inflight.current = controller;
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
if (!getAccessToken()) { if (!getAccessToken()) {
setState({ setState({
...INITIAL, ...INITIAL,

View File

@ -0,0 +1,14 @@
import {useEffect} from 'react';
import {ensureAutoSession} from '@/lib/autoSession';
/**
* 화면이 열리자마자 세션을 확보한다.
*
* 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을
* 기다려야 하기 때문에, 훅 바깥에 두고 공유한다.
*/
export function useAutoLogin() {
useEffect(() => {
void ensureAutoSession();
}, []);
}

View File

@ -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<void> | null = null;
export function ensureAutoSession(): Promise<void> {
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;
}

View File

@ -11,6 +11,7 @@ import {
Step5Generating, Step5Generating,
} from '@/features/onboarding'; } from '@/features/onboarding';
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
@ -31,6 +32,7 @@ function siteUrl(domain: string | null | undefined): string | null {
} }
export function BuilderPage() { export function BuilderPage() {
useAutoLogin();
/** /**
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`. * 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
* *
@ -39,8 +41,6 @@ export function BuilderPage() {
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다. * 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다. * placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
*/ */
// 개발 서버에서는 세션을 조용히 확보한다 — 위저드 앞에 로그인 화면을 세우지 않기 위해서다.
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId'); const urlPlaceId = searchParams.get('placeId');

View File

@ -4,6 +4,9 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string; readonly VITE_API_BASE_URL?: string;
readonly VITE_PUBLISH_HOST?: string; readonly VITE_PUBLISH_HOST?: string;
readonly VITE_SITE_PREVIEW_URL?: string; readonly VITE_SITE_PREVIEW_URL?: string;
/** ⚠️ 번들에 구워진다 — 내부 테스트 호스트에서만 채운다(lib/autoSession). */
readonly VITE_AUTO_LOGIN_ID?: string;
readonly VITE_AUTO_LOGIN_PW?: string;
} }
interface ImportMeta { interface ImportMeta {