[feat] solution/frontend: 로그인을 에디터 진입에서 한 번만 받는다
위저드를 걷는 동안 '로그인이 만료되었습니다' 가 떴다. 만료가 아니라 **한 번도 로그인한 적이 없는 것**이었다(자동 로그인 계정이 없으면 ensureAutoSession 이 즉시 끝난다). 문구가 사실과 달라 고장으로 읽혔다. - features/auth/EditorSignInGate: 에디터에 들어갈 때만 로그인을 받는다. 위저드 1~5단계는 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. /login 으로 튕기지 않는 이유는 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없기 때문이다 - features/auth/SignInForm: 로그인 화면과 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서(signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다 - usePlaceSearch: 토큰이 없을 때의 문구에서 '만료' 를 걷어낸다. 검색은 서버가 토큰을 요구하므로 (place.py 전 엔드포인트가 IsValidAccessToken) 프론트가 없앨 수 있는 제약이 아니다 tsc·eslint·vite build 통과
This commit is contained in:
parent
4debeaed3a
commit
969fb6773c
37
solution/frontend/src/features/auth/EditorSignInGate.tsx
Normal file
37
solution/frontend/src/features/auth/EditorSignInGate.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 에디터 앞의 로그인 관문.
|
||||||
|
*
|
||||||
|
* ★ 위저드(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
solution/frontend/src/features/auth/SignInForm.tsx
Normal file
104
solution/frontend/src/features/auth/SignInForm.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 로그인 폼 한 벌.
|
||||||
|
*
|
||||||
|
* ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서
|
||||||
|
* (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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -132,11 +132,15 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
|||||||
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||||
await ensureAutoSession();
|
await ensureAutoSession();
|
||||||
|
|
||||||
|
// ★ 로그인 없이도 여기까지는 온다. 예전엔 '로그인이 만료되었습니다' 를 띄웠는데,
|
||||||
|
// 만료가 아니라 **아직 로그인한 적이 없는 것**이라 사장님에게는 고장으로 읽혔다.
|
||||||
|
// 지도 검색만 못 할 뿐이니 직접 입력으로 계속 가게 한다 — 로그인은 에디터에서 한 번 받는다.
|
||||||
if (!getAccessToken()) {
|
if (!getAccessToken()) {
|
||||||
setState({
|
setState({
|
||||||
...INITIAL,
|
...INITIAL,
|
||||||
phase: 'unavailable',
|
phase: 'unavailable',
|
||||||
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
|
unavailableReason:
|
||||||
|
'지도 검색은 로그인한 뒤에 쓸 수 있습니다. 지금은 상호와 주소를 직접 넣고 계속 진행하세요.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,9 @@ 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,
|
||||||
@ -13,6 +15,7 @@ import {
|
|||||||
import {EditorLayout} from '@/features/builder';
|
import {EditorLayout} from '@/features/builder';
|
||||||
import {useAutoLogin} from '@/hooks/useAutoLogin';
|
import {useAutoLogin} from '@/hooks/useAutoLogin';
|
||||||
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';
|
||||||
|
|
||||||
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
|
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
|
||||||
@ -81,7 +84,11 @@ export function BuilderPage() {
|
|||||||
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
|
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
|
||||||
|
|
||||||
const step = useBuilderStore((s) => s.step);
|
const step = useBuilderStore((s) => s.step);
|
||||||
|
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);
|
||||||
@ -118,6 +125,11 @@ 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">
|
||||||
|
|||||||
@ -1,117 +1,42 @@
|
|||||||
import {useState, type FormEvent} from 'react';
|
|
||||||
import {Navigate, useLocation, useNavigate} from 'react-router';
|
import {Navigate, useLocation, useNavigate} from 'react-router';
|
||||||
import {LogIn} from 'lucide-react';
|
import {SignInForm} from '@/features/auth/SignInForm';
|
||||||
import {login, me, UserRole} from '@/api';
|
import {useAuthStore} from '@/stores/auth';
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import {Input} from '@/components/ui/input';
|
|
||||||
import {notifyApiError} from '@/lib/notify';
|
|
||||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const signIn = useAuthStore((s) => s.signIn);
|
|
||||||
|
|
||||||
const [id, setId] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
|
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
|
||||||
// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다.
|
// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다.
|
||||||
const from = (location.state as {from?: string} | null)?.from ?? '/';
|
const from = (location.state as {from?: string} | null)?.from ?? '/';
|
||||||
|
|
||||||
if (user) return <Navigate to={from} replace />;
|
if (user) return <Navigate to={from} replace />;
|
||||||
|
|
||||||
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));
|
|
||||||
navigate(from, {replace: true});
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, '로그인에 실패했습니다.');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
||||||
<form
|
<SignInForm
|
||||||
onSubmit={handleSubmit}
|
onSignedIn={() => navigate(from, {replace: true})}
|
||||||
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
|
header={
|
||||||
>
|
<div className="space-y-1 text-center">
|
||||||
<div className="space-y-1 text-center">
|
<img
|
||||||
<img
|
src="/brand/web4ai-wordmark.svg"
|
||||||
src="/brand/web4ai-wordmark.svg"
|
alt="Web4Ai"
|
||||||
alt="Web4Ai"
|
className="mx-auto mb-3 h-9 w-auto"
|
||||||
className="mx-auto mb-3 h-9 w-auto"
|
/>
|
||||||
/>
|
<h1 className="text-sm font-medium text-muted-foreground">관리자</h1>
|
||||||
<h1 className="text-sm font-medium text-muted-foreground">관리자</h1>
|
<p className="text-xs text-muted-foreground">사업장·정보 확인·발행을 관리합니다.</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
사업장·정보 확인·발행을 관리합니다.
|
}
|
||||||
|
footer={
|
||||||
|
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
사이트는 로그인 없이 만들어 볼 수 있습니다. 로그인은 편집 화면에서 한 번만 필요합니다.{' '}
|
||||||
|
<a href="/builder" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||||
|
빌더로 이동
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
<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>로그인</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
|
||||||
빌더는 로그인 없이도 사용할 수 있습니다.{' '}
|
|
||||||
<a href="/builder" className="font-medium text-primary underline-offset-2 hover:underline">
|
|
||||||
빌더로 이동
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user