화면은 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, 사이드바 '관리자 · 데모대행사'.
155 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|