[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:
parent
43483c6c38
commit
db58e94540
@ -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=
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -17,7 +17,8 @@ export const router = createBrowserRouter([
|
|||||||
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
|
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
|
||||||
* 만들어 보기도 전에 막힌다.
|
* 만들어 보기도 전에 막힌다.
|
||||||
*
|
*
|
||||||
* 대신 이미 로그인돼 있으면 그대로 쓰고, 아니면 서버가 필요한 순간(2단계 검색)에만 알린다.
|
* 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
|
||||||
|
* 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
|
||||||
*
|
*
|
||||||
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
|
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
14
solution/frontend/src/hooks/useAutoLogin.ts
Normal file
14
solution/frontend/src/hooks/useAutoLogin.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {useEffect} from 'react';
|
||||||
|
import {ensureAutoSession} from '@/lib/autoSession';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면이 열리자마자 세션을 확보한다.
|
||||||
|
*
|
||||||
|
* 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을
|
||||||
|
* 기다려야 하기 때문에, 훅 바깥에 두고 공유한다.
|
||||||
|
*/
|
||||||
|
export function useAutoLogin() {
|
||||||
|
useEffect(() => {
|
||||||
|
void ensureAutoSession();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
49
solution/frontend/src/lib/autoSession.ts
Normal file
49
solution/frontend/src/lib/autoSession.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
3
solution/frontend/src/vite-env.d.ts
vendored
3
solution/frontend/src/vite-env.d.ts
vendored
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user