diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e5a7ab..deaf3cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -137,8 +137,11 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 `UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다. 라우트 가드는 화면을 가리지 **번들은 못 가린다.** ★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다. -2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다"). - 내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다. +2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다 + ("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도 + 다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고 + role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다). + 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다. → 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 / 내부: 실패가 곧 차단). 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. @@ -177,7 +180,9 @@ OWNER role=2 → 403 ``` OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. -`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). +`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 +같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기 +회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 @@ -222,8 +227,11 @@ admin 자기 파일만 `@admin` 이다. ### 아직 안 한 것 -- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — - 그때 `solution/frontend` 의 인증 정책을 다시 본다. +- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직 + `/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 0a5e4db..a550884 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -106,6 +106,50 @@ 백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 — 바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다. +--- + +## 2026-09-02 — 회원가입과 구글 로그인 + +**한 일** +- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과 + 가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다. +- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL + 허용(소셜 계정), `id` 는 20 → 64자(`google_` 가 20자를 넘는다). +- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가 + 들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.** + +**왜 가입부터 만들었나** +계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데 +그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개** +로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다. + +**로그인 관문은 에디터 진입 그대로다** +한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이 +`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다. +관문은 `EditorSignInGate`(969fb67) 한 자리다. + +**밟기 쉬운 자리** +- **`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 컬럼이면 그 경로가 통째로 깨진다. +- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라, + 기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다). + +**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다. + +**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다. +버튼 렌더까지는 확인했다(빌려온 client_id 로). + +**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료· +`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터 +상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과. + ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 **왜** diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index ac3ccf3..f12c9a3 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -358,8 +358,6 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id); -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; --- 같은 구글 계정으로 두 번 가입되지 않게. provider 를 키에 넣어 수단이 늘어도 이 인덱스가 그대로 쓰인다. -CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; @@ -441,3 +439,7 @@ ALTER TABLE company.users ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; +-- ★ 이 인덱스는 위 인덱스 절이 아니라 **여기** 있어야 한다. 기존 DB 에서는 컬럼이 ALTER 로 +-- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) + WHERE deleted = FALSE AND provider_uid IS NOT NULL; diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index b2206fb..4208797 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -3,9 +3,12 @@ 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: }, + // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. + {path: '/signup', element: }, // ★ 첫 화면은 업종 선택(위저드 1단계)이다. // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index b78b597..61a1fe7 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,8 +1,8 @@ import type {ComponentType, ReactNode} from 'react'; -import {Link, NavLink, useLocation} from 'react-router'; -import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react'; +import {Link, NavLink, useLocation, useNavigate} from 'react-router'; +import {LayoutGrid, LogIn, LogOut, Search, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; -import {useAuthStore} from '@/stores/auth'; +import {userLabel, useAuthStore} from '@/stores/auth'; export type NavItem = { to: string; @@ -30,6 +30,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? const user = useAuthStore((s) => s.user); const signOut = useAuthStore((s) => s.signOut); const location = useLocation(); + const navigate = useNavigate(); return (
@@ -58,19 +59,43 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? ))} + {/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는 + **비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만 + 남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
- {user?.name ?? user?.id} - {user?.companyName ? ` · ${user.companyName}` : ''} + {user ? ( + <> + {userLabel(user)} + {user.companyName ? ` · ${user.companyName}` : ''} + + ) : ( + 로그인하지 않았습니다 + )}
- + {user ? ( + + ) : ( + + + 로그인 + + )}
diff --git a/solution/frontend/src/features/auth/SignInForm.tsx b/solution/frontend/src/features/auth/SignInForm.tsx index b9d04de..ba5e622 100644 --- a/solution/frontend/src/features/auth/SignInForm.tsx +++ b/solution/frontend/src/features/auth/SignInForm.tsx @@ -6,11 +6,11 @@ */ import {useState, type FormEvent, type ReactNode} from 'react'; import {LogIn} from 'lucide-react'; -import {login, me, UserRole} from '@/api'; +import {login} from '@/api'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {notifyApiError} from '@/lib/notify'; -import {toAuthUser, useAuthStore} from '@/stores/auth'; +import {establishSession} from '@/lib/session'; interface SignInFormProps { /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ @@ -22,7 +22,6 @@ interface SignInFormProps { } export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) { - const signIn = useAuthStore((s) => s.signIn); const [id, setId] = useState(''); const [password, setPassword] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -36,20 +35,12 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); return; } - // ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. - // 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. - if (!res.access_token || !res.refresh_token) { + // 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이 + // 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다. + if (!(await establishSession(res, id))) { notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); return; } - const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; - - // 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다. - signIn(tokens, {userId: '', id, role: UserRole.USER}); - - const meRes = await me(); - // 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다. - if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes)); onSignedIn?.(); } catch (error) { notifyApiError(error, '로그인에 실패했습니다.'); diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index e2d6c33..94c8551 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -1,5 +1,5 @@ import {useEffect, useRef} from 'react'; -import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react'; +import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react'; import {Link, useSearchParams} from 'react-router'; import {SiteStatus} from '@o2o/shared'; import {getAccessToken} from '@/api'; @@ -15,7 +15,7 @@ import { import {EditorLayout} from '@/features/builder'; import {useAutoLogin} from '@/hooks/useAutoLogin'; import {usePlaceSync} from '@/hooks/usePlaceSync'; -import {useAuthStore} from '@/stores/auth'; +import {userLabel, useAuthStore} from '@/stores/auth'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ @@ -86,9 +86,11 @@ export function BuilderPage() { const step = useBuilderStore((s) => s.step); const goToStep = useBuilderStore((s) => s.goToStep); const storeName = useBuilderStore((s) => s.storeName); + // 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다. + const user = useAuthStore((s) => s.user); + const signOut = useAuthStore((s) => s.signOut); // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. - const authUser = useAuthStore((s) => s.user); - const isSignedIn = Boolean(authUser) || Boolean(getAccessToken()); + const isSignedIn = Boolean(user) || Boolean(getAccessToken()); // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. const wiredPlaceId = useBuilderStore((s) => s.placeId); @@ -158,26 +160,49 @@ export function BuilderPage() { {/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 — 같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다. ★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */} - {publishedUrl ? ( - - - 발행본 사이트 열기 - - ) : ( - - - 발행본 사이트 열기 - - )} +
+ {publishedUrl ? ( + + + 발행본 사이트 열기 + + ) : ( + + + 발행본 사이트 열기 + + )} + + {user && ( + <> + + + {userLabel(user)} + {user.companyName ? ` · ${user.companyName}` : ''} + + + + )} +
diff --git a/solution/frontend/src/stores/auth.ts b/solution/frontend/src/stores/auth.ts index 50f291c..82fad43 100644 --- a/solution/frontend/src/stores/auth.ts +++ b/solution/frontend/src/stores/auth.ts @@ -31,6 +31,14 @@ export function toAuthUser(res: ResMe): AuthUser { }; } +/** + * 화면에 띄울 이름. 구글 계정의 로그인 아이디는 `google_` 라 그대로 보이면 안 된다 — + * 이름 → 이메일 순으로 떨어뜨리고 아이디는 마지막이다. + */ +export function userLabel(user: AuthUser): string { + return user.name || user.email || user.id; +} + interface AuthState { user: AuthUser | null; /** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */