[refactor] solution/frontend: 로그인 관문을 문 앞 하나로 — 에디터 관문과 2단계 우회로 제거

같은 날 두 자리에서 같은 문제를 풀어 관문이 두 겹이 됐다. 둘 다 두면 문 앞(RequireAuth)이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드다. 문 앞을 남긴 이유는 열어 둔 값이
공짜가 아니었기 때문이다.

에디터 관문을 쓰려면 2단계가 토큰 없이 지나가야 했고, 그래서 토큰이 없을 때 서버를 부르지 않고
입력값으로 신원을 세우는 우회로가 생겼다(confirmManual). 그건 이 레포의 단 하나의 규칙
— 검증 전에는 수집·발행 금지 — 을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을 다시"다.
게다가 가입이 이제 그 자리에서 끝나므로(가입 응답에 토큰이 실린다) 문 앞 로그인의 마찰은
"만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.

- features/auth/EditorSignInGate·SignInForm 삭제. 관문이 하나면 폼도 하나다 —
  SignInForm 이 경고하던 'signIn → me 를 두 벌로 들고 있다' 를 lib/session 한 곳으로 모았다
- Step2PlaceSearch: 토큰 없을 때 검증을 건너뛰던 두 갈래 제거
- usePlaceSearch: confirmManual 제거. 토큰이 없으면 이제 진짜 '만료'다(문 앞을 통과했으므로)
  — 문구를 사실대로 되돌린다
- BuilderPage: 에디터 진입 분기 제거

되돌리려면 app/router 의 RequireAuth 를 벗기고 969fb67·22b7623 을 되살리면 된다.

tsc·eslint·vite build 통과(사장님 앱·admin).
This commit is contained in:
Mina Choi 2026-09-02 09:41:28 +09:00
parent e8e695c5a9
commit b94daa9dcf
6 changed files with 27 additions and 181 deletions

View File

