Compare commits

...

31 Commits

Author SHA1 Message Date
dohyeons b190e2ba08 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-12-01 15:52:49 +09:00
dohyeons 2ae43c879f MD5 해시로 비밀번호 저장방식 변경 2025-12-01 12:18:56 +09:00
dohyeons 166004e8fd 회원가입 시 COMPANY_13으로 저장 2025-12-01 11:58:31 +09:00
dohyeons 08d4d7dbfc Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-12-01 11:55:56 +09:00
dohyeons b787b027a6 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-28 18:12:48 +09:00
dohyeons c38153eff1 회원가입 시 vehicles 테이블 연동 및 차량 타입 필드 추가 2025-11-28 17:24:18 +09:00
dohyeons 0c897ad8fd Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-28 11:59:02 +09:00
dohyeons 8e455def0c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-25 15:16:24 +09:00
dohyeons 10c16c818a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-21 10:46:17 +09:00
dohyeons 6cbe200f00 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-21 04:28:12 +09:00
dohyeons 660ddb0f95 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-14 12:14:36 +09:00
dohyeons 0b61ef4d12 회원가입 버튼 텍스트 수정 2025-11-13 18:25:50 +09:00
dohyeons 8f926f6887 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-13 18:15:53 +09:00
dohyeons cb8184735c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-13 18:07:24 +09:00
dohyeons ec26aa1bac Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-13 17:59:41 +09:00
dohyeons 22b6404a5b logistream 공차중계용 회원가입 구현 2025-11-13 16:06:39 +09:00
dohyeons a2e58c3848 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-13 12:15:19 +09:00
dohyeons ff21a84932 8080포트 제거 2025-11-11 18:42:35 +09:00
dohyeons 91a4401120 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream 2025-11-11 18:13:39 +09:00
dohyeons 1514af2383 빌드 시점에 NEXT_PUBLIC_API_URL 설정 2025-11-11 17:49:41 +09:00
dohyeons c669374156 NEXT_PUBLIC_API_URL에 포트 8080 추가 2025-11-11 17:20:17 +09:00
dohyeons cf2b5d4e80 url을 맞춰 수정 2025-11-11 16:59:16 +09:00
dohyeons 3153cf0383 헬스체크 설정 추가 2025-11-11 13:49:56 +09:00
dohyeons 198f9a6f2b 포트 수정 2025-11-11 13:14:25 +09:00
dohyeons b47f34c616 디렉터리 권한 부여 2025-11-11 12:44:28 +09:00
dohyeons 8508e64ab3 Fix: Update package-lock.json for uuid@9.0.1 2025-11-11 12:26:36 +09:00
dohyeons 12b5c4243a uuid버전 다운그레이드 2025-11-11 12:23:21 +09:00
dohyeons 802cda7348 권한 부여 2025-11-11 11:52:53 +09:00
dohyeons 70e97aa4a2 도커파일에 환경변수 설정(임시) 2025-11-11 11:26:26 +09:00
dohyeons f340b1ac05 log 디렉터리 권한 문제 수정 2025-11-11 10:13:41 +09:00
dohyeons 50dbf1f738 도커 파일 수정 2025-11-10 18:49:44 +09:00
15 changed files with 882 additions and 23 deletions

View File

@ -39,8 +39,10 @@ RUN npm ci && \
COPY frontend/ ./
# Next.js 프로덕션 빌드 (린트 비활성화)
# 빌드 시점에 환경변수 설정 (번들에 포함됨)
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
RUN npm run build:no-lint
# ------------------------------
@ -66,9 +68,19 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
# 업로드 디렉토리 생성 (백엔드용)
RUN mkdir -p /app/backend/uploads && \
chown -R nodejs:nodejs /app/backend/uploads
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성
# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함
RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \
/app/uploads /app/data && \
chown -R nodejs:nodejs /app/backend /app/uploads /app/data && \
chmod -R 777 /app/uploads /app/data && \
chmod -R 755 /app/backend
# 프론트엔드 standalone 모드를 위한 디렉토리 생성
RUN mkdir -p /app/frontend/data && \
chown -R nodejs:nodejs /app/frontend && \
chmod -R 755 /app/frontend
# 시작 스크립트 생성
RUN echo '#!/bin/sh' > /app/start.sh && \
@ -77,29 +89,44 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
echo 'cd /app/backend' >> /app/start.sh && \
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
echo 'node dist/app.js &' >> /app/start.sh && \
echo 'PORT=8080 node dist/app.js &' >> /app/start.sh && \
echo 'BACKEND_PID=$!' >> /app/start.sh && \
echo '' >> /app/start.sh && \
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
echo 'cd /app/frontend' >> /app/start.sh && \
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
echo 'npm start &' >> /app/start.sh && \
echo 'FRONTEND_PID=$!' >> /app/start.sh && \
echo '' >> /app/start.sh && \
echo '# 프로세스 모니터링' >> /app/start.sh && \
echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
echo 'PORT=3000 exec npm start' >> /app/start.sh && \
chmod +x /app/start.sh && \
chown nodejs:nodejs /app/start.sh
# ============================================================
# 환경변수 설정 (임시 조치)
# helm-charts의 values_logistream.yaml 관리자가 설정 완료 시 삭제 예정
# ============================================================
ENV NODE_ENV=production \
LOG_LEVEL=info \
HOST=0.0.0.0 \
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \
ENCRYPTION_KEY="ilshin-plm-mail-encryption-key-32characters-2024-secure" \
JWT_EXPIRES_IN="24h" \
CORS_CREDENTIALS="true" \
CORS_ORIGIN="https://logistream.kpslp.kr" \
KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \
ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \
NEXT_TELEMETRY_DISABLED="1" \
NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
# 비특권 사용자로 전환
USER nodejs
# 포트 노출
EXPOSE 3000 8080
# 헬스체크
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# 헬스체크 (백엔드와 프론트엔드 둘 다 확인)
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health && \
wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
# 컨테이너 시작
CMD ["/app/start.sh"]

View File

@ -34,7 +34,7 @@
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
"uuid": "^13.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},
"devDependencies": {
@ -10642,16 +10642,16 @@
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {

View File

@ -48,7 +48,7 @@
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
"uuid": "^13.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},
"devDependencies": {

View File

@ -384,4 +384,72 @@ export class AuthController {
});
}
}
/**
* POST /api/auth/signup
* API ()
*/
static async signup(req: Request, res: Response): Promise<void> {
try {
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
logger.info(`=== 회원가입 API 호출 ===`);
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}, vehicleType: ${vehicleType}`);
// 입력값 검증
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
res.status(400).json({
success: false,
message: "모든 필수 항목을 입력해주세요.",
error: {
code: "INVALID_INPUT",
details: "필수 입력값이 누락되었습니다.",
},
});
return;
}
// 회원가입 처리
const signupResult = await AuthService.signupUser({
userId,
password,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
vehicleType, // 차량 타입 추가
});
if (signupResult.success) {
logger.info(`회원가입 성공: ${userId}`);
res.status(201).json({
success: true,
message: "회원가입이 완료되었습니다.",
data: {
userId,
},
});
} else {
logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`);
res.status(400).json({
success: false,
message: signupResult.message || "회원가입에 실패했습니다.",
error: {
code: "SIGNUP_FAILED",
details: signupResult.message,
},
});
}
} catch (error: any) {
logger.error("회원가입 오류:", error);
res.status(500).json({
success: false,
message: "회원가입 처리 중 오류가 발생했습니다.",
error: {
code: "SIGNUP_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
},
});
}
}
}

View File

@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
*/
router.post("/refresh", AuthController.refreshToken);
/**
* POST /api/auth/signup
* API
*/
router.post("/signup", AuthController.signup);
export default router;

View File

@ -170,7 +170,8 @@ export class AuthService {
[userInfo.company_code || "ILSHIN"]
);
const companyName = companyResult.length > 0 ? companyResult[0].company_name : undefined;
const companyName =
companyResult.length > 0 ? companyResult[0].company_name : undefined;
// DB에서 조회한 원본 사용자 정보 상세 로그
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
@ -342,4 +343,129 @@ export class AuthService {
);
}
}
/**
*
* - user_info
* - vehicles ()
*/
static async signupUser(data: {
userId: string;
password: string;
userName: string;
phoneNumber: string;
licenseNumber: string;
vehicleNumber: string;
vehicleType?: string;
}): Promise<{ success: boolean; message?: string }> {
try {
const {
userId,
password,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
vehicleType,
} = data;
// 1. 중복 사용자 확인
const existingUser = await query<any>(
`SELECT user_id FROM user_info WHERE user_id = $1`,
[userId]
);
if (existingUser.length > 0) {
return {
success: false,
message: "이미 존재하는 아이디입니다.",
};
}
// 2. 중복 차량번호 확인
const existingVehicle = await query<any>(
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`,
[vehicleNumber]
);
if (existingVehicle.length > 0) {
return {
success: false,
message: "이미 등록된 차량번호입니다.",
};
}
// 3. 비밀번호 암호화 (MD5 - 기존 시스템 호환)
const crypto = require("crypto");
const hashedPassword = crypto
.createHash("md5")
.update(password)
.digest("hex");
// 4. 사용자 정보 저장 (user_info)
await query(
`INSERT INTO user_info (
user_id,
user_password,
user_name,
cell_phone,
license_number,
vehicle_number,
company_code,
user_type,
status,
regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
[
userId,
hashedPassword,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
"COMPANY_13", // 기본 회사 코드
null, // user_type: null
"active", // status: active
]
);
// 5. 차량 정보 저장 (vehicles) - 공차중계용
// status = 'off': 앱 미사용/로그아웃 상태
await query(
`INSERT INTO vehicles (
vehicle_number,
vehicle_type,
driver_name,
driver_phone,
status,
company_code,
user_id,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
[
vehicleNumber,
vehicleType || null,
userName,
phoneNumber,
"off", // 초기 상태: off (앱 미사용)
"COMPANY_13", // 기본 회사 코드
userId, // 사용자 ID 연결
]
);
logger.info(`회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
return {
success: true,
message: "회원가입이 완료되었습니다.",
};
} catch (error: any) {
logger.error("회원가입 오류:", error);
return {
success: false,
message: error.message || "회원가입 중 오류가 발생했습니다.",
};
}
}
}

View File

@ -0,0 +1,49 @@
"use client";
import { useSignup } from "@/hooks/useSignup";
import { LoginHeader } from "@/components/auth/LoginHeader";
import { SignupForm } from "@/components/auth/SignupForm";
import { LoginFooter } from "@/components/auth/LoginFooter";
/**
*
*/
export default function SignupPage() {
const {
formData,
isLoading,
error,
showPassword,
validationErrors,
touchedFields,
isFormValid,
handleInputChange,
handleBlur,
handleSignup,
togglePasswordVisibility,
} = useSignup();
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
<div className="w-full max-w-md space-y-8">
<LoginHeader />
<SignupForm
formData={formData}
isLoading={isLoading}
error={error}
showPassword={showPassword}
validationErrors={validationErrors}
touchedFields={touchedFields}
isFormValid={isFormValid}
onInputChange={handleInputChange}
onBlur={handleBlur}
onSubmit={handleSignup}
onTogglePassword={togglePasswordVisibility}
/>
<LoginFooter />
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { LoginFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
import { useRouter } from "next/navigation";
interface LoginFormProps {
formData: LoginFormData;
@ -28,6 +29,8 @@ export function LoginForm({
onSubmit,
onTogglePassword,
}: LoginFormProps) {
const router = useRouter();
return (
<Card className="border-0 shadow-xl">
<CardHeader className="space-y-1">
@ -97,6 +100,17 @@ export function LoginForm({
"로그인"
)}
</Button>
{/* 회원가입 버튼 */}
<Button
type="button"
variant="outline"
className="h-11 w-full font-medium"
onClick={() => router.push("/signup")}
disabled={isLoading}
>
</Button>
</form>
</CardContent>
</Card>

View File

@ -0,0 +1,263 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
import { SignupFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
import { useRouter } from "next/navigation";
interface SignupFormProps {
formData: SignupFormData;
isLoading: boolean;
error: string;
showPassword: boolean;
validationErrors: Record<string, string>;
touchedFields: Record<string, boolean>;
isFormValid: boolean;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
onSubmit: (e: React.FormEvent) => void;
onTogglePassword: () => void;
}
/**
*
*/
export function SignupForm({
formData,
isLoading,
error,
showPassword,
validationErrors,
touchedFields,
isFormValid,
onInputChange,
onBlur,
onSubmit,
onTogglePassword,
}: SignupFormProps) {
const router = useRouter();
return (
<Card className="border-0 shadow-xl">
<CardHeader className="space-y-1">
<CardTitle className="text-center text-2xl"></CardTitle>
<CardDescription className="text-center"> </CardDescription>
</CardHeader>
<CardContent>
<ErrorMessage message={error} />
<form onSubmit={onSubmit} className="space-y-4">
{/* 아이디 */}
<div className="space-y-2">
<Label htmlFor="userId"> *</Label>
<Input
id="userId"
name="userId"
type="text"
placeholder="아이디를 입력하세요"
value={formData.userId}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11"
required
/>
{touchedFields?.userId && validationErrors.userId && (
<p className="text-destructive text-xs">{validationErrors.userId}</p>
)}
</div>
{/* 비밀번호 */}
<div className="space-y-2">
<Label htmlFor="password"> *</Label>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="비밀번호를 입력하세요"
value={formData.password}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11 pr-10"
required
/>
<button
type="button"
onClick={onTogglePassword}
className="absolute top-1/2 right-3 -translate-y-1/2 transform cursor-pointer text-slate-400 transition-colors hover:text-slate-600"
disabled={isLoading}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{touchedFields?.password && validationErrors.password && (
<p className="text-destructive text-xs">{validationErrors.password}</p>
)}
</div>
{/* 비밀번호 확인 */}
<div className="space-y-2">
<Label htmlFor="passwordConfirm"> *</Label>
<div className="relative">
<Input
id="passwordConfirm"
name="passwordConfirm"
type={showPassword ? "text" : "password"}
placeholder="비밀번호를 다시 입력하세요"
value={formData.passwordConfirm}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11 pr-10"
required
/>
<button
type="button"
onClick={onTogglePassword}
className="absolute top-1/2 right-3 -translate-y-1/2 transform cursor-pointer text-slate-400 transition-colors hover:text-slate-600"
disabled={isLoading}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{touchedFields?.passwordConfirm && validationErrors.passwordConfirm && (
<p className="text-destructive text-xs">{validationErrors.passwordConfirm}</p>
)}
</div>
{/* 이름 */}
<div className="space-y-2">
<Label htmlFor="userName"> *</Label>
<Input
id="userName"
name="userName"
type="text"
placeholder="이름을 입력하세요"
value={formData.userName}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11"
required
/>
{touchedFields?.userName && validationErrors.userName && (
<p className="text-destructive text-xs">{validationErrors.userName}</p>
)}
</div>
{/* 연락처 */}
<div className="space-y-2">
<Label htmlFor="phoneNumber"> *</Label>
<Input
id="phoneNumber"
name="phoneNumber"
type="text"
placeholder="010-1234-5678"
value={formData.phoneNumber}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11"
required
/>
{touchedFields?.phoneNumber && validationErrors.phoneNumber && (
<p className="text-destructive text-xs">{validationErrors.phoneNumber}</p>
)}
</div>
{/* 면허번호 */}
<div className="space-y-2">
<Label htmlFor="licenseNumber"> *</Label>
<Input
id="licenseNumber"
name="licenseNumber"
type="text"
placeholder="12-34-567890-12"
value={formData.licenseNumber}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11"
required
/>
{touchedFields?.licenseNumber && validationErrors.licenseNumber && (
<p className="text-destructive text-xs">{validationErrors.licenseNumber}</p>
)}
</div>
{/* 차량번호 */}
<div className="space-y-2">
<Label htmlFor="vehicleNumber"> *</Label>
<Input
id="vehicleNumber"
name="vehicleNumber"
type="text"
placeholder="12가1234"
value={formData.vehicleNumber}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11"
required
/>
{touchedFields?.vehicleNumber && validationErrors.vehicleNumber && (
<p className="text-destructive text-xs">{validationErrors.vehicleNumber}</p>
)}
</div>
{/* 차량 타입 */}
<div className="space-y-2">
<Label htmlFor="vehicleType"> *</Label>
<Input
id="vehicleType"
name="vehicleType"
type="text"
placeholder="예: 1톤, 5톤, 11톤, 25톤"
value={formData.vehicleType}
onChange={onInputChange}
onBlur={onBlur}
disabled={isLoading}
className="h-11"
required
/>
{touchedFields?.vehicleType && validationErrors.vehicleType && (
<p className="text-destructive text-xs">{validationErrors.vehicleType}</p>
)}
</div>
{/* 회원가입 버튼 */}
<Button
type="submit"
className="h-11 w-full bg-slate-900 font-medium text-white hover:bg-slate-800"
disabled={isLoading || !isFormValid}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"회원가입(공차중계)"
)}
</Button>
{/* 로그인으로 돌아가기 버튼 */}
<Button
type="button"
variant="outline"
className="h-11 w-full font-medium"
onClick={() => router.push("/login")}
disabled={isLoading}
>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@ -64,7 +64,10 @@ export const useLogin = () => {
// 로컬 스토리지에서 토큰 가져오기
const token = localStorage.getItem("authToken");
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
// API URL 동적 계산 (매번 호출 시마다)
const apiBaseUrl = API_BASE_URL;
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",

243
frontend/hooks/useSignup.ts Normal file
View File

@ -0,0 +1,243 @@
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { SignupFormData } from "@/types/auth";
import { signupUser } from "@/lib/api/auth";
import { useToast } from "@/hooks/use-toast";
/**
*
*/
const validators = {
// 연락처: 010-1234-5678 형식
phoneNumber: (value: string): string | null => {
const phoneRegex = /^01[0-9]-\d{3,4}-\d{4}$/;
if (!value) return "연락처를 입력해주세요";
if (!phoneRegex.test(value)) return "올바른 연락처 형식이 아닙니다 (예: 010-1234-5678)";
return null;
},
// 면허번호: 12-34-567890-12 형식
licenseNumber: (value: string): string | null => {
const licenseRegex = /^\d{2}-\d{2}-\d{6}-\d{2}$/;
if (!value) return "면허번호를 입력해주세요";
if (!licenseRegex.test(value)) return "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)";
return null;
},
// 차량번호: 12가1234 형식
vehicleNumber: (value: string): string | null => {
const vehicleRegex = /^\d{2,3}[가-힣]\d{4}$/;
if (!value) return "차량번호를 입력해주세요";
if (!vehicleRegex.test(value)) return "올바른 차량번호 형식이 아닙니다 (예: 12가1234)";
return null;
},
// 차량 타입: 필수 입력
vehicleType: (value: string): string | null => {
if (!value) return "차량 타입을 입력해주세요";
if (value.length < 2) return "차량 타입은 2자 이상이어야 합니다";
return null;
},
// 아이디: 4자 이상
userId: (value: string): string | null => {
if (!value) return "아이디를 입력해주세요";
if (value.length < 4) return "아이디는 4자 이상이어야 합니다";
return null;
},
// 비밀번호: 6자 이상
password: (value: string): string | null => {
if (!value) return "비밀번호를 입력해주세요";
if (value.length < 6) return "비밀번호는 6자 이상이어야 합니다";
return null;
},
// 비밀번호 확인: password와 일치해야 함
passwordConfirm: (value: string, password?: string): string | null => {
if (!value) return "비밀번호 확인을 입력해주세요";
if (password && value !== password) return "비밀번호가 일치하지 않습니다";
return null;
},
// 이름: 2자 이상
userName: (value: string): string | null => {
if (!value) return "이름을 입력해주세요";
if (value.length < 2) return "이름은 2자 이상이어야 합니다";
return null;
},
};
/**
*
*/
export function useSignup() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState<SignupFormData>({
userId: "",
password: "",
passwordConfirm: "",
userName: "",
phoneNumber: "",
licenseNumber: "",
vehicleNumber: "",
vehicleType: "",
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [touchedFields, setTouchedFields] = useState<Record<string, boolean>>({});
const [isFormValid, setIsFormValid] = useState(false);
// 유효성 검사 실행
const validateField = useCallback((name: string, value: string, password?: string) => {
const validator = validators[name as keyof typeof validators];
if (validator) {
// passwordConfirm 검증 시 password도 함께 전달
if (name === "passwordConfirm") {
return validator(value, password);
}
return validator(value);
}
return null;
}, []);
// 전체 폼 유효성 검사
const validateForm = useCallback(() => {
const errors: Record<string, string> = {};
let hasErrors = false;
Object.keys(formData).forEach((key) => {
const error = validateField(key, formData[key as keyof SignupFormData], formData.password);
if (error) {
errors[key] = error;
hasErrors = true;
}
});
setValidationErrors(errors);
setIsFormValid(!hasErrors && Object.values(formData).every((value) => value !== ""));
}, [formData, validateField]);
// 입력 변경 핸들러
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const newFormData = { ...formData, [name]: value };
setFormData(newFormData);
// touched된 필드만 실시간 유효성 검사
if (touchedFields[name]) {
const fieldError = validateField(name, value, newFormData.password);
setValidationErrors((prev) => ({
...prev,
[name]: fieldError || "",
}));
}
// password가 변경되면 passwordConfirm도 다시 검증
if (name === "password" && touchedFields.passwordConfirm) {
const confirmError = validateField("passwordConfirm", newFormData.passwordConfirm, value);
setValidationErrors((prev) => ({
...prev,
passwordConfirm: confirmError || "",
}));
}
setError("");
},
[validateField, touchedFields, formData],
);
// 포커스 아웃 핸들러 (Blur)
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// 필드를 touched로 표시
setTouchedFields((prev) => ({ ...prev, [name]: true }));
// 유효성 검사 실행
const fieldError = validateField(name, value, formData.password);
setValidationErrors((prev) => ({
...prev,
[name]: fieldError || "",
}));
},
[validateField, formData.password],
);
// 회원가입 제출
const handleSignup = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// 모든 필드를 touched로 표시 (제출 시 모든 에러 표시)
const allTouched = Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {});
setTouchedFields(allTouched);
// 최종 유효성 검사
validateForm();
if (!isFormValid) {
setError("입력 정보를 다시 확인해주세요");
return;
}
setIsLoading(true);
try {
const response = await signupUser(formData);
if (response.success) {
toast({
title: "회원가입 성공",
description: "로그인 페이지로 이동합니다",
});
// 1초 후 로그인 페이지로 이동
setTimeout(() => {
router.push("/login");
}, 1000);
} else {
setError(response.message || "회원가입에 실패했습니다");
}
} catch (err: any) {
console.error("회원가입 오류:", err);
setError(err.message || "회원가입 중 오류가 발생했습니다");
} finally {
setIsLoading(false);
}
},
[formData, isFormValid, router, toast, validateForm],
);
// 비밀번호 표시 토글
const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
// 폼 데이터 변경 시 유효성 검사 실행
useEffect(() => {
validateForm();
}, [formData, validateForm]);
return {
formData,
isLoading,
error,
showPassword,
validationErrors,
touchedFields,
isFormValid,
handleInputChange,
handleBlur,
handleSignup,
togglePasswordVisibility,
};
}

21
frontend/lib/api/auth.ts Normal file
View File

@ -0,0 +1,21 @@
import { apiClient } from "./client";
import { SignupFormData, SignupResponse } from "@/types/auth";
/**
* API
*/
export async function signupUser(data: SignupFormData): Promise<SignupResponse> {
try {
const response = await apiClient.post<SignupResponse>("/auth/signup", data);
return response.data;
} catch (error: any) {
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
message: error.message || "회원가입 중 오류가 발생했습니다",
};
}
}

View File

@ -13,6 +13,11 @@ const getApiBaseUrl = (): string => {
const currentPort = window.location.port;
const protocol = window.location.protocol;
// 프로덕션 환경: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
if (currentHost === "logistream.kpslp.kr") {
return `${protocol}//${currentHost}/api`;
}
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api";
@ -27,11 +32,21 @@ const getApiBaseUrl = (): string => {
}
}
// 3. 기본값
return "http://localhost:8080/api";
// 3. 기본값 (서버사이드 빌드 시)
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api";
};
export const API_BASE_URL = getApiBaseUrl();
// 매번 호출 시 동적으로 계산 (getter 함수)
export const getAPIBaseURL = getApiBaseUrl;
// 하위 호환성을 위해 유지하되, 동적으로 계산되도록 수정
let _cachedApiBaseUrl: string | null = null;
export const API_BASE_URL = (() => {
if (_cachedApiBaseUrl === null || typeof window !== "undefined") {
_cachedApiBaseUrl = getApiBaseUrl();
}
return _cachedApiBaseUrl;
})();
// 이미지 URL을 완전한 URL로 변환하는 함수
export const getFullImageUrl = (imagePath: string): string => {

View File

@ -8,6 +8,11 @@ export function getApiUrl(endpoint: string): string {
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
// 프로덕션: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
if (hostname === "logistream.kpslp.kr") {
return `https://logistream.kpslp.kr${endpoint}`;
}
// 프로덕션: v1.vexplor.com → https://api.vexplor.com
if (hostname === "v1.vexplor.com") {
return `https://api.vexplor.com${endpoint}`;

View File

@ -7,6 +7,17 @@ export interface LoginFormData {
password: string;
}
export interface SignupFormData {
userId: string;
password: string;
passwordConfirm: string;
userName: string;
phoneNumber: string;
licenseNumber: string;
vehicleNumber: string;
vehicleType: string; // 차량 타입 (예: 1톤, 5톤, 11톤)
}
export interface LoginResponse {
success: boolean;
message?: string;
@ -18,6 +29,14 @@ export interface LoginResponse {
errorCode?: string;
}
export interface SignupResponse {
success: boolean;
message?: string;
data?: {
userId: string;
};
}
export interface AuthStatus {
isLoggedIn: boolean;
isAdmin?: boolean;