From d6a6c8e22940e5c5bc3e51fc103c65bb66dd91a9 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 09:34:13 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend:=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EA=B1=B8=EC=96=B4=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=98=A8=20=EB=92=A4=EC=97=90=20=EB=A7=89=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위저드 2단계부터 백엔드를 부르고, 만든 결과는 사업장·사이트로 계정에 귀속된다. 로그인 없이 걸어온 사람은 3단계쯤에서 '로그인이 만료되었습니다' 를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이 없었던 것이다. 문 앞에서 막는 편이 낫다. - app/router: /builder 를 RequireAuth 뒤로 - app/provider: 자동 로그인(AUTO_LOGIN_ID·PW)을 부팅에서 붙인다. 화면 안(useAutoLogin)은 가드가 먼저 판단하므로 영영 실행되지 않는다 — 그래서 훅을 지웠다 - 자동 로그인이 신원까지 채웠으면 me() 를 두 번 부르지 않는다 ⚠️ main 의 969fb67(에디터 진입에서 한 번만 로그인)·22b7623 과 **정면으로 다른 설계**다. 되돌리려면 router 의 RequireAuth 한 겹만 벗기면 된다. tsc·eslint·vite build 통과. --- AGENTS.md | 2 + docs/ARCHITECTURE.md | 22 +++++++---- docs/DEVLOG.md | 38 ++++++++++++++++++ solution/frontend/src/app/provider.tsx | 43 ++++++++++++--------- solution/frontend/src/app/router.tsx | 26 ++++++++++--- solution/frontend/src/hooks/useAutoLogin.ts | 14 ------- solution/frontend/src/pages/BuilderPage.tsx | 9 ++--- 7 files changed, 103 insertions(+), 51 deletions(-) delete mode 100644 solution/frontend/src/hooks/useAutoLogin.ts diff --git a/AGENTS.md b/AGENTS.md index 87b3819..83530f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ (`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의 수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.** 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다). +- **`/builder` 는 로그인 뒤에 있다.** 자동 로그인(`AUTO_LOGIN_ID`·`PW`)은 화면 안이 아니라 + 부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안에서 부르면 늦다. - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 CDN 을 앞에 세워야 한다. 아니면 비워라. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e5a7ab..35a93df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -137,10 +137,12 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 `UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다. 라우트 가드는 화면을 가리지 **번들은 못 가린다.** ★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다. -2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다"). - 내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다. - → 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 / - 내부: 실패가 곧 차단). +2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함) + **계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면, + 내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면 + 실수는 늘 **느슨한 쪽으로** 난다. + → 지금은 `LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다 + (사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음). 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. ### 백엔드 — 코드 한 벌, 진입점 둘 @@ -177,7 +179,9 @@ OWNER role=2 → 403 ``` OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. -`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). +`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 +같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고, +자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 @@ -222,8 +226,12 @@ admin 자기 파일만 `@admin` 이다. ### 아직 안 한 것 -- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — - 그때 `solution/frontend` 의 인증 정책을 다시 본다. +- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로 + 들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다. +- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고 + 거절한다 → [DECISIONS.md 1-5](DECISIONS.md) +- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서 + 비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다. - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 `127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.** - **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로 diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index b0ec3bf..0604186 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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` 하나로 모았다 diff --git a/solution/frontend/src/app/provider.tsx b/solution/frontend/src/app/provider.tsx index 4ff0817..354342d 100644 --- a/solution/frontend/src/app/provider.tsx +++ b/solution/frontend/src/app/provider.tsx @@ -2,13 +2,17 @@ import {QueryClientProvider} from '@tanstack/react-query'; import {useEffect, type ReactNode} from 'react'; import {Toaster} from 'sonner'; import {getAccessToken, me} from '@/api'; +import {ensureAutoSession} from '@/lib/autoSession'; import {queryClient} from '@/lib/query-client'; import {toAuthUser, useAuthStore} from '@/stores/auth'; /** - * 저장된 액세스 토큰으로 세션을 복구한다. - * 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가 - * 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다. + * 저장된 액세스 토큰으로 세션을 복구한다. 실패해도 앱은 뜬다 — + * 로그인 화면·가입 화면은 로그인 없이 열려야 하므로, 인증 실패가 전체를 막으면 안 된다. + * + * ★ 자동 로그인(VITE_AUTO_LOGIN_ID·PW)을 **여기서** 시도한다. 예전엔 빌더 화면 안에서 + * 불렀는데, 빌더가 RequireAuth 뒤로 들어가면서 그 자리는 영영 실행되지 않는다 — + * 가드가 먼저 판단하고 로그인 화면으로 보내 버린다. 계정이 안 주입돼 있으면 즉시 끝난다. */ function useRestoreSession() { const setUser = useAuthStore((s) => s.setUser); @@ -16,24 +20,27 @@ function useRestoreSession() { useEffect(() => { let alive = true; - if (!getAccessToken()) { - finishRestore(); - return; - } - void me() - .then((res) => { - if (!alive || res.result?.success === false) return; + void (async () => { + if (!getAccessToken()) await ensureAutoSession(); + if (!getAccessToken()) { + if (alive) finishRestore(); + return; + } + // 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다. + if (useAuthStore.getState().user) { + if (alive) finishRestore(); + return; + } + try { + const res = await me(); // ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다. // 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다. - if (!res.user_id || !res.id) return; - setUser(toAuthUser(res)); - }) - .catch(() => { + if (alive && res.result?.success !== false && res.user_id && res.id) setUser(toAuthUser(res)); + } catch { /* 토큰이 죽었으면 비로그인 상태로 계속 간다. */ - }) - .finally(() => { - if (alive) finishRestore(); - }); + } + if (alive) finishRestore(); + })(); return () => { alive = false; }; diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index b2206fb..89f3f7a 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -1,11 +1,14 @@ import {createBrowserRouter, Navigate} from 'react-router'; +import {RequireAuth} from '@/components/layout/RequireAuth'; import {BuilderPage} from '@/pages/BuilderPage'; import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {LoginPage} from '@/pages/LoginPage'; import {NotFoundPage} from '@/pages/NotFoundPage'; +import {SignupPage} from '@/pages/SignupPage'; export const router = createBrowserRouter([ {path: '/login', element: }, + {path: '/signup', element: }, // ★ 첫 화면은 업종 선택(위저드 1단계)이다. // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), @@ -14,15 +17,26 @@ export const router = createBrowserRouter([ {path: '/', element: }, /** - * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 - * 만들어 보기도 전에 막힌다. + * ★ 빌더부터는 로그인한 사람만 들어온다. * - * 대신 세션은 조용히 확보한다 — 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: }, + { + path: '/builder', + element: ( + + + + ), + }, /** * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다. diff --git a/solution/frontend/src/hooks/useAutoLogin.ts b/solution/frontend/src/hooks/useAutoLogin.ts deleted file mode 100644 index 2830f5d..0000000 --- a/solution/frontend/src/hooks/useAutoLogin.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {useEffect} from 'react'; -import {ensureAutoSession} from '@/lib/autoSession'; - -/** - * 화면이 열리자마자 세션을 확보한다. - * - * 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을 - * 기다려야 하기 때문에, 훅 바깥에 두고 공유한다. - */ -export function useAutoLogin() { - useEffect(() => { - void ensureAutoSession(); - }, []); -} diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 1146e6f..eb9259d 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -11,7 +11,6 @@ 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'; @@ -32,14 +31,12 @@ function siteUrl(domain: string | null | undefined): string | null { } export function BuilderPage() { - useAutoLogin(); /** * 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=`. * - * ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고 - * (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면 - * 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다. - * placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다. + * ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: placeId 는 있을 수도 없을 수도 있는 + * 선택값이다(새로 만들기 vs 사업장 열기). 쿼리스트링이면 라우트를 하나도 안 건드리고 + * 두 경우를 같은 화면이 받는다. placeId 가 없으면 아래 훅은 네트워크를 타지 않는다. */ const [searchParams, setSearchParams] = useSearchParams(); const urlPlaceId = searchParams.get('placeId');