@ -19,6 +19,24 @@
"로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이 "로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이
없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다. 없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
**관문이 두 개였다 — 문 앞 하나로 합쳤다**
같은 날 두 자리에서 같은 문제를 풀었다. main 은 **에디터 진입(6단계)** 에서 받았고
(`EditorSignInGate`·`SignInForm`), 이쪽은 **`/builder` 문 앞**에서 받았다. 둘 다 두면 문 앞이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드가 된다. 문 앞을 남긴 이유:
- 에디터 관문을 쓰려면 **2단계가 토큰 없이 지나가야** 했고, 그래서 토큰이 없을 때 서버를 부르지
않고 입력값으로 신원을 세우는 우회로가 생겼다(`confirmManual`). 그건 이 레포의 단 하나의
규칙(**검증 전에는 수집·발행 금지**)을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을
다시" 다. 열어 둔 값이 공짜가 아니었다.
- 이제 가입이 그 자리에서 끝난다(가입 응답에 토큰이 실린다). 구글이면 클릭 두 번이다 —
문 앞 로그인의 마찰이 "만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.
- 관문이 하나면 로그인 폼도 하나다. `SignInForm` 이 경고하던 "`signIn → me` 순서를 두 벌로
들고 있다"는 `lib/session.establishSession` 한 곳으로 모았다.
지운 것: `features/auth/EditorSignInGate`·`SignInForm`, `usePlaceSearch.confirmManual`,
Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx``RequireAuth` 를 벗기고
그 셋을 되살리면 된다(커밋 `969fb67`·`22b7623`).
**왜 가입까지 만들었나** **왜 가입까지 만들었나**
빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가 빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가
아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개** 아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**

View File

@ -1,37 +0,0 @@
/**
* .
*
* (1~5) .
* .
* .
* /login . .
*/
import {SignInForm} from './SignInForm';
export function EditorSignInGate({onBack}: {onBack: () => void}) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
<SignInForm
submitLabel="로그인하고 편집 시작"
header={
<div className="space-y-1 text-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="mx-auto mb-3 h-9 w-auto" />
<h1 className="text-sm font-bold"> </h1>
<p className="text-xs leading-relaxed text-muted-foreground">
. .
</p>
</div>
}
footer={
<button
type="button"
onClick={onBack}
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
</button>
}
/>
</div>
);
}

View File

@ -1,104 +0,0 @@
/**
* .
*
* .
* (signIn me) , "로그인은 됐는데 계속 401" .
*/
import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react';
import {login, me, UserRole} 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';
interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
header: ReactNode;
/** 폼 아래 각주(빌더로 돌아가기 등). */
footer?: ReactNode;
submitLabel?: string;
onSignedIn?: () => void;
}
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);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
try {
const res = await login({id, password});
if (res.result?.success === false) {
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return;
}
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다.
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다.
if (!res.access_token || !res.refresh_token) {
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, '로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
{header}
<div className="space-y-3">
<div>
<label htmlFor="login-id" className="mb-1.5 block text-xs font-semibold">
</label>
<Input
id="login-id"
value={id}
onChange={(e) => setId(e.target.value)}
autoComplete="username"
required
/>
</div>
<div>
<label htmlFor="login-pw" className="mb-1.5 block text-xs font-semibold">
</label>
<Input
id="login-pw"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
<LogIn />
<span>{submitLabel}</span>
</Button>
{footer}
</form>
);
}

View File

@ -1,7 +1,7 @@
import {useState} from 'react'; import {useState} from 'react';
import {useSearchParams} from 'react-router'; import {useSearchParams} from 'react-router';
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react'; import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
import {getAccessToken, type PlaceCandidate} from '@/api'; import type {PlaceCandidate} from '@/api';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
@ -83,14 +83,6 @@ export function Step2PlaceSearch() {
const runSearch = () => { const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
setPickedIndex(null); setPickedIndex(null);
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
void search.search(storeName, location); void search.search(storeName, location);
}; };
@ -121,12 +113,6 @@ export function Step2PlaceSearch() {
const pickByUrl = async () => { const pickByUrl = async () => {
const url = placeUrl.trim(); const url = placeUrl.trim();
if (!url) return; if (!url) return;
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
const identity = await search.confirmByUrl(url); const identity = await search.confirmByUrl(url);
if (!identity) return; if (!identity) return;
confirmIdentity(identity); confirmIdentity(identity);

View File

@ -132,10 +132,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다. // 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession(); await ensureAutoSession();
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다. // ★ /builder 가 RequireAuth 뒤라 여기까지 왔으면 토큰이 있어야 한다. 없다면 위저드를
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다. // 걷는 도중에 세션이 끊긴 것이다 — 그건 실제로 '만료' 이므로 그렇게 말한다.
if (!getAccessToken()) { if (!getAccessToken()) {
setState({...INITIAL}); setState({
...INITIAL,
phase: 'unavailable',
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
});
return; return;
} }
@ -266,15 +270,5 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* (POST /verify) . URL * (POST /verify) . URL
* . . * . .
*/ */
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => { return {...state, isConfirming, search, confirm, confirmByUrl, reset};
return {
placeId: placeIdRef.current,
name: name.trim(),
address: address.trim(),
origin: 'owner',
sourceLabel: '직접 입력',
};
}, []);
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset};
} }

View File

@ -2,9 +2,7 @@ import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react'; import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell'; import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import { import {
Step1Industry, Step1Industry,
Step2PlaceSearch, Step2PlaceSearch,
@ -14,7 +12,6 @@ import {
} from '@/features/onboarding'; } from '@/features/onboarding';
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
@ -83,9 +80,6 @@ export function BuilderPage() {
const step = useBuilderStore((s) => s.step); const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep); const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const authUser = useAuthStore((s) => s.user);
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId); const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -122,11 +116,6 @@ export function BuilderPage() {
); );
} }
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />;
}
if (step === EDITOR_STEP) { if (step === EDITOR_STEP) {
return ( return (
<div className="relative flex h-screen w-screen flex-col overflow-hidden"> <div className="relative flex h-screen w-screen flex-col overflow-hidden">