feat(auth): 모바일 전용 로그인 페이지 추가 (/m/login)

- src/app/(auth)/m/login/page.tsx 신규 — 한 화면에 딱 맞는 모바일 layout (logo + form + 푸터, safe-area inset 적용)
- middleware.ts publicPaths 에 /m/login + PWA 자원(/manifest.json, /sw.js, /.well-known) 추가
- 세션 있는 상태로 /m/login 진입 시 /m/dashboard 로 자동 redirect
- manifest.json 의 start_url 을 /m/login 으로 변경 → TWA APK 가 앱 실행 시 바로 로그인 화면

로그인 성공 시 /m/dashboard 로 이동 (기존 /login 은 API 응답의 redirectTo 사용, 모바일은 hardcode).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-12 23:44:37 +09:00
parent 451117cfbe
commit 20e6255aa3
3 changed files with 206 additions and 2 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "모모유통 ERP",
"short_name": "모모ERP",
"description": "모모유통 유통관리 ERP",
"start_url": "/",
"start_url": "/m/login",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { User, Lock, Eye, EyeOff } from "lucide-react";
import Swal from "sweetalert2";
const SAVE_KEY = "momo_saved_credentials";
export default function MobileLoginPage() {
const router = useRouter();
const [userId, setUserId] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);
const [remember, setRemember] = useState(true);
const [saveCreds, setSaveCreds] = useState(false);
useEffect(() => {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return;
const saved = JSON.parse(raw) as { userId?: string; password?: string };
if (saved.userId) setUserId(saved.userId);
if (saved.password) setPassword(saved.password);
setSaveCreds(true);
} catch {
/* ignore */
}
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!userId || !password) {
Swal.fire({ icon: "warning", title: "아이디와 비밀번호를 입력하세요." });
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, password, remember }),
});
const data = await res.json();
if (data.success) {
try {
if (saveCreds) {
localStorage.setItem(SAVE_KEY, JSON.stringify({ userId, password }));
} else {
localStorage.removeItem(SAVE_KEY);
}
} catch {
/* ignore */
}
// 모바일은 항상 /m/dashboard 로
router.push("/m/dashboard");
} else {
Swal.fire({
icon: "error",
title: "로그인 실패",
text: data.message || "아이디 또는 비밀번호를 확인하세요.",
});
}
} catch {
Swal.fire({ icon: "error", title: "서버 오류", text: "잠시 후 다시 시도하세요." });
} finally {
setLoading(false);
}
};
return (
<div
className="min-h-[100dvh] flex flex-col bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-6"
style={{ paddingTop: "max(env(safe-area-inset-top), 1rem)", paddingBottom: "max(env(safe-area-inset-bottom), 1rem)" }}
>
{/* 배경 패턴 */}
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%)",
}}
/>
{/* 헤더: 로고 + 타이틀 */}
<div className="relative z-10 pt-8 pb-6 flex flex-col items-center">
<img src="/momo-icon.svg" alt="MOMO" className="w-16 h-16 mb-3 drop-shadow-lg" />
<h1 className="text-white text-xl font-bold tracking-tight"> ERP</h1>
<p className="text-emerald-100/70 text-xs mt-1"> </p>
</div>
{/* 폼 카드 */}
<div className="relative z-10 flex-1 flex items-center">
<form
onSubmit={handleSubmit}
className="w-full bg-white rounded-2xl shadow-2xl px-5 py-6 space-y-4"
>
<div>
<label className="block text-[11px] font-semibold text-slate-600 mb-1.5 tracking-wide">
</label>
<div className="relative group">
<User
size={16}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="아이디"
autoFocus
autoComplete="username"
inputMode="text"
className="w-full h-11 pl-10 pr-3 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 mb-1.5 tracking-wide">
</label>
<div className="relative group">
<Lock
size={16}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type={showPw ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호"
autoComplete="current-password"
className="w-full h-11 pl-10 pr-10 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1.5 text-slate-400 active:text-slate-700"
tabIndex={-1}
>
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="space-y-2 pt-1">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-4 h-4 accent-emerald-600"
/>
<span className="text-xs text-slate-700"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={saveCreds}
onChange={(e) => {
setSaveCreds(e.target.checked);
if (!e.target.checked) {
try {
localStorage.removeItem(SAVE_KEY);
} catch {
/* ignore */
}
}
}}
className="w-4 h-4 accent-emerald-600"
/>
<span className="text-xs text-slate-700">/ </span>
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full h-12 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white text-sm font-bold tracking-wide shadow-md active:translate-y-px transition disabled:opacity-60"
>
{loading ? "로그인 중..." : "로그인"}
</button>
</form>
</div>
{/* 푸터 */}
<div className="relative z-10 text-center pb-4 pt-2">
<p className="text-[10px] text-emerald-100/60 tracking-wide">
&copy; 2026 MOMO DISTRIBUTION
</p>
</div>
</div>
);
}
+8 -1
View File
@@ -7,6 +7,7 @@ export function middleware(request: NextRequest) {
// 인증 불필요 경로
const publicPaths = [
"/login",
"/m/login",
"/signup",
"/api/auth/login",
"/api/auth/signup",
@@ -17,6 +18,12 @@ export function middleware(request: NextRequest) {
"/icon.svg",
"/momo-logo.svg",
"/momo-icon.svg",
"/icon-192.png",
"/icon-512.png",
"/icon-180.png",
"/manifest.json",
"/sw.js",
"/.well-known",
];
// 랜딩 페이지: 세션이 살아있으면 대시보드로 직행 (이미 로그인된 사용자가 /로 들어와도 마케팅 화면 안 보이게)
if (pathname === "/") {
@@ -26,7 +33,7 @@ export function middleware(request: NextRequest) {
return NextResponse.next();
}
// 로그인/가입 페이지도 세션 있으면 대시보드로
if (pathname === "/login" || pathname === "/signup") {
if (pathname === "/login" || pathname === "/m/login" || pathname === "/signup") {
if (request.cookies.get("plm-session")) {
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}