o2o-site-AEO/solution/frontend/src/pages/LoginPage.tsx
Mina Choi e2d15955b0 [fix] solution/frontend,deploy: :80 에서 로그인이 CORS 로 막히던 것 — API 를 같은 오리진으로
화면은 http://localhost(:80) 인데 번들이 http://localhost:9800 을 직접 불렀다. 백엔드 허용
오리진 기본값은 :3000~3005 뿐이라 브라우저가 막았고, 화면에는 '로그인에 실패했습니다'
(네트워크 예외 문구)만 떴다 — 아이디·비번 문제로 보인다.

nginx 가 이미 /v1 을 프록시한다(site.conf). 그쪽으로 부르면 CORS 를 아예 안 탄다.

- .env: PUBLIC_API_BASE_URL=http://localhost — 번들이 같은 오리진을 보게 한다
- LoginPage: 개발 편의로 admin/1234 기본값. ★ 운영 전에 빈 문자열로 되돌릴 것

브라우저 확인(localhost:80): 로그인 → /builder, 사이드바 '관리자 · 데모대행사'.
2026-09-02 22:56:22 +09:00

155 lines
5.6 KiB
TypeScript

import {useState, type FormEvent} from 'react';
import {Link, Navigate, useLocation, useNavigate} from 'react-router';
import {LogIn} from 'lucide-react';
import {googleLogin, login} from '@/api';
import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
import {notifyApiError} from '@/lib/notify';
import {establishSession} from '@/lib/session';
import {useAuthStore} from '@/stores/auth';
/**
* 로그인 화면. **사장님 앱과 내부 운영 화면이 같이 쓴다.**
*
* ★ `selfServe` 로 갈린다 — 사장님 앱은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은
* 우리가 만들어 준다. 가입 링크를 두 곳에 다 두면 admin 라우터에 없는 `/signup` 으로 보내
* 404 가 난다(admin/src/app/router.tsx 에 그 경로는 없다).
*/
export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
// 개발 편의로 기본값을 채워 둔다. 운영에 열기 전에 반드시 빈 문자열로 되돌린다.
const [id, setId] = useState('admin');
const [password, setPassword] = useState('1234');
const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다.
const from = (location.state as {from?: string} | null)?.from ?? '/';
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;
}
if (!(await establishSession(res, id))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
navigate(from, {replace: true});
} catch (error) {
notifyApiError(error, '로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
const handleGoogle = async (credential: string) => {
setIsSubmitting(true);
try {
const res = await googleLogin({credential});
if (res.result?.success === false) {
notifyApiError({data: res}, '구글 로그인에 실패했습니다.');
return;
}
if (!(await establishSession(res, ''))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
navigate(from, {replace: true});
} catch (error) {
notifyApiError(error, '구글 로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
<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-medium text-muted-foreground">
{selfServe ? '로그인' : '관리자'}
</h1>
<p className="text-xs text-muted-foreground">
{selfServe
? '내 가게 사이트를 만들고 발행합니다.'
: '사업장·정보 확인·발행을 관리합니다.'}
</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>
{selfServe && isGoogleLoginEnabled() && (
<>
<div className="flex items-center gap-2">
<span className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" />
</div>
<GoogleSignInButton onCredential={handleGoogle} text="signin_with" />
</>
)}
{selfServe && (
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
아직 계정이 없으신가요?{' '}
<Link to="/signup" className="font-medium text-primary underline-offset-2 hover:underline">
회원가입
</Link>
</p>
)}
</form>
</div>
);
}