[현재고 — 캡처/공유 시에만 숨김] - 거래처에 보낼 이미지에서 내부 정보(현재고)가 보이면 안 됨 - 거래명세표 표의 현재고 th/td 와 재고 부족 경고 박스에 .js-no-export 클래스 추가 - captureAndShare 안에서 toPng 직전 임시로 display:none → 캡처 후 복원 - 화면에서는 그대로 보이고, 다운받은 PNG/공유 이미지에서만 빠짐 [로그인 유지 — 30일 세션] - /api/auth/login 요청 body 에 remember 추가 - /lib/session.ts createSession(user, remember=false) — 24시간(기본) / 30일(remember=true) - 로그인 폼에 [✓ 로그인 유지 (30일)] 체크박스 (기본 ON, 나이 많은 사용자 친화) - 체크 해제하면 24시간 세션 유지 (기존 동작) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPw, setShowPw] = useState(false);
|
const [showPw, setShowPw] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [remember, setRemember] = useState(true); // 기본 ON
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -26,7 +27,7 @@ export default function LoginPage() {
|
|||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ userId, password }),
|
body: JSON.stringify({ userId, password, remember }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -175,6 +176,17 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer select-none py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
className="w-5 h-5 accent-emerald-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700 font-semibold">로그인 유지 (30일)</span>
|
||||||
|
<span className="text-xs text-slate-400 ml-auto">매번 다시 로그인 안 해도 돼요</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -349,10 +349,20 @@ function StatementPreview({
|
|||||||
try {
|
try {
|
||||||
const mod = await import("html-to-image");
|
const mod = await import("html-to-image");
|
||||||
if (!statementRef.current) return;
|
if (!statementRef.current) return;
|
||||||
const dataUrl = await mod.toPng(statementRef.current, {
|
// 거래처에 보낼 이미지에서 "현재고"는 내부 정보라 숨긴다.
|
||||||
backgroundColor: "#ffffff",
|
const hideEls = statementRef.current.querySelectorAll<HTMLElement>(".js-no-export");
|
||||||
pixelRatio: 2,
|
const prev: { el: HTMLElement; display: string }[] = [];
|
||||||
});
|
hideEls.forEach((el) => { prev.push({ el, display: el.style.display }); el.style.display = "none"; });
|
||||||
|
let dataUrl: string;
|
||||||
|
try {
|
||||||
|
dataUrl = await mod.toPng(statementRef.current, {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
pixelRatio: 2,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// 복원
|
||||||
|
prev.forEach((p) => { p.el.style.display = p.display; });
|
||||||
|
}
|
||||||
const blob = await (await fetch(dataUrl)).blob();
|
const blob = await (await fetch(dataUrl)).blob();
|
||||||
const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
|
const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
|
||||||
// Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유
|
// Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유
|
||||||
@@ -487,7 +497,7 @@ function StatementPreview({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lowStock.length > 0 && (
|
{lowStock.length > 0 && (
|
||||||
<div className="border border-rose-200 bg-rose-50 rounded p-2 text-[11px] text-rose-700 flex items-start gap-2">
|
<div className="border border-rose-200 bg-rose-50 rounded p-2 text-[11px] text-rose-700 flex items-start gap-2 js-no-export">
|
||||||
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
|
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<b>재고 부족 {lowStock.length}건</b> — 출고 시 거부됩니다:
|
<b>재고 부족 {lowStock.length}건</b> — 출고 시 거부됩니다:
|
||||||
@@ -526,7 +536,7 @@ function StatementPreview({
|
|||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 text-left">품명</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 text-left">품명</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-12">구분</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-12">구분</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-14">현재고</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-14 js-no-export">현재고</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-14">수량</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-14">수량</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-20">단가</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-20">단가</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5">공급가</th>
|
<th className="border border-slate-300 px-1.5 py-1.5">공급가</th>
|
||||||
@@ -568,7 +578,7 @@ function StatementPreview({
|
|||||||
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
|
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
|
||||||
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
||||||
</td>
|
</td>
|
||||||
<td className={`border border-slate-300 px-1.5 py-1 text-right ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}>
|
<td className={`border border-slate-300 px-1.5 py-1 text-right js-no-export ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}>
|
||||||
{isExtra ? "-" : fmt(it.STOCK_QTY)}
|
{isExtra ? "-" : fmt(it.STOCK_QTY)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
|
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
|
||||||
@@ -671,7 +681,7 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-slate-300 px-1.5 py-1 text-center text-rose-700">과세</td>
|
<td className="border border-slate-300 px-1.5 py-1 text-center text-rose-700">과세</td>
|
||||||
<td className="border border-slate-300 px-1.5 py-1 text-right text-slate-400">-</td>
|
<td className="border border-slate-300 px-1.5 py-1 text-right text-slate-400 js-no-export">-</td>
|
||||||
<td className="border border-slate-300 px-1 py-1 text-right">
|
<td className="border border-slate-300 px-1 py-1 text-right">
|
||||||
<input type="number" min={1} value={qty}
|
<input type="number" min={1} value={qty}
|
||||||
onChange={(e) => setQty(Number(e.target.value))}
|
onChange={(e) => setQty(Number(e.target.value))}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import type { User } from "@/types";
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { userId, password } = body;
|
const { userId, password, remember } = body as { userId?: string; password?: string; remember?: boolean };
|
||||||
|
const keepLoggedIn = !!remember;
|
||||||
|
|
||||||
if (!userId || !password) {
|
if (!userId || !password) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const momo = await verifyMomoCredentials(userId, password);
|
const momo = await verifyMomoCredentials(userId, password);
|
||||||
if (momo.success && momo.user) {
|
if (momo.success && momo.user) {
|
||||||
const sessionUser: User = momoToSessionUser(momo.user);
|
const sessionUser: User = momoToSessionUser(momo.user);
|
||||||
await createSession(sessionUser);
|
await createSession(sessionUser, keepLoggedIn);
|
||||||
return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) });
|
return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) });
|
||||||
}
|
}
|
||||||
// MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스)
|
// MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스)
|
||||||
@@ -34,7 +35,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const fito = await verifyCredentials(userId, password);
|
const fito = await verifyCredentials(userId, password);
|
||||||
if (fito.success && fito.user) {
|
if (fito.success && fito.user) {
|
||||||
await createSession(fito.user);
|
await createSession(fito.user, keepLoggedIn);
|
||||||
return NextResponse.json({ success: true, user: fito.user, redirectTo: landingFor(fito.user) });
|
return NextResponse.json({ success: true, user: fito.user, redirectTo: landingFor(fito.user) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const momo = await verifyMomoCredentials(userId, password);
|
const momo = await verifyMomoCredentials(userId, password);
|
||||||
if (momo.success && momo.user) {
|
if (momo.success && momo.user) {
|
||||||
const sessionUser: User = momoToSessionUser(momo.user);
|
const sessionUser: User = momoToSessionUser(momo.user);
|
||||||
await createSession(sessionUser);
|
await createSession(sessionUser, keepLoggedIn);
|
||||||
return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) });
|
return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -7,13 +7,15 @@ const SECRET = new TextEncoder().encode(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const SESSION_COOKIE = "plm-session";
|
const SESSION_COOKIE = "plm-session";
|
||||||
const SESSION_DURATION = 24 * 60 * 60; // 24시간 (기존 1440분과 동일)
|
const SESSION_DURATION_DEFAULT = 24 * 60 * 60; // 24시간 (기본)
|
||||||
|
const SESSION_DURATION_REMEMBER = 30 * 24 * 60 * 60; // 30일 (로그인 유지)
|
||||||
|
|
||||||
export async function createSession(user: User): Promise<string> {
|
export async function createSession(user: User, remember = false): Promise<string> {
|
||||||
|
const duration = remember ? SESSION_DURATION_REMEMBER : SESSION_DURATION_DEFAULT;
|
||||||
const token = await new SignJWT({ user })
|
const token = await new SignJWT({ user })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime(`${SESSION_DURATION}s`)
|
.setExpirationTime(`${duration}s`)
|
||||||
.sign(SECRET);
|
.sign(SECRET);
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -21,7 +23,7 @@ export async function createSession(user: User): Promise<string> {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: SESSION_DURATION,
|
maxAge: duration,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user