From deb9bcc8cf67c0021391a630f56c683d41c81e8e Mon Sep 17 00:00:00 2001 From: dongsilove Date: Wed, 12 Nov 2025 23:30:26 +0900 Subject: [PATCH 01/83] =?UTF-8?q?wr:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/app/auth/login/page.tsx | 2 + .../app/auth/login/{route.ts => route-bak.ts} | 0 apps/wr/package.json | 4 +- apps/wr/src/entities/user/api/user-service.ts | 225 +++++ apps/wr/src/entities/user/index.ts | 17 + .../src/entities/user/model/user-interface.ts | 362 +++++++ .../user/ui/password-reset-dialog.tsx | 124 +++ apps/wr/src/features/auth/login/index.ts | 1 + .../src/features/auth/login/ui/OtpModal.tsx | 153 +++ .../features/auth/login/ui/SignupModal.tsx | 701 ++++++++++++++ .../src/features/auth/login/ui/TwoFAModal.tsx | 198 ++++ .../auth/login/ui/UserSignupModal.tsx | 901 ++++++++++++++++++ .../src/features/auth/login/ui/login-page.tsx | 321 +++++++ apps/wr/src/features/auth/login/ui/login.css | 310 ++++++ .../features/auth/login/ui/signup-modal.css | 277 ++++++ .../auth/login/ui/user-signup-modal.css | 436 +++++++++ apps/wr/src/shared/api/signup-client.ts | 71 ++ apps/wr/src/shared/config/api.ts | 29 + apps/wr/src/shared/ui/form.tsx | 175 ++++ pnpm-lock.yaml | 6 + 20 files changed, 4312 insertions(+), 1 deletion(-) create mode 100644 apps/wr/app/auth/login/page.tsx rename apps/wr/app/auth/login/{route.ts => route-bak.ts} (100%) create mode 100644 apps/wr/src/entities/user/api/user-service.ts create mode 100644 apps/wr/src/entities/user/index.ts create mode 100644 apps/wr/src/entities/user/model/user-interface.ts create mode 100644 apps/wr/src/entities/user/ui/password-reset-dialog.tsx create mode 100644 apps/wr/src/features/auth/login/index.ts create mode 100644 apps/wr/src/features/auth/login/ui/OtpModal.tsx create mode 100644 apps/wr/src/features/auth/login/ui/SignupModal.tsx create mode 100644 apps/wr/src/features/auth/login/ui/TwoFAModal.tsx create mode 100644 apps/wr/src/features/auth/login/ui/UserSignupModal.tsx create mode 100644 apps/wr/src/features/auth/login/ui/login-page.tsx create mode 100644 apps/wr/src/features/auth/login/ui/login.css create mode 100644 apps/wr/src/features/auth/login/ui/signup-modal.css create mode 100644 apps/wr/src/features/auth/login/ui/user-signup-modal.css create mode 100644 apps/wr/src/shared/api/signup-client.ts create mode 100644 apps/wr/src/shared/ui/form.tsx diff --git a/apps/wr/app/auth/login/page.tsx b/apps/wr/app/auth/login/page.tsx new file mode 100644 index 000000000..0dbc42284 --- /dev/null +++ b/apps/wr/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/src/features/auth/login"; +export default LoginPage; diff --git a/apps/wr/app/auth/login/route.ts b/apps/wr/app/auth/login/route-bak.ts similarity index 100% rename from apps/wr/app/auth/login/route.ts rename to apps/wr/app/auth/login/route-bak.ts diff --git a/apps/wr/package.json b/apps/wr/package.json index 0c2cbb93e..0db58e871 100644 --- a/apps/wr/package.json +++ b/apps/wr/package.json @@ -14,10 +14,12 @@ "@common/assets": "workspace:*", "@common/business_components": "workspace:*", "@common/components": "workspace:*", - "@hookform/resolvers": "^4.1.3", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", + "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "3.5.1-alpha.1", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", diff --git a/apps/wr/src/entities/user/api/user-service.ts b/apps/wr/src/entities/user/api/user-service.ts new file mode 100644 index 000000000..23d509b87 --- /dev/null +++ b/apps/wr/src/entities/user/api/user-service.ts @@ -0,0 +1,225 @@ +import { client } from "@/src/shared/api"; +import { ENDPOINT } from "@/src/shared/config"; +import type { + UserService, + UserSearchParams, + UserList, + UserDetailResponse, + UserSttsStatsList, + UserUpdateData, + UserPasswordUpdateData, + UserFindPasswordUpdateData, + UserPasswordMatchData, + UserUpdateInfoData, + UserSaveData, + UserFindPasswordSaveData, + UserVerifyData, + UserEmailData, + UserIdData, + AdminUserList, +} from "../model/user-interface"; + +export class UserApiService implements UserService { + // 사용자 목록 조회 + public async findPageUser(params: UserSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}`, + { params }, + ); + return response.data; + } + + // 사용자 단건 조회 + public async findByUserUnqId(userUnqId: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + ); + return response.data; + } + + // 로그인한 사용자 정보 조회 + public async findByUserUnqIdByLogin(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + return response.data; + } + + // 사용자 회원상태 통계 조회 + public async selectUserSttsStats(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, + ); + return response.data; + } + + // 사용자 저장 + public async saveUser(data: UserSaveData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}`, + data, + ); + return response.data; + } + + // 사용자 수정 + public async updateUser( + userUnqId: string, + data: UserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 삭제 + public async deleteUser(userUnqId: string): Promise { + const response = await client.delete( + `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 처리 + public async lockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 해제처리 + public async unlockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, + ); + return response.data; + } + + // 리프레시 토큰 정보 재발급 + public async refreshToken(): Promise { + await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); + } + + // 사용자 비밀번호 변경 + public async updatePassword(data: UserPasswordUpdateData): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/update`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 초기화(관리자용) + public async initializePassword( + userUnqId: string, + data: UserPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 변경 + public async changePassword( + data: UserFindPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/change`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 확인 + public async matchPassword(data: UserPasswordMatchData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/match`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 + public async findPasswordUser( + data: UserFindPasswordSaveData, + ): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/find`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 유효성 확인 + public async validPassword(token: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, + ); + return response.data; + } + + // 사용자 회원정보 변경 + public async updateInfo( + userUnqId: string, + data: UserUpdateInfoData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 승인처리 + public async applyUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, + ); + return response.data; + } + + // 사용자 회원탈퇴 + public async leaveUser(data: UserVerifyData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/leave`, + data, + ); + return response.data; + } + + // 사용자 이메일 중복 체크 + public async existsEmail(data: UserEmailData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + data, + ); + return response.data; + } + + // 사용자 아이디 중복 체크 + public async existsUserId(data: UserIdData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + data, + ); + return response.data; + } + + // 관리자 목록 조회 + public async findPageAdminUser( + params: UserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, + { params }, + ); + return response.data; + } +} + +export const UserApi = new UserApiService(); diff --git a/apps/wr/src/entities/user/index.ts b/apps/wr/src/entities/user/index.ts new file mode 100644 index 000000000..8927256f9 --- /dev/null +++ b/apps/wr/src/entities/user/index.ts @@ -0,0 +1,17 @@ +export { UserApi } from "./api/user-service"; +export { + USER_STATE_CODE, + userSaveSchema, + userPasswordUpdateSchema, + userUpdateSchema, + type UserStateCode, + type UserSearchParams, + type UserListResponse, + type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, + type UserUpdateData, + type AdminUserList, + type AdminUserListResponse, +} from "./model/user-interface"; +export { PasswordResetDialog } from "./ui/password-reset-dialog"; diff --git a/apps/wr/src/entities/user/model/user-interface.ts b/apps/wr/src/entities/user/model/user-interface.ts new file mode 100644 index 000000000..be4eff5d2 --- /dev/null +++ b/apps/wr/src/entities/user/model/user-interface.ts @@ -0,0 +1,362 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/src/shared/model"; + +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +export type UserStateCode = + (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + +// 사용자 검색 파라미터 +export interface UserSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + userStateCode?: string; + aprvYn?: string; + grpId?: string; + frDateYmd?: string; + toDateYmd?: string; + userName?: string; +} + +// 사용자 목록 응답 인터페이스 +export interface UserListResponse { + userId: string; + userName: string; + email: string; + roleId: string; + roleName: string; + userStateCode: UserStateCode; + userStateCodeName: string; + lastLoginDate: string; + loginFailCount: number; + userUnqId: string; + mphn: string; + telno: string; + aprvYn: string; + regDt: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + aprvYmd: string; +} + +// 사용자 상세 응답 인터페이스 +export interface UserDetailResponse { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: UserStateCode; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; + twoFactorEnabled: boolean; + adminRoleYn: string; + instSeCd: string; + aprvYmd: string; + enoCd: string; + dtlInstCd: string; + laeSeNm: string; + ogdpSeNm: string; + instSeNm: string; + userStateNm: string; + pswdHntNm: string; + ecoNm: string; + dtlInstNm: string; +} + +// 사용자 상태 통계 응답 인터페이스 +export interface UserSttsStatsResponse { + sttsCd: string; + sttsNm: string; + cnt: number; +} + +// 관리자 목록 응답 인터페이스 +export interface AdminUserListResponse { + userUnqId: string; + userId: string; + userName: string; + groupId: string; + groupNm: string; + userStateCode: string; + userStateCodeName: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + dtlInstCd: string; + dtlInstNm: string; + mdfr: string; + mdfrNm: string; + mdfcnDt: string; +} + +// 사용자 수정 요청 스키마 +export const userUpdateSchema = z.object({ + userId: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + roleId: z.string().optional(), + userStateCode: z.string(), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + aprvYn: z.string(), + autzrId: z.string().uuid().optional(), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + aprvYmd: z.string().max(8).optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 변경 스키마 +export const userPasswordUpdateSchema = z + .object({ + password: z.string().optional(), + provider: z.string().optional(), + token: z.string().optional(), + newPassword: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["newPasswordConfirm"], + }); + +// 사용자 비밀번호 찾기 변경 스키마 +export const userFindPasswordUpdateSchema = z.object({ + tokenValue: z.string(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 비밀번호 확인 스키마 +export const userPasswordMatchSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 회원정보 변경 스키마 +export const userUpdateInfoSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), +}); + +// 사용자 저장 스키마 +export const userSaveSchema = z.object({ + userId: z.string(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 찾기 저장 스키마 +export const userFindPasswordSaveSchema = z.object({ + userName: z.string(), + emailAddr: z.string().email(), + mainUrl: z.string(), + changePasswordUrl: z.string(), +}); + +// 사용자 인증 요청 스키마 +export const userVerifySchema = z.object({ + password: z + .string() + .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), +}); + +// 사용자 이메일 요청 스키마 +export const userEmailSchema = z.object({ + userId: z.string().optional(), + email: z.string().email(), +}); + +// 사용자 ID 요청 스키마 +export const userIdSchema = z.object({ + userId: z.string(), +}); + +// Zod 스키마 타입 정의 +export type UserUpdateData = z.infer; +export type UserPasswordUpdateData = z.infer; +export type UserFindPasswordUpdateData = z.infer< + typeof userFindPasswordUpdateSchema +>; +export type UserPasswordMatchData = z.infer; +export type UserUpdateInfoData = z.infer; +export type UserSaveData = z.infer; +export type UserFindPasswordSaveData = z.infer< + typeof userFindPasswordSaveSchema +>; +export type UserVerifyData = z.infer; +export type UserEmailData = z.infer; +export type UserIdData = z.infer; + +// 페이지네이션 타입 정의 +export type UserList = PageableList; +export type UserSttsStatsList = UserSttsStatsResponse[]; +export type AdminUserList = PageableList; + +// 사용자 서비스 인터페이스 +export interface UserService { + // 사용자 목록 조회 + findPageUser: (params: UserSearchParams) => Promise; + + // 사용자 단건 조회 + findByUserUnqId: (userUnqId: string) => Promise; + + // 로그인한 사용자 정보 조회 + findByUserUnqIdByLogin: () => Promise; + + // 사용자 회원상태 통계 조회 + selectUserSttsStats: () => Promise; + + // 사용자 저장 + saveUser: (data: UserSaveData) => Promise; + + // 사용자 수정 + updateUser: (userUnqId: string, data: UserUpdateData) => Promise; + + // 사용자 삭제 + deleteUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 처리 + lockUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 해제처리 + unlockUser: (userUnqId: string) => Promise; + + // 리프레시 토큰 정보 재발급 + refreshToken: () => Promise; + + // 사용자 비밀번호 변경 + updatePassword: (data: UserPasswordUpdateData) => Promise; + + // 사용자 비밀번호 초기화(관리자용) + initializePassword: ( + userUnqId: string, + data: UserPasswordUpdateData, + ) => Promise; + + // 사용자 비밀번호 찾기 변경 + changePassword: (data: UserFindPasswordUpdateData) => Promise; + + // 사용자 비밀번호 확인 + matchPassword: (data: UserPasswordMatchData) => Promise; + + // 사용자 비밀번호 찾기 + findPasswordUser: (data: UserFindPasswordSaveData) => Promise; + + // 사용자 비밀번호 찾기 유효성 확인 + validPassword: (token: string) => Promise; + + // 사용자 회원정보 변경 + updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; + + // 사용자 승인처리 + applyUser: (userUnqId: string) => Promise; + + // 사용자 회원탈퇴 + leaveUser: (data: UserVerifyData) => Promise; + + // 사용자 이메일 중복 체크 + existsEmail: (data: UserEmailData) => Promise; + + // 사용자 아이디 중복 체크 + existsUserId: (data: UserIdData) => Promise; + + // 관리자 목록 조회 + findPageAdminUser: (params: UserSearchParams) => Promise; +} diff --git a/apps/wr/src/entities/user/ui/password-reset-dialog.tsx b/apps/wr/src/entities/user/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..91dc0dcfb --- /dev/null +++ b/apps/wr/src/entities/user/ui/password-reset-dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + DialogTrigger, +} from "@common/components"; +import { + UserApi, + userPasswordUpdateSchema, + type UserPasswordUpdateData, +} from "@/src/entities/user"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/src/shared/ui/form"; +import { useAlertStore, useMutationWrapper } from "@/src/shared/lib"; + +interface PasswordResetDialogProps { + userUnqId: string; + children: ReactNode; +} + +export function PasswordResetDialog({ + userUnqId, + children, +}: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + provider: "password", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다.", () => { + setOpen(false); + form.reset(); + }); + }, + }); + + return ( + + {children} + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/wr/src/features/auth/login/index.ts b/apps/wr/src/features/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/wr/src/features/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/wr/src/features/auth/login/ui/OtpModal.tsx b/apps/wr/src/features/auth/login/ui/OtpModal.tsx new file mode 100644 index 000000000..2c216faaf --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/OtpModal.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/src/shared/config"; +import { tokenManager } from "@/src/entities/auth/lib/token-manager"; +import { + Button, + Dialog, + DialogContent, +} from "@common/components"; + +interface OtpModalProps { + onClose: () => void; + onSuccess: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) { + const [otp, setOtp] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + try { + setIsLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + console.log("tempToken from localStorage:", tempToken); + + if (!tempToken) { + setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요."); + return; + } + + // Proxy를 통해 OTP 인증 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + otpCode: otp, + tempToken + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || 'OTP 인증 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("OTP verification response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + console.log("Response data keys:", Object.keys(data)); + console.log("Full response data:", JSON.stringify(data, null, 2)); + + // 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음 + const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token']; + + console.log("Extracted accessToken:", !!accessToken); + console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "..."); + + // access-token이 있는 경우 저장 + if (accessToken) { + console.log("Saving access token from OTP verification"); + tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장 + + // 리프레시 토큰도 확인 + const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token; + if (refreshToken) { + console.log("Saving refresh token"); + tokenManager.setToken(REFRESH_TOKEN, refreshToken); + } + + // userId는 원래 로그인 응답에서 가져온 값 사용 + const originalUserId = data.userId; + if (originalUserId) { + console.log("Saving userId from original login response"); + tokenManager.setToken(AUTH_USER_ID, originalUserId); + } + + console.log("All tokens saved successfully"); + } else { + console.log("No access token in OTP response"); + console.log("Available data keys:", Object.keys(data)); + console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`)); + } + + onSuccess(); + } catch (error: unknown) { + console.error("OTP verification error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("OTP 인증 실패"); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + onClose(); + }; + + return ( + + +
+

OTP 인증

+ +
+ setOtp(e.target.value)} + disabled={isLoading} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/wr/src/features/auth/login/ui/SignupModal.tsx b/apps/wr/src/features/auth/login/ui/SignupModal.tsx new file mode 100644 index 000000000..00a244e13 --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/SignupModal.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { UserSaveData } from "@/src/entities/user"; +import { ENDPOINT, BASE_URL } from "@/src/shared/config"; +import { SignupClient } from "@/src/shared/api/signup-client"; +import "./signup-modal.css"; + +interface SignupModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onClose: () => void; + onSuccess: () => void; +} + +function SignupModal({ open, onClose, onSuccess }: SignupModalProps) { + const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement'); + const [agreed, setAgreed] = useState(false); + const [formData, setFormData] = useState({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingId, setIsCheckingId] = useState(false); + const [isCheckingEmail, setIsCheckingEmail] = useState(false); + const [idAvailable, setIdAvailable] = useState(null); + const [emailAvailable, setEmailAvailable] = useState(null); + + // 모달이 닫힐 때 초기화 + useEffect(() => { + if (!open) { + setCurrentStep('agreement'); + setAgreed(false); + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } + }, [open]); + + const handleAgreementChange = (e: React.ChangeEvent) => { + setAgreed(e.target.checked); + }; + + const handleNextStep = () => { + if (agreed) { + setCurrentStep('signup'); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // 에러 초기화 + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: "" })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + // 필수 필드 검증 + if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; + if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; + if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; + if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; + if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요."; + if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; + if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; + + // 비밀번호 확인 + if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; + } + + // 비밀번호 정규식 검증 + const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; + if (formData.password && !passwordRegex.test(formData.password)) { + newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; + } + + // 이메일 형식 검증 + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "올바른 이메일 형식을 입력해주세요."; + } + + // 중복 체크 검증 + if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다."; + if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다."; + + setErrors(newErrors); + const isValid = Object.keys(newErrors).length === 0; + console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); + return isValid; + }; + + const checkUserIdDuplicate = async () => { + if (!formData.userId) { + setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." })); + return; + } + + setIsCheckingId(true); + try { + // SignupClient를 통해 중복 체크 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + { userId: formData.userId } + ); + + const exists = response.data; + setIdAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." })); + } else { + setErrors(prev => ({ ...prev, userId: "" })); + } + } catch (error) { + console.error("아이디 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingId(false); + } + }; + + const checkEmailDuplicate = async () => { + if (!formData.email) { + setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." })); + return; + } + + setIsCheckingEmail(true); + try { + // SignupClient를 통해 중복 체크 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + { email: formData.email } + ); + + const exists = response.data; + setEmailAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." })); + } else { + setErrors(prev => ({ ...prev, email: "" })); + } + } catch (error) { + console.error("이메일 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingEmail(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log("회원가입 버튼 클릭됨"); + + if (!validateForm()) { + console.log("폼 검증 실패"); + return; + } + + setIsLoading(true); + try { + const signupData: UserSaveData = { + userId: formData.userId, + userName: formData.userName, + email: formData.email || undefined, + password: formData.password, + pswdHnt: formData.pswdHnt, + pswdAns: formData.pswdAns || undefined, + mphn: formData.mphn || undefined, + telno: formData.telno || undefined, + zip: formData.zip || undefined, + addr: formData.addr || undefined, + daddr: formData.daddr || undefined, + laeSeCd: formData.laeSeCd, + ogdpSeCd: formData.ogdpSeCd, + expryYmd: "", + instSeCd: formData.instSeCd || undefined, + dtlInstCd: formData.dtlInstCd || undefined, + enoCd: formData.enoCd || undefined, + }; + + // SignupClient를 통해 익명사용자 회원가입 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`, + signupData + ); + + console.log("회원가입 성공:", response.data); + + // 성공 시 모달 닫기 및 콜백 호출 + onSuccess(); + onClose(); + + // 폼 초기화 + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + + } catch (error: unknown) { + console.error("회원가입 오류:", error); + if (error instanceof Error) { + setErrors(prev => ({ ...prev, submit: error.message })); + } else { + setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." })); + } + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}

+ +
+ + {currentStep === 'agreement' ? ( +
+
+

개인정보 수집 및 이용 동의

+

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

+ +

1. 수집하는 개인정보 항목

+
    +
  • 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분
  • +
  • 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호
  • +
+ +

2. 개인정보의 수집 및 이용목적

+
    +
  • 회원가입 및 관리
  • +
  • 서비스 제공 및 운영
  • +
  • 고객상담 및 문의응답
  • +
+ +

3. 개인정보의 보유 및 이용기간

+

회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)

+ +

4. 동의 거부권 및 거부 시 불이익

+

개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.

+
+ +
+ +
+ +
+ + +
+
+ ) : ( +
+
+

기본 정보

+ +
+
+ +
+ + +
+ {idAvailable === true && 사용 가능한 아이디입니다.} + {errors.userId && {errors.userId}} +
+
+ +
+
+ + + {errors.userName && {errors.userName}} +
+
+ +
+
+ +
+ + +
+ {emailAvailable === true && 사용 가능한 이메일입니다.} + {errors.email && {errors.email}} +
+
+ +
+
+ + + {errors.password && {errors.password}} +
+
+ +
+
+ + + {errors.confirmPassword && {errors.confirmPassword}} +
+
+ +
+
+ + + {errors.pswdHnt && {errors.pswdHnt}} +
+
+ +
+
+ + + {errors.pswdAns && {errors.pswdAns}} +
+
+
+ +
+

연락처 정보

+ +
+
+ + + {errors.mphn && {errors.mphn}} +
+
+ +
+
+ + + {errors.telno && {errors.telno}} +
+
+
+ +
+

주소 정보

+ +
+
+ + + {errors.zip && {errors.zip}} +
+
+ +
+
+ + + {errors.addr && {errors.addr}} +
+
+ +
+
+ + + {errors.daddr && {errors.daddr}} +
+
+
+ +
+

기관 정보

+ +
+
+ + + {errors.laeSeCd && {errors.laeSeCd}} +
+
+ +
+
+ + + {errors.ogdpSeCd && {errors.ogdpSeCd}} +
+
+ +
+
+ + + {errors.instSeCd && {errors.instSeCd}} +
+
+ +
+
+ + + {errors.dtlInstCd && {errors.dtlInstCd}} +
+
+
+ + {errors.submit && ( +
+
{errors.submit}
+
+ )} + +
+ + + +
+
+ )} +
+
+ ); +} + +export default SignupModal; diff --git a/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx b/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx new file mode 100644 index 000000000..d66747b0c --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { + Button, + Dialog, + DialogContent, +} from "@common/components"; + +interface TwoFAModalProps { + onClose: () => void; + onSuccess: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) { + const [qrImage, setQrImage] = useState(""); + const [secret, setSecret] = useState(""); + const [otp, setOtp] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSetupLoading, setIsSetupLoading] = useState(true); + + useEffect(() => { + const fetchQR = async () => { + try { + setIsSetupLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + if (!tempToken) { + setError("임시 토큰이 없습니다"); + return; + } + + // Proxy를 통해 2FA setup 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tempToken }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || 'QR코드 발급 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA setup response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + + // Proxy 응답에서 QR 데이터 추출 + setQrImage(data.qrImage); + setSecret(data.secret); + } catch (error: unknown) { + console.error("2FA setup error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("QR코드 발급 실패"); + } + } finally { + setIsSetupLoading(false); + } + }; + + if (open) { + fetchQR(); + } + }, [open]); + + const handleEnable2FA = async () => { + try { + setIsLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + if (!tempToken) { + setError("임시 토큰이 없습니다"); + return; + } + + // Proxy를 통해 2FA enable 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': tempToken, + }, + body: JSON.stringify({ + otpCode: otp, + tempToken: tempToken, + secret: secret, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || '2FA 등록 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA enable response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + console.log("Response data keys:", Object.keys(data)); + + // 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리) + console.log("2FA setup completed successfully"); + + onSuccess(); + } catch (error: unknown) { + console.error("2FA enable error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("2FA 등록 실패"); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + onClose(); + }; + + return ( + + +
+

2FA 등록

+ + {isSetupLoading ? ( +
+
QR코드 로딩 중...
+
+ ) : ( + <> + {qrImage && ( +
+ QR코드 +
+ )} + +
+ setOtp(e.target.value)} + disabled={isLoading} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + )} +
+
+
+ ); +} diff --git a/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx b/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx new file mode 100644 index 000000000..8d3220e8e --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx @@ -0,0 +1,901 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { SignupClient } from "@/src/shared/api/signup-client"; +import { BASE_URL, ENDPOINT } from "@/src/shared/config"; +import "./user-signup-modal.css"; + +interface UserSignupModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface UserSignupData { + userId: string; + userName: string; + email?: string; + password: string; + pswdHnt: string; + pswdAns?: string; + mphn?: string; + telno?: string; + zip?: string; + addr?: string; + daddr?: string; + laeSeCd: string; + ogdpSeCd: string; + instSeCd?: string; + dtlInstCd?: string; + enoCd?: string; +} + +// 익명 사용자용 CodeSelect 컴포넌트 +interface AnonymousCodeSelectProps { + upCd: string; + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +function AnonymousCodeSelect({ + upCd, + value, + onValueChange, + disabled, + placeholder = "선택", +}: AnonymousCodeSelectProps) { + const [codes, setCodes] = useState< + Array<{ lwpoCd: string; lwpoCdNm: string }> + >([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchCodes = async () => { + if (!upCd) return; + + setIsLoading(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + + const response = await signupClient.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, + ); + + if (response.data && Array.isArray(response.data)) { + setCodes(response.data); + } else { + setCodes([]); + } + } catch (error) { + console.error(`공통 코드 로드 실패 (${upCd}):`, error); + setCodes([]); + } finally { + setIsLoading(false); + } + }; + + fetchCodes(); + }, [upCd]); + + return ( + + ); +} + +function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) { + const [currentStep, setCurrentStep] = useState<"agreement" | "signup">( + "agreement", + ); + const [agreed, setAgreed] = useState(false); + const [formData, setFormData] = useState({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingId, setIsCheckingId] = useState(false); + const [isCheckingEmail, setIsCheckingEmail] = useState(false); + const [idAvailable, setIdAvailable] = useState(null); + const [emailAvailable, setEmailAvailable] = useState(null); + + // 모달이 닫힐 때 초기화 + useEffect(() => { + if (!open) { + setCurrentStep("agreement"); + setAgreed(false); + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } + }, [open]); + + const handleAgreementChange = (e: React.ChangeEvent) => { + setAgreed(e.target.checked); + }; + + const handleNextStep = () => { + if (agreed) { + setCurrentStep("signup"); + } + }; + + const handleInputChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + // 에러 초기화 + if (errors[name]) { + setErrors((prev) => ({ ...prev, [name]: "" })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + console.log("폼 검증 시작:", formData); + + // 필수 필드 검증 + if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; + if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; + if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; + if (!formData.confirmPassword) + newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; + if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요."; + if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; + if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; + + // 비밀번호 확인 + if ( + formData.password && + formData.confirmPassword && + formData.password !== formData.confirmPassword + ) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; + } + + // 비밀번호 정규식 검증 + const passwordRegex = + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; + if (formData.password && !passwordRegex.test(formData.password)) { + newErrors.password = + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; + } + + // 이메일 형식 검증 + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "올바른 이메일 형식을 입력해주세요."; + } + + // 중복 체크 검증 + if (idAvailable === false) + newErrors.userId = "이미 사용 중인 아이디입니다."; + if (emailAvailable === false) + newErrors.email = "이미 사용 중인 이메일입니다."; + + setErrors(newErrors); + const isValid = Object.keys(newErrors).length === 0; + console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); + return isValid; + }; + + const checkUserIdDuplicate = async () => { + if (!formData.userId) { + setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." })); + return; + } + + setIsCheckingId(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + { userId: formData.userId }, + ); + + const exists = response.data; + setIdAvailable(!exists); + if (exists) { + setErrors((prev) => ({ + ...prev, + userId: "이미 사용 중인 아이디입니다.", + })); + } else { + setErrors((prev) => ({ ...prev, userId: "" })); + } + } catch (error) { + console.error("아이디 중복 체크 오류:", error); + setErrors((prev) => ({ + ...prev, + userId: "아이디 중복 체크 중 오류가 발생했습니다.", + })); + } finally { + setIsCheckingId(false); + } + }; + + const checkEmailDuplicate = async () => { + if (!formData.email) { + setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." })); + return; + } + + setIsCheckingEmail(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + { email: formData.email }, + ); + + const exists = response.data; + setEmailAvailable(!exists); + if (exists) { + setErrors((prev) => ({ + ...prev, + email: "이미 사용 중인 이메일입니다.", + })); + } else { + setErrors((prev) => ({ ...prev, email: "" })); + } + } catch (error) { + console.error("이메일 중복 체크 오류:", error); + setErrors((prev) => ({ + ...prev, + email: "이메일 중복 체크 중 오류가 발생했습니다.", + })); + } finally { + setIsCheckingEmail(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log("회원가입 버튼 클릭됨 - handleSubmit"); + console.log("현재 폼 데이터:", formData); + + if (!validateForm()) { + console.log("폼 검증 실패"); + return; + } + + console.log("폼 검증 성공, 회원가입 API 호출 시작"); + setIsLoading(true); + try { + const signupData: UserSignupData = { + userId: formData.userId, + userName: formData.userName, + email: formData.email || undefined, + password: formData.password, + pswdHnt: formData.pswdHnt, + pswdAns: formData.pswdAns || undefined, + mphn: formData.mphn || undefined, + telno: formData.telno || undefined, + zip: formData.zip || undefined, + addr: formData.addr || undefined, + daddr: formData.daddr || undefined, + laeSeCd: formData.laeSeCd, + ogdpSeCd: formData.ogdpSeCd, + instSeCd: formData.instSeCd || undefined, + dtlInstCd: formData.dtlInstCd || undefined, + enoCd: formData.enoCd || undefined, + }; + + // SignupClient를 통해 익명사용자 회원가입 API 호출 + console.log("SignupClient 생성 및 API 호출:", { + baseURL: BASE_URL, + endpoint: ENDPOINT.USER_SERVICE.USER_JOIN, + data: signupData, + }); + + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + ENDPOINT.USER_SERVICE.USER_JOIN, + signupData, + ); + + console.log("회원가입 성공:", response.data); + + // 성공 시 모달 닫기 및 콜백 호출 + onSuccess(); + onClose(); + + // 폼 초기화 + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } catch (error: unknown) { + console.error("회원가입 오류:", error); + if (error instanceof Error) { + setErrors((prev) => ({ ...prev, submit: error.message })); + } else { + setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." })); + } + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( +
+
+
+

+ {currentStep === "agreement" ? "개인정보제공동의" : "회원가입"} +

+ +
+ + {currentStep === "agreement" ? ( +
+
+

개인정보 수집 및 이용 동의

+

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

+ +

1. 수집하는 개인정보 항목

+
    +
  • + 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, + 기관구분 +
  • +
  • + 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, + 상세기관코드, 사업자등록번호 +
  • +
+ +

2. 개인정보의 수집 및 이용목적

+
    +
  • 회원가입 및 관리
  • +
  • 서비스 제공 및 운영
  • +
  • 고객상담 및 문의응답
  • +
+ +

3. 개인정보의 보유 및 이용기간

+

+ 회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 + 기간까지) +

+ +

4. 동의 거부권 및 거부 시 불이익

+

+ 개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 + 시 회원가입이 제한됩니다. +

+
+ +
+ +
+ +
+ + +
+
+ ) : ( +
+
+

기본 정보

+ +
+
+ +
+ + +
+ {idAvailable === true && ( + + 사용 가능한 아이디입니다. + + )} + {errors.userId && ( + + {errors.userId} + + )} +
+
+ +
+
+ + + {errors.userName && ( + + {errors.userName} + + )} +
+
+ +
+
+ +
+ + +
+ {emailAvailable === true && ( + + 사용 가능한 이메일입니다. + + )} + {errors.email && ( + + {errors.email} + + )} +
+
+ +
+
+ + + {errors.password && ( + + {errors.password} + + )} +
+
+ +
+
+ + + {errors.confirmPassword && ( + + {errors.confirmPassword} + + )} +
+
+ +
+
+ + + setFormData((prev) => ({ ...prev, pswdHnt: value })) + } + disabled={isLoading} + placeholder="비밀번호 힌트를 선택하세요" + /> + {errors.pswdHnt && ( + + {errors.pswdHnt} + + )} +
+
+ +
+
+ + + {errors.pswdAns && ( + + {errors.pswdAns} + + )} +
+
+
+ +
+

연락처 정보

+ +
+
+ + + {errors.mphn && ( + + {errors.mphn} + + )} +
+
+ +
+
+ + + {errors.telno && ( + + {errors.telno} + + )} +
+
+
+ +
+

주소 정보

+ +
+
+ + + {errors.zip && ( + + {errors.zip} + + )} +
+
+ +
+
+ + + {errors.addr && ( + + {errors.addr} + + )} +
+
+ +
+
+ + + {errors.daddr && ( + + {errors.daddr} + + )} +
+
+
+ +
+

기관 정보

+ +
+
+ + + setFormData((prev) => ({ ...prev, laeSeCd: value })) + } + disabled={isLoading} + placeholder="법인구분을 선택하세요" + /> + {errors.laeSeCd && ( + + {errors.laeSeCd} + + )} +
+
+ +
+
+ + + setFormData((prev) => ({ ...prev, ogdpSeCd: value })) + } + disabled={isLoading} + placeholder="기관구분을 선택하세요" + /> + {errors.ogdpSeCd && ( + + {errors.ogdpSeCd} + + )} +
+
+ +
+
+ + + {errors.instSeCd && ( + + {errors.instSeCd} + + )} +
+
+ +
+
+ + + {errors.dtlInstCd && ( + + {errors.dtlInstCd} + + )} +
+
+
+ + {errors.submit && ( +
+
+ {errors.submit} +
+
+ )} + +
+ + + +
+
+ )} +
+
+ ); +} + +export default UserSignupModal; diff --git a/apps/wr/src/features/auth/login/ui/login-page.tsx b/apps/wr/src/features/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..4e30f880b --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/login-page.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import { images } from "@common/assets"; +import { LoginApi } from "@/src/entities/auth"; +import { ENDPOINT } from "@/src/shared/config"; +import OtpModal from "./OtpModal"; +import TwoFAModal from "./TwoFAModal"; +import UserSignupModal from "./UserSignupModal"; +import "./login.css"; +import { useRouter } from "next/navigation"; + +function LoginPage() { + const [isOtpEnabled, setIsOtpEnabled] = useState(false); + const [showOtpModal, setShowOtpModal] = useState(false); + const [showTwoFAModal, setShowTwoFAModal] = useState(false); + const [showSignupModal, setShowSignupModal] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + const router = useRouter(); + + // 모달 상태 디버깅 + useEffect(() => { + console.log("Modal states - OTP:", showOtpModal, "2FA:", showTwoFAModal); + }, [showOtpModal, showTwoFAModal]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + setSuccessMessage(""); + + try { + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + console.log("Login attempt - OTP enabled:", isOtpEnabled); + console.log("User ID:", userId); + + if (isOtpEnabled) { + // 2FA 로그인 - Proxy를 통해 처리 (2FA 전용 엔드포인트 사용) + console.log("Starting 2FA login..."); + + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN_2FA}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + password, + provider: 'password' + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || '로그인 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA login response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + + // 2FA 로그인 응답에는 tempToken과 registered2FA만 포함됨 + if (!data.tempToken) { + throw new Error("Missing tempToken in 2FA login response"); + } + + // 임시 토큰을 localStorage에 저장 (OTP 인증용) + localStorage.setItem('tempToken', data.tempToken); + console.log("tempToken saved:", data.tempToken); + + if (data.registered2FA) { + console.log("User has 2FA registered, showing OTP modal"); + setShowOtpModal(true); + } else { + console.log("User doesn't have 2FA registered, showing 2FA setup modal"); + setShowTwoFAModal(true); + } + } else { + // 기본 로그인 - proxy를 통해 처리 + console.log("Starting basic login..."); + await LoginApi.login({ userId, password }); + window.location.href = "/dashboard"; + } + } catch (error: unknown) { + console.error("Login error:", error); + + // 에러 처리 + if (error instanceof Error) { + setError(error.message); + } else { + setError("로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + const handleOtpSuccess = () => { + console.log("OTP authentication successful, redirecting to dashboard"); + localStorage.removeItem("tempToken"); // 임시 토큰 제거 + router.push("/dashboard"); + }; + + const handleTwoFASuccess = () => { + console.log("2FA setup successful, redirecting to dashboard"); + localStorage.removeItem("tempToken"); // 임시 토큰 제거 + router.push("/dashboard"); + }; + + const handleOtpClose = () => { + console.log("OTP modal closed"); + setShowOtpModal(false); + localStorage.removeItem("tempToken"); + }; + + const handleTwoFAClose = () => { + console.log("2FA modal closed"); + setShowTwoFAModal(false); + localStorage.removeItem("tempToken"); + }; + + const handleSignupSuccess = () => { + console.log("회원가입 성공"); + setError(""); // 에러 메시지 초기화 + setSuccessMessage("회원가입이 완료되었습니다. 로그인해주세요."); + }; + + const handleSignupClose = () => { + console.log("회원가입 모달 닫기"); + setShowSignupModal(false); + setSuccessMessage(""); // 성공 메시지 초기화 + }; + + return ( + <> +
+
+
+

+ 유역하수도 통합플랫폼 +
+ 통합관리시스템 +

+

유역하수도 통합플랫폼에 오신 것을 환영합니다.

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+
+ setIsOtpEnabled(e.target.checked)} + disabled={isLoading} + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} +
+ 계정이 없으신가요? + +
+
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ + {/* OTP 인증 모달 */} + + + {/* 2FA 등록 모달 */} + + + {/* 회원가입 모달 */} + + + ); +} + +export { LoginPage }; diff --git a/apps/wr/src/features/auth/login/ui/login.css b/apps/wr/src/features/auth/login/ui/login.css new file mode 100644 index 000000000..af73c9c68 --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/login.css @@ -0,0 +1,310 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(/images/bg_login.jpg) no-repeat center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/wr/src/features/auth/login/ui/signup-modal.css b/apps/wr/src/features/auth/login/ui/signup-modal.css new file mode 100644 index 000000000..0fb1585c9 --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/signup-modal.css @@ -0,0 +1,277 @@ +/* 모달 오버레이 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +/* 모달 컨텐츠 */ +.modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* 모달 헤더 */ +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.modal-header h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: #1f2937; +} + +.close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; +} + +.close-button:hover { + color: #374151; +} + +/* 폼 스타일 */ +.signup-form { + padding: 24px; +} + +.form-section { + margin-bottom: 32px; +} + +.form-section h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; +} + +.form-row { + margin-bottom: 16px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + margin-bottom: 6px; + font-weight: 500; + color: #374151; + font-size: 1.1rem; +} + +.form-group input, +.form-group select { + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-group input:disabled, +.form-group select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* 입력 필드와 버튼이 함께 있는 경우 */ +.input-with-button { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.input-with-button input { + flex: 1; +} + +.check-button { + padding: 10px 16px; + background-color: #6b7280; + color: white; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + min-width: 80px; +} + +.check-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.check-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 메시지 스타일 */ +.error-message { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.success-message { + color: #059669; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.submit-error { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + text-align: center; +} + +/* 폼 액션 버튼 */ +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + margin-top: 24px; +} + +.cancel-button, +.submit-button { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + border: none; + min-width: 100px; +} + +.cancel-button { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.cancel-button:hover:not(:disabled) { + background-color: #e5e7eb; +} + +.submit-button { + background-color: #3b82f6; + color: white; +} + +.submit-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.cancel-button:disabled, +.submit-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .modal-content { + margin: 10px; + max-height: 95vh; + } + + .modal-header { + padding: 16px 20px; + } + + .signup-form { + padding: 20px; + } + + .form-actions { + flex-direction: column; + } + + .cancel-button, + .submit-button { + width: 100%; + } + + .input-with-button { + flex-direction: column; + align-items: stretch; + } + + .check-button { + width: 100%; + margin-top: 8px; + } +} + +/* 스크롤바 스타일 */ +.modal-content::-webkit-scrollbar { + width: 6px; +} + +.modal-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/apps/wr/src/features/auth/login/ui/user-signup-modal.css b/apps/wr/src/features/auth/login/ui/user-signup-modal.css new file mode 100644 index 000000000..241929a2a --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/user-signup-modal.css @@ -0,0 +1,436 @@ +/* 신규 사용자 가입 모달 오버레이 */ +.user-signup-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +/* 신규 사용자 가입 모달 컨텐츠 */ +.user-signup-modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* 신규 사용자 가입 모달 헤더 */ +.user-signup-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.user-signup-modal-header h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: #1f2937; +} + +.user-signup-close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; +} + +.user-signup-close-button:hover { + color: #374151; +} + +/* 신규 사용자 가입 폼 스타일 */ +.user-signup-form { + padding: 24px; +} + +.user-signup-form-section { + margin-bottom: 32px; +} + +.user-signup-form-section h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; +} + +.user-signup-form-row { + margin-bottom: 16px; +} + +.user-signup-form-group { + display: flex; + flex-direction: column; +} + +.user-signup-form-group label { + margin-bottom: 6px; + font-weight: 500; + color: #374151; + font-size: 1.1rem; +} + +.user-signup-form-group input, +.user-signup-form-group select { + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +/* CodeSelect 컴포넌트 스타일링 */ +.user-signup-form-group .CodeSelect { + width: 100%; +} + +.user-signup-form-group .CodeSelect select { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +.user-signup-form-group .CodeSelect select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-form-group .CodeSelect select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* Select 요소 스타일링 */ +.user-signup-select { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; + cursor: pointer; +} + +.user-signup-select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +.user-signup-select option { + padding: 8px; + font-size: 1rem; +} + +.user-signup-form-group input:focus, +.user-signup-form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-form-group input:disabled, +.user-signup-form-group select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* 입력 필드와 버튼이 함께 있는 경우 */ +.user-signup-input-with-button { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.user-signup-input-with-button input { + flex: 1; +} + +.user-signup-check-button { + padding: 10px 16px; + background-color: #6b7280; + color: white; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + min-width: 80px; +} + +.user-signup-check-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.user-signup-check-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 메시지 스타일 */ +.user-signup-error-message { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.user-signup-success-message { + color: #059669; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.user-signup-submit-error { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + text-align: center; +} + +/* 폼 액션 버튼 */ +.user-signup-form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + margin-top: 24px; +} + +.user-signup-cancel-button, +.user-signup-submit-button, +.user-signup-back-button { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + border: none; + min-width: 100px; +} + +.user-signup-cancel-button { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.user-signup-cancel-button:hover:not(:disabled) { + background-color: #e5e7eb; +} + +.user-signup-submit-button { + background-color: #3b82f6; + color: white; +} + +.user-signup-submit-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.user-signup-back-button { + background-color: #6b7280; + color: white; +} + +.user-signup-back-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.user-signup-cancel-button:disabled, +.user-signup-submit-button:disabled, +.user-signup-back-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 개인정보제공동의 스타일 */ +.user-signup-agreement-content { + padding: 24px; +} + +.user-signup-agreement-text { + margin-bottom: 24px; +} + +.user-signup-agreement-text h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; +} + +.user-signup-agreement-text h4 { + margin: 20px 0 8px 0; + font-size: 1.1rem; + font-weight: 600; + color: #374151; +} + +.user-signup-agreement-text p { + margin: 8px 0; + color: #6b7280; + line-height: 1.5; +} + +.user-signup-agreement-text ul { + margin: 8px 0; + padding-left: 20px; + color: #6b7280; +} + +.user-signup-agreement-text li { + margin: 4px 0; + line-height: 1.5; +} + +.user-signup-agreement-checkbox { + margin-bottom: 24px; + padding: 16px; + background-color: #f9fafb; + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.user-signup-agreement-checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 500; + color: #374151; +} + +.user-signup-agreement-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; +} + +.user-signup-agreement-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.user-signup-next-button { + padding: 12px 24px; + background-color: #3b82f6; + color: white; + border: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s; + min-width: 100px; +} + +.user-signup-next-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.user-signup-next-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .user-signup-modal-content { + margin: 10px; + max-height: 95vh; + } + + .user-signup-modal-header { + padding: 16px 20px; + } + + .user-signup-form { + padding: 20px; + } + + .user-signup-form-actions { + flex-direction: column; + } + + .user-signup-cancel-button, + .user-signup-submit-button, + .user-signup-back-button { + width: 100%; + } + + .user-signup-input-with-button { + flex-direction: column; + align-items: stretch; + } + + .user-signup-check-button { + width: 100%; + margin-top: 8px; + } +} + +/* 스크롤바 스타일 */ +.user-signup-modal-content::-webkit-scrollbar { + width: 6px; +} + +.user-signup-modal-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.user-signup-modal-content::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.user-signup-modal-content::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/apps/wr/src/shared/api/signup-client.ts b/apps/wr/src/shared/api/signup-client.ts new file mode 100644 index 000000000..2cd459cd7 --- /dev/null +++ b/apps/wr/src/shared/api/signup-client.ts @@ -0,0 +1,71 @@ +import axios from "axios"; +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; + +interface SignupClientConfig { + baseURL: string; +} + +class SignupClient { + private static instance: SignupClient; + private axiosInstance: AxiosInstance; + + private constructor(config: SignupClientConfig) { + this.axiosInstance = axios.create({ + baseURL: config.baseURL, + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + }); + + // 인증 토큰 없이도 요청할 수 있도록 인터셉터 설정 + this.axiosInstance.interceptors.request.use((config) => { + // 인증 토큰을 자동으로 추가하지 않음 + return config; + }); + } + + public static getInstance(config: SignupClientConfig): SignupClient { + if (!SignupClient.instance) { + SignupClient.instance = new SignupClient(config); + } + return SignupClient.instance; + } + + public async get>( + url: string, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.get(url, config); + return response; + } + + public async post, D = unknown>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.post(url, data, config); + return response; + } + + public async put, D = unknown>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.put(url, data, config); + return response; + } + + public async delete>( + url: string, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.delete(url, config); + return response; + } +} + +export type { SignupClientConfig }; +export { SignupClient }; diff --git a/apps/wr/src/shared/config/api.ts b/apps/wr/src/shared/config/api.ts index b0d1a5be2..eebb15c19 100644 --- a/apps/wr/src/shared/config/api.ts +++ b/apps/wr/src/shared/config/api.ts @@ -14,13 +14,21 @@ export const ENDPOINT = { }, USER_SERVICE: { LOGIN: "/user-service/login", + LOGIN_2FA: "/user-service/api/v1/auth/login", USERS: "/user-service/api/v1/users", SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", AUTHORIZATIONS: "/user-service/api/v1/authorizations", ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations", ROLES: "/user-service/api/v1/roles", + LOGINLOG: "/user-service/api/v1/login-logs", + PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs", + OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify", + TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup", + TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable", + USER_JOIN: "/user-service/api/v1/anonymouse/users/join", }, CMS_SERVICE: { + DASHBOARD: "/cms-service/api/v1/dashboard", SITES: "/cms-service/api/v1/sites", IPS: "/cms-service/api/v1/site-ips", DOMAINS: "/cms-service/api/v1/site-dmns", @@ -29,6 +37,8 @@ export const ENDPOINT = { MENUS: "/cms-service/api/v1/menus", MENU_ROLES: "/cms-service/api/v1/menu-roles", MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts", + MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg", + MENU_HISTORY: "/cms-service/api/v1/menu-hstry", CONTENTS: "/cms-service/api/v1/contents", CONTENTS_DETAILS: "/cms-service/api/v1/contents-details", ORGANIZATIONS: "/cms-service/api/v1/ognzs", @@ -44,13 +54,32 @@ export const ENDPOINT = { SURVEY: "/cms-service/api/v1/srvy", SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem", SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl", + ZIPS: "/cms-service/api/v1/zips", + GROUPS: "/cms-service/api/v1/groups", + GROUP_USERS: "/cms-service/api/v1/group-users", + MANAGER_IP: "/cms-service/api/v1/mgrip", + APILOG: "/cms-service/api/v1/api-logs", + SITE_AUTHORITY: "/cms-service/api/v1/site-authrt", + CONNECTION_LOG: "/cms-service/api/v1/cntnlog", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt", POSTS: "/board-service/api/v1/posts", POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", + POST_EVALUATION: "/board-service/api/v1/pstEvl", }, + SEWERAGE_SERVICE: { + PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts", + OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts", + }, + IC_SERVICE: { + GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics", + }, + DC_SERVICE: { + DATA_STATUS: "/dc-service/api/v1/dc/dataStatus", + }, + }; export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/wr/src/shared/ui/form.tsx b/apps/wr/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/wr/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +