[feat] solution/frontend: 빌더를 로그인 뒤로 — 걸어 들어온 뒤에 막지 않는다

위저드 2단계부터 백엔드를 부르고, 만든 결과는 사업장·사이트로 계정에 귀속된다. 로그인 없이
걸어온 사람은 3단계쯤에서 '로그인이 만료되었습니다' 를 만나고 그때까지 넣은 걸 잃었다 —
만료가 아니라 처음부터 세션이 없었던 것이다. 문 앞에서 막는 편이 낫다.

- app/router: /builder 를 RequireAuth 뒤로
- app/provider: 자동 로그인(AUTO_LOGIN_ID·PW)을 부팅에서 붙인다. 화면 안(useAutoLogin)은
  가드가 먼저 판단하므로 영영 실행되지 않는다 — 그래서 훅을 지웠다
- 자동 로그인이 신원까지 채웠으면 me() 를 두 번 부르지 않는다

⚠️ main 의 969fb67(에디터 진입에서 한 번만 로그인)·22b7623 과 **정면으로 다른 설계**다.
   되돌리려면 router 의 RequireAuth 한 겹만 벗기면 된다.

tsc·eslint·vite build 통과.
This commit is contained in:
Mina Choi 2026-09-02 09:34:13 +09:00
parent 48109fdc99
commit d6a6c8e229
7 changed files with 103 additions and 51 deletions

View File

@ -43,6 +43,8 @@
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의 (`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.** 수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다). 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
- **`/builder` 는 로그인 뒤에 있다.** 자동 로그인(`AUTO_LOGIN_ID`·`PW`)은 화면 안이 아니라
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안에서 부르면 늦다.
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데 - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라. CDN 을 앞에 세워야 한다. 아니면 비워라.

View File

@ -137,10 +137,12 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다. `UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.** 라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다. ★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다"). 2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함)
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다. **계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면,
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 / 내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면
내부: 실패가 곧 차단). 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 `LoginPage``selfServe` 플래그가 그 차이를 한 곳에서 드러낸다
(사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### 백엔드 — 코드 한 벌, 진입점 둘 ### 백엔드 — 코드 한 벌, 진입점 둘
@ -177,7 +179,9 @@ OWNER role=2 → 403
``` ```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고,
자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner`
@ -222,8 +226,12 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것 ### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — - 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로
그때 `solution/frontend` 의 인증 정책을 다시 본다. 들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서
비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.** `127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host**`solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로 - **폰트 self-host**`solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로

View File

@ -3,6 +3,44 @@
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다. 무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
## 2026-09-02 — 로그인을 붙이고, 빌더를 그 뒤로 넣었다
**한 일**
- id/pw **회원가입**(`POST /v1/auth/signup`) 과 **구글 로그인**(`POST /v1/auth/google`) 추가.
- `company.users``provider`(AuthProvider) · `provider_uid`(구글 sub) 추가. `password`
NULL 허용, `id` 는 20 → 64자.
- `/builder``RequireAuth` 뒤로 넣었다. 자동 로그인은 화면 안(`useAutoLogin`) 이 아니라
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안은 이제 실행되지 않는다.
- 로그인 화면에 구글 버튼 + 가입 링크. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
**왜 빌더를 막았나**
빌더는 "만들어 보기 전에 막지 않으려고" 열려 있었다. 그런데 위저드 2단계부터 백엔드를 부르고,
만든 결과는 사업장·사이트로 **계정에 귀속**된다. 로그인 없이 걸어온 사람은 3단계쯤에서
"로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이
없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
**왜 가입까지 만들었나**
빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가
아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**밟기 쉬운 자리**
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이
된다 → [DECISIONS.md 1-5](DECISIONS.md)
- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이
None 을 만나 500 이 난다.
- `provider``server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다 — 말미의
"기존 DB 보정(ALTER)" 섹션이 새 컬럼을 채우고 `id` 를 넓힌다. 안 하면 로그인부터 500 이다.
**검증** — 백엔드 `pytest` auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified` 거절 확인), 프론트 `tsc` · `eslint` · `vite build` 통과.
--- ---
## 2026-09-01 — 설정을 `.env` 하나로 모았다 ## 2026-09-01 — 설정을 `.env` 하나로 모았다

View File

@ -2,13 +2,17 @@ import {QueryClientProvider} from '@tanstack/react-query';
import {useEffect, type ReactNode} from 'react'; import {useEffect, type ReactNode} from 'react';
import {Toaster} from 'sonner'; import {Toaster} from 'sonner';
import {getAccessToken, me} from '@/api'; import {getAccessToken, me} from '@/api';
import {ensureAutoSession} from '@/lib/autoSession';
import {queryClient} from '@/lib/query-client'; import {queryClient} from '@/lib/query-client';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {toAuthUser, useAuthStore} from '@/stores/auth';
/** /**
* . * .
* , * · , .
* . . *
* (VITE_AUTO_LOGIN_ID·PW) **** .
* , RequireAuth
* . .
*/ */
function useRestoreSession() { function useRestoreSession() {
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
@ -16,24 +20,27 @@ function useRestoreSession() {
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
if (!getAccessToken()) { void (async () => {
finishRestore(); if (!getAccessToken()) await ensureAutoSession();
return; if (!getAccessToken()) {
} if (alive) finishRestore();
void me() return;
.then((res) => { }
if (!alive || res.result?.success === false) return; // 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다.
if (useAuthStore.getState().user) {
if (alive) finishRestore();
return;
}
try {
const res = await me();
// ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다. // ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다.
// 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다. // 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다.
if (!res.user_id || !res.id) return; if (alive && res.result?.success !== false && res.user_id && res.id) setUser(toAuthUser(res));
setUser(toAuthUser(res)); } catch {
})
.catch(() => {
/* 토큰이 죽었으면 비로그인 상태로 계속 간다. */ /* 토큰이 죽었으면 비로그인 상태로 계속 간다. */
}) }
.finally(() => { if (alive) finishRestore();
if (alive) finishRestore(); })();
});
return () => { return () => {
alive = false; alive = false;
}; };

View File

@ -1,11 +1,14 @@
import {createBrowserRouter, Navigate} from 'react-router'; import {createBrowserRouter, Navigate} from 'react-router';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {BuilderPage} from '@/pages/BuilderPage'; import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage'; import {NotFoundPage} from '@/pages/NotFoundPage';
import {SignupPage} from '@/pages/SignupPage';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, {path: '/login', element: <LoginPage />},
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다. // ★ 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
@ -14,15 +17,26 @@ export const router = createBrowserRouter([
{path: '/', element: <Navigate to="/builder?new=1" replace />}, {path: '/', element: <Navigate to="/builder?new=1" replace />},
/** /**
* * .
* .
* *
* VITE_AUTO_LOGIN_ID·PW useAutoLogin() * ( ). 2
* , (2 ) . * , · ** **
* 3 "로그인이 만료되었습니다" .
* .
* *
* (6) AppShell BuilderPage . * (VITE_AUTO_LOGIN_ID·PW)
* (app/provider.tsx). .
*
* (6) AppShell BuilderPage .
*/ */
{path: '/builder', element: <BuilderPage />}, {
path: '/builder',
element: (
<RequireAuth>
<BuilderPage />
</RequireAuth>
),
},
/** /**
* (/places, /local-content, /seo) `admin/` . * (/places, /local-content, /seo) `admin/` .

View File

@ -1,14 +0,0 @@
import {useEffect} from 'react';
import {ensureAutoSession} from '@/lib/autoSession';
/**
* .
*
* `lib/autoSession` (usePlaceSearch)
* , .
*/
export function useAutoLogin() {
useEffect(() => {
void ensureAutoSession();
}, []);
}

View File

@ -11,7 +11,6 @@ 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';
@ -32,14 +31,12 @@ function siteUrl(domain: string | null | undefined): string | null {
} }
export function BuilderPage() { export function BuilderPage() {
useAutoLogin();
/** /**
* `/builder?placeId=<uuid>`. * `/builder?placeId=<uuid>`.
* *
* (`/builder/:placeId`) 이유: 빌더는 * (`/builder/:placeId`) 이유: placeId
* (router.tsx ), placeId . * ( vs ).
* . * . placeId .
* placeId .
*/ */
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId'); const urlPlaceId = searchParams.get('placeId');