diff --git a/app/Http/Controllers/Web/Auth/RegisterController.php b/app/Http/Controllers/Web/Auth/RegisterController.php index 290f607..00d5fb9 100644 --- a/app/Http/Controllers/Web/Auth/RegisterController.php +++ b/app/Http/Controllers/Web/Auth/RegisterController.php @@ -449,7 +449,7 @@ class RegisterController extends Controller ], ]; - // 비밀번호는 로그에 남기면 사고남 → 마스킹/제거 + // 비밀번호는 마스킹/제거 $logFinal = $final; $logFinal['password_plain'] = '***masked***'; $logFinal['pin2_plain'] = '***masked***'; diff --git a/app/Http/Controllers/Web/Mypage/InfoGateController.php b/app/Http/Controllers/Web/Mypage/InfoGateController.php index c842d27..2cb1edd 100644 --- a/app/Http/Controllers/Web/Mypage/InfoGateController.php +++ b/app/Http/Controllers/Web/Mypage/InfoGateController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web\Mypage; use App\Http\Controllers\Controller; use App\Services\MemInfoService; use App\Services\Danal\DanalAuthtelService; +use App\Services\Dozn\DoznAccountAuthService; use App\Repositories\Member\MemberAuthRepository; use App\Support\LegacyCrypto\CiSeedCrypto; use App\Support\LegacyCrypto\CiPassword; @@ -40,7 +41,7 @@ final class InfoGateController extends Controller public function info_renew(Request $request) { - // ✅ gate (기존 그대로) + // gate (기존 그대로) $gate = (array) $request->session()->get('mypage_gate', []); $gateOk = (bool) Arr::get($gate, 'ok', false); $gateAt = (int) Arr::get($gate, 'at', 0); @@ -51,14 +52,14 @@ final class InfoGateController extends Controller $remainSec = ($expireTs > 0) ? max(0, $expireTs - $nowTs) : 0; $isGateValid = ($remainSec > 0); - // ✅ 회원정보: _sess 기반 + // 회원정보: _sess 기반 $sess = (array) $request->session()->get('_sess', []); $memberName = (string) Arr::get($sess, '_mname', ''); $memberEmail = (string) Arr::get($sess, '_mid', ''); $dtReg = (string) Arr::get($sess, '_dt_reg', ''); - // ✅ 전화번호 복호 (인증 완료 상태라 마스킹 제외) + // 전화번호 복호 (인증 완료 상태라 마스킹 제외) $rawPhone = (string) Arr::get($sess, '_mcell', ''); $memberPhone = (string) $this->seed->decrypt($rawPhone); $user = $request->user(); @@ -91,20 +92,35 @@ final class InfoGateController extends Controller /** - * 재인증을 위한 세셔초기화 + * 재인증을 위한 연장 및 세셔초기화 */ public function gateReset(Request $request) { - // 게이트 인증 세션만 초기화 - $request->session()->forget('mypage_gate'); + $ttlSec = 5 * 60; + $now = time(); - // (선택) reauth도 같이 초기화하고 싶으면 - $request->session()->forget('mypage.reauth.at'); - $request->session()->forget('mypage.reauth.until'); + $gate = (array) $request->session()->get('mypage_gate', []); + $gateOk = (bool)($gate['ok'] ?? false); + $gateAt = (int) ($gate['at'] ?? 0); - $request->session()->save(); + $expireTs = ($gateOk && $gateAt > 0) ? ($gateAt + $ttlSec) : 0; + $isValid = ($expireTs > 0) && ($expireTs > $now); - // 게이트 페이지로 이동 + if ($isValid) { + // 남은 시간이 있으면: 5분 연장(= at를 현재로 갱신) + $request->session()->put('mypage_gate', [ + 'ok' => true, + 'email' => (string)($gate['email'] ?? ''), + 'at' => $now, + ]); + $request->session()->save(); + + }else{ + // 시간이 끝났으면: 초기화 + $request->session()->forget('mypage_gate'); + $request->session()->save(); + } + // 인덱스로 보내기 return redirect()->route('web.mypage.info.index'); } @@ -271,7 +287,7 @@ final class InfoGateController extends Controller ]); } - // ✅ 목적이 마이페이지 연락처 변경일 때만 추가 검증 + // 목적이 마이페이지 연락처 변경일 때만 추가 검증 $purpose = (string) $request->session()->get('mypage.pass_purpose', ''); if ($purpose === 'mypage_phone_change') { @@ -331,5 +347,278 @@ final class InfoGateController extends Controller ]); } + /*비밀번호 변경 저장*/ + public function passwordUpdate(Request $request, MemInfoService $memInfoService, MemberAuthRepository $repo) + { + if (!$this->isGateOk($request)) { + return $this->gateFailJson($request, '인증 시간이 만료되었습니다. 다시 인증해 주세요.'); + } + + // 로그인 세션 + $sess = (array) $request->session()->get('_sess', []); + $isLogin = (bool) ($sess['_login_'] ?? false); + $email = (string) ($sess['_mid'] ?? ''); + $memNo = (int) ($sess['_mno'] ?? 0); + + if (!$isLogin || $email === '' || $memNo <= 0) { + return response()->json([ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인해 주세요.', + 'redirect' => route('web.auth.login'), + ], 401); + } + + // 검증 (요청하신 메시지 그대로) + $request->validate([ + 'current_password' => ['required', 'string'], + 'password' => [ + 'required', + 'string', + 'min:8', + 'max:20', + // 영문+숫자+특수문자 포함 + 'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,20}$/', + 'confirmed', // password_confirmation + ], + ], [ + 'password.required' => '비밀번호를 입력해 주세요.', + 'password.min' => '비밀번호는 8자리 이상이어야 합니다.', + 'password.max' => '비밀번호는 20자리를 초과할 수 없습니다.', + 'password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.', + ]); + + $currentPw = (string) $request->input('current_password'); + $newPw = (string) $request->input('password'); + + // 현재 비밀번호 확인(기존 attemptLegacyLogin 로직 재사용) + $res = $memInfoService->attemptLegacyLogin([ + 'email' => $email, + 'password' => $currentPw, + 'ip' => $request->ip(), + 'ua' => substr((string) $request->userAgent(), 0, 500), + 'return_url' => url('/mypage/info_renew'), + ]); + + $ok = (bool)($res['ok'] ?? $res['success'] ?? false); + if (!$ok) { + // 레이어에서 보여줄 메시지 + return response()->json([ + 'ok' => false, + 'message' => (string)($res['message'] ?? '현재 비밀번호가 일치하지 않습니다.'), + ], 422); + } + + // 변경 저장(레포 이미 존재) + 성공로그(레포 이미 존재) + $repo->updatePasswordOnly($memNo, $newPw); + $repo->logPasswordResetSuccess( + $memNo, + (string) $request->ip(), + (string) $request->userAgent(), + 'S' + ); + + + return response()->json([ + 'ok' => true, + 'message' => '비밀번호가 변경되었습니다.', + ]); + } + + //2차 비밀번호 변경 + public function updatePin2( + Request $request, + MemInfoService $memInfoService, + MemberAuthRepository $repo + ) { + if (!$this->isGateOk($request)) { + return response()->json([ + 'ok' => false, + 'message' => '인증 시간이 만료되었습니다. 다시 인증해 주세요.', + ], 419); + } + + // 입력 검증(메시지 그대로) + $request->validate([ + 'current_password' => ['required', 'string'], + 'current_pin2' => ['required', 'digits:4'], + 'pin2' => ['required', 'digits:4'], + 'pin2_confirmation' => ['required', 'same:pin2'], + ], [ + 'current_password.required' => '이전 비밀번호를 입력해 주세요.', + + 'current_pin2.required' => '이전 2차 비밀번호(숫자 4자리)를 입력해 주세요.', + 'current_pin2.digits' => '이전 2차 비밀번호는 숫자 4자리여야 합니다.', + + 'pin2.required' => '2차 비밀번호(숫자 4자리)를 입력해 주세요.', + 'pin2.digits' => '2차 비밀번호는 숫자 4자리여야 합니다.', + 'pin2_confirmation.required' => '2차 비밀번호 확인을 입력해 주세요.', + 'pin2_confirmation.same' => '2차 비밀번호 확인이 일치하지 않습니다.', + ]); + + // 세션에서 mem_no/email 확보 + $sess = (array) $request->session()->get('_sess', []); + $isLogin = (bool) ($sess['_login_'] ?? false); + $email = (string) ($sess['_mid'] ?? ''); + $memNo = (int) ($sess['_mno'] ?? 0); + + if (!$isLogin || $email === '' || $memNo <= 0) { + return response()->json([ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.', + ], 401); + } + + // 1) 로그인 비밀번호(1차) 검증 (기존 attemptLegacyLogin 재사용) + $currentPw = (string) $request->input('current_password'); + + $res = $memInfoService->attemptLegacyLogin([ + 'email' => $email, + 'password' => $currentPw, + 'ip' => $request->ip(), + 'ua' => substr((string) $request->userAgent(), 0, 500), + 'return_url' => url('/mypage/info_renew'), + ]); + + $pwOk = (bool)($res['ok'] ?? $res['success'] ?? false); + if (!$pwOk) { + return response()->json([ + 'ok' => false, + 'reason' => 'invalid_current_password', + 'message' => (string)($res['message'] ?? '이전 비밀번호가 일치하지 않습니다.'), + ], 422); + } + + // 2) 현재 2차 비밀번호 검증 + $currentPin2 = (string) $request->input('current_pin2'); + $pin2Ok = $repo->verifyPin2($memNo, $currentPin2); + + if (!$pin2Ok) { + return response()->json([ + 'ok' => false, + 'reason' => 'invalid_current_pin2', + 'message' => '이전 2차 비밀번호가 일치하지 않습니다.', + ], 422); + } + + // 3) 새 2차 비밀번호 저장 + $newPin2 = (string) $request->input('pin2'); + + try { + $repo->updatePin2Only($memNo, $newPin2); + } catch (\Throwable $e) { + Log::error('[mypage] pin2 update failed', [ + 'mem_no' => $memNo, + 'err' => $e->getMessage(), + ]); + + return response()->json([ + 'ok' => false, + 'message' => '2차 비밀번호 변경에 실패했습니다. 잠시 후 다시 시도해 주세요.', + ], 500); + } + + return response()->json([ + 'ok' => true, + 'message' => '2차 비밀번호가 변경되었습니다.', + ]); + } + + + public function verifyOut(Request $request, DoznAccountAuthService $dozn, MemberAuthRepository $repo) + { + // 게이트 만료 처리(프로젝트 정책대로) + if (!$this->isGateOk($request)) { + return $this->gateFailJson($request, '인증이 만료되었습니다. 다시 인증해 주세요.'); + } + + // 레거시 세션 기준으로 memNo / 실명 확정 + $sess = (array) $request->session()->get('_sess', []); + $memNo = (int) ($sess['_mno'] ?? 0); + $memberName = trim((string) ($sess['_mname'] ?? '')); + + if ($memNo <= 0 || $memberName === '') { + return response()->json([ + 'ok' => false, + 'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.', + 'redirect' => route('web.auth.login'), + ], 401); + } + + $request->validate([ + 'pin2' => ['required','digits:4'], + 'bank_code' => ['required','string'], + 'account' => ['required','regex:/^\d+$/'], + 'depositor' => ['required','string','max:50'], + ], [ + 'pin2.required' => '2차 비밀번호(숫자 4자리)를 입력해 주세요.', + 'pin2.digits' => '2차 비밀번호는 숫자 4자리여야 합니다.', + 'bank_code.required' => '은행을 선택해 주세요.', + 'account.required' => '계좌번호를 입력해 주세요.', + 'account.regex' => '계좌번호는 숫자만 입력해 주세요.', + 'depositor.required' => '예금주(성명)를 입력해 주세요.', + ]); + + $pin2 = trim((string) $request->input('pin2', '')); + $bankCode = trim((string) $request->input('bank_code', '')); + $account = trim((string) $request->input('account', '')); + $depositor = trim((string) $request->input('depositor', '')); + + if ($depositor !== $memberName) { + return response()->json([ + 'ok' => false, + 'message' => '가입자 성명과 예금주가 일치하지 않습니다.', + 'errors' => ['depositor' => ['가입자 성명과 예금주가 일치하지 않습니다.']], + ], 422); + } + + // 1) 2차비번 검증 먼저 + $okPin2 = $repo->verifyPin2($memNo, $pin2); + if (!$okPin2) { + return response()->json([ + 'ok' => false, + 'message' => '2차 비밀번호가 올바르지 않습니다.', + 'errors' => ['pin2' => ['2차 비밀번호가 올바르지 않습니다.']], + ], 422); + } + + // 2) Dozn 인증 + 저장 + $res = $dozn->verifyAndSaveOutAccount( + $memNo, + $memberName, // 세션 실명 + $depositor, // 위에서 실명 일치 확인됨 + $bankCode, + $account, + true + ); + + return response()->json($res, 200); + } + + /** + * gate 유효성 체크 (mypage_gate.at만 사용, TTL=5분) + */ + private function isGateOk(Request $request, int $ttlSec = 300): bool + { + $gate = (array) $request->session()->get('mypage_gate', []); + $ok = (bool)($gate['ok'] ?? false); + $at = (int)($gate['at'] ?? 0); + + if (!$ok || $at <= 0) return false; + + return (time() - $at) <= $ttlSec; + } + + /** + * gate 만료 공통 JSON 응답 + */ + private function gateFailJson(Request $request, string $message = '인증이 필요합니다.') + { + return response()->json([ + 'ok' => false, + 'message' => $message, + 'redirect' => route('web.mypage.info.index'), // gate 페이지로 유도 + ], 401); + } + } diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php index 13a0266..21965f6 100644 --- a/app/Repositories/Member/MemberAuthRepository.php +++ b/app/Repositories/Member/MemberAuthRepository.php @@ -432,10 +432,6 @@ class MemberAuthRepository $ipfResult = (string) data_get($sessionAll, 'signup.ipf_result', ''); $ipfSeq = (int) data_get($sessionAll, 'signup.ipf_seq', 0); -// if ($ipfResult === 'A') { -// // 이 케이스는 원래 step0에서 막혀야 정상이지만, 방어적으로 한번 더 -// throw new \RuntimeException('회원가입이 제한된 IP입니다.'); -// } if ($ipfResult === 'S' && $ipfSeq > 0) { $this->updateJoinLogAfterSignup($ipfSeq, $memNo, $email); } @@ -665,29 +661,85 @@ class MemberAuthRepository 'dt_reg' => $now, ]); - // ✅ 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리 + // 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리 + if ($affected <= 0) { + throw new \RuntimeException('mem_st_ring_not_found'); + } + } + + // 2차 비밀번호 검증 + public function verifyPin2(int $memNo, string $pin2Plain): bool + { + if ($memNo <= 0) return false; + + // 4자리 숫자만 허용 + if (!preg_match('/^\d{4}$/', $pin2Plain)) return false; + + $stored = (string) DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->value('passwd2'); + + if ($stored === '') return false; + + $hash = CiPassword::makePass2($pin2Plain); + + return hash_equals($stored, $hash); + } + + + // 2차 비밀번호 수정 + public function updatePin2Only(int $memNo, string $pin2Plain): void + { + if ($memNo <= 0 || $pin2Plain === '') { + throw new \InvalidArgumentException('invalid_payload'); + } + + // 4자리 숫자 강제 (컨트롤러에서도 validate 하지만 2중 방어) + if (!preg_match('/^\d{4}$/', $pin2Plain)) { + throw new \InvalidArgumentException('invalid_pin2'); + } + + $passwd2 = CiPassword::makePass2($pin2Plain); + $now = now()->format('Y-m-d H:i:s'); + + // mem_st_ring 존재 전제: 없으면 데이터 이상으로 실패 처리(비번 변경과 동일 정책) + $affected = DB::table('mem_st_ring') + ->where('mem_no', $memNo) + ->update([ + 'passwd2' => $passwd2, + 'passwd2_reg' => $now, + ]); + if ($affected <= 0) { throw new \RuntimeException('mem_st_ring_not_found'); } } - public function logPasswordResetSuccess(int $memNo, string $ip, string $agent): void + public function logPasswordResetSuccess(int $memNo, string $ip, string $agent, string $state = 'E'): void { $now = now()->format('Y-m-d H:i:s'); + + // state: E=비밀번호찾기 변경, S=직접 변경 + $state = strtoupper(trim($state)); + if (!in_array($state, ['E', 'S'], true)) { + $state = 'E'; + } + DB::table('mem_passwd_modify')->insert([ - 'state' => 'E', + 'state' => $state, 'info' => json_encode([ 'mem_no' => (string)$memNo, 'redate' => $now, 'remote_addr' => $ip, - 'agent' => $agent, + 'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리) ], JSON_UNESCAPED_UNICODE), 'rgdate' => $now, ]); } + } diff --git a/app/Services/Dozn/DoznAccountAuthService.php b/app/Services/Dozn/DoznAccountAuthService.php new file mode 100644 index 0000000..760e28b --- /dev/null +++ b/app/Services/Dozn/DoznAccountAuthService.php @@ -0,0 +1,360 @@ +mode = (string)($cfg['mode'] ?? 'real'); + $this->orgCode = (string)($cfg['org_code'] ?? ''); + $this->apiKey = (string)($cfg['api_key'] ?? ''); + $this->urlReal = (string)($cfg['url_real'] ?? ''); + $this->urlTest = (string)($cfg['url_test'] ?? ''); + $this->timeout = (int)($cfg['timeout'] ?? 10); + } + + /** + * 출금(out) 계좌 성명 인증 + 저장까지 한 번에 처리 + * + * @param int $memNo 회원번호 + * @param string $memberRealName 회원 실명(서버에서 꺼낸 값) + * @param string $inputDepositor 입력한 예금주(화면 입력) + * @param string $bankCode 은행코드(001~) + * @param string $account 계좌번호(숫자만 권장) + * @param bool $requireSameName 입력 예금주 == 회원실명 강제 여부 + * + * @return array { ok, code, message, status, depositor, telegram_no } + */ + public function verifyAndSaveOutAccount( + int $memNo, + string $memberRealName, + string $inputDepositor, + string $bankCode, + string $account, + bool $requireSameName = true + ): array { + $memNo = (int)$memNo; + $memberRealName = trim($memberRealName); + $inputDepositor = trim($inputDepositor); + $bankCode = trim($bankCode); + $account = preg_replace('/[^0-9]/', '', (string)$account); + + // 은행코드 검증 (config/bank_code.php) + $bankMap = (array) config('bank_code.flat', []); + if ($bankCode === '' || !isset($bankMap[$bankCode])) { + return $this->fail('INVALID_BANK', '은행코드가 올바르지 않습니다.'); + } + if ($account === '') { + return $this->fail('INVALID_ACCOUNT', '계좌번호를 입력해 주세요.'); + } + if ($inputDepositor === '') { + return $this->fail('INVALID_DEPOSITOR', '예금주를 입력해 주세요.'); + } + + // 입력 예금주와 회원 실명 일치 강제 (네 CI3 로직과 동일) + if ($requireSameName && $memberRealName !== '' && $memberRealName !== $inputDepositor) { + // act_state=4(정보불일치) 기록(선택) + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '4', [ + 'reason' => 'member_name_mismatch', + 'member_real_name' => $memberRealName, + 'input_depositor' => $inputDepositor, + ]); + + return $this->fail('NAME_MISMATCH', '가입자 성명과 예금주가 일치하지 않습니다!'); + } + + // telegram_no 발급 (일별 6자리) + $telegramNo = $this->issueTelegramNo(); + + // 요청 페이로드 (Dozn 스펙) + $payload = [ + 'org_code' => $this->orgCode, + 'api_key' => $this->apiKey, + 'telegram_no' => $telegramNo, + 'bank_code' => $bankCode, + 'account' => $account, + 'check_depositor' => 'N', // 기존 코드 유지 (실명체크 identify_no는 추후) + // 'identify_no' => '761127', + ]; + + // 요청 로그 insert + $logSeq = $this->logRequest($memNo, $payload, [ + 'input_depositor' => $inputDepositor, + 'mode' => $this->mode, + ]); + + // 진행상태 기록 (2: 인증진행중) + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '2', [ + 'log_seq' => $logSeq, + 'telegram_no' => $telegramNo, + ]); + + // Dozn 호출 + $resp = $this->callDozn($payload); + + // 결과 로그 update + $this->logResult($logSeq, $resp['raw'] ?? []); + + $status = (string)($resp['status'] ?? ''); + $depositor = (string)($resp['depositor'] ?? ''); + + // 성공(200) 처리 + if ($status === '200') { + if ($depositor === '' || $depositor !== $inputDepositor) { + // 정보 불일치 (4) + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '4', [ + 'status' => $status, + 'returned_depositor' => $depositor, + 'input_depositor' => $inputDepositor, + 'telegram_no' => $telegramNo, + ]); + + return [ + 'ok' => false, + 'code' => 'DEPOSITOR_MISMATCH', + 'message' => '예금주가 일치하지 않습니다!', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // 성공(3): 계좌 저장 + $this->saveOutAccountSuccess($memNo, $bankCode, $account, $depositor, [ + 'status' => $status, + 'telegram_no' => $telegramNo, + 'result' => $resp['raw'] ?? [], + ]); + + return [ + 'ok' => true, + 'code' => 'OK', + 'message' => '인증완료 및 계좌번호가 등록되었습니다.', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // 잘못된 계좌(520) + if ($status === '520') { + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '5', [ + 'status' => $status, + 'telegram_no' => $telegramNo, + 'result' => $resp['raw'] ?? [], + ]); + + return [ + 'ok' => false, + 'code' => 'INVALID_ACCOUNT', + 'message' => '잘 못된 계좌번호 입니다.', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // 기타 오류 + $this->upsertOutAccountState($memNo, $bankCode, $account, $inputDepositor, '5', [ + 'status' => $status, + 'telegram_no' => $telegramNo, + 'result' => $resp['raw'] ?? [], + ]); + + return [ + 'ok' => false, + 'code' => 'DOZN_ERROR', + 'message' => ($status ?: 'ERR') . '|인증에 문제가 발생했습니다. 잠시후 다시 시도해주세요.', + 'status' => $status, + 'depositor' => $depositor, + 'telegram_no' => $telegramNo, + ]; + } + + // ------------------------------------------------------------------ + // Dozn HTTP call + // ------------------------------------------------------------------ + private function callDozn(array $payload): array + { + $url = $this->mode === 'test' ? $this->urlTest : $this->urlReal; + if ($url === '' || $this->orgCode === '' || $this->apiKey === '') { + return [ + 'status' => 'CONFIG', + 'depositor' => '', + 'raw' => ['error' => 'dozn_config_missing'], + ]; + } + + try { + $res = Http::timeout($this->timeout) + ->acceptJson() + ->asJson() + ->post($url, $payload); + + $json = $res->json(); + if (!is_array($json)) $json = []; + + return [ + 'status' => (string)($json['status'] ?? ''), + 'depositor' => (string)($json['depositor'] ?? ''), + 'raw' => $json, + ]; + } catch (\Throwable $e) { + Log::warning('[dozn] request failed', [ + 'err' => $e->getMessage(), + ]); + + return [ + 'status' => 'HTTP', + 'depositor' => '', + 'raw' => ['error' => 'http_exception', 'message' => $e->getMessage()], + ]; + } + } + + // ------------------------------------------------------------------ + // telegram_no: mem_account_telegram_no 사용 (일별 6자리) + // ------------------------------------------------------------------ + private function issueTelegramNo(): int + { + $today = Carbon::today()->toDateString(); + + return (int) DB::transaction(function () use ($today) { + // 오늘 발급된 마지막 telegram_no를 잠그고 다음 번호 생성 + $last = DB::table('mem_account_telegram_no') + ->where('date', $today) + ->lockForUpdate() + ->max('telegram_no'); + + $next = $last ? ((int)$last + 1) : 1; + if ($next > 999999) $next = 1; + + DB::table('mem_account_telegram_no')->insert([ + 'telegram_no' => $next, + 'date' => $today, + ]); + + return $next; + }); + } + + // ------------------------------------------------------------------ + // Logs: mem_account_log + // ------------------------------------------------------------------ + private function logRequest(int $memNo, array $payload, array $meta = []): int + { + $now = Carbon::now()->format('Y-m-d H:i:s'); + + $req = array_merge($payload, $meta); + + $reqJson = json_encode($req, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($reqJson === false) $reqJson = '{}'; + + return (int) DB::table('mem_account_log')->insertGetId([ + 'mem_no' => $memNo, + 'request_data' => $reqJson, + 'request_time' => $now, + 'result_data' => '{}', + 'result_time' => $now, + ]); + } + + private function logResult(int $seq, array $result): void + { + if ($seq <= 0) return; + + $now = Carbon::now()->format('Y-m-d H:i:s'); + + $resJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($resJson === false) $resJson = '{}'; + + DB::table('mem_account_log') + ->where('seq', $seq) + ->update([ + 'result_data' => $resJson, + 'result_time' => $now, + ]); + } + + + + // ------------------------------------------------------------------ + // mem_account: 회원당 out 1개 유지 (스키마 유지, 코드로 강제) + // ------------------------------------------------------------------ + private function upsertOutAccountState( + int $memNo, + string $bankCode, + string $account, + string $depositor, + string $actState, + array $confirmLog = [] + ): void { + $bankMap = (array) config('bank_code.flat', []); + $bankName = (string)($bankMap[$bankCode] ?? ''); + + $now = Carbon::now()->format('Y-m-d H:i:s'); + + DB::transaction(function () use ($memNo, $bankCode, $bankName, $account, $depositor, $actState, $confirmLog, $now) { + // mem_no + act_type='out' 기준으로 row 잠금 + $row = DB::table('mem_account') + ->where('mem_no', $memNo) + ->where('act_type', 'out') + ->lockForUpdate() + ->first(); + + $data = [ + 'mem_no' => $memNo, + 'act_type' => 'out', + 'act_state' => $actState, + 'bank_code' => $bankCode, + 'bank_name' => $bankName, + 'bank_act_name' => $depositor, + 'bank_act_num' => $account, + 'in_date' => $now, + 'act_date' => $now, + 'confirm_log' => json_encode($confirmLog ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; + + if ($row) { + DB::table('mem_account')->where('seq', $row->seq)->update($data); + } else { + DB::table('mem_account')->insert($data); + } + }); + } + + private function saveOutAccountSuccess( + int $memNo, + string $bankCode, + string $account, + string $depositor, + array $confirmLog = [] + ): void { + // 성공은 act_state=3 + $this->upsertOutAccountState($memNo, $bankCode, $account, $depositor, '3', $confirmLog); + } + + private function fail(string $code, string $message): array + { + return [ + 'ok' => false, + 'code' => $code, + 'message' => $message, + ]; + } +} diff --git a/app/Services/FindPasswordService.php b/app/Services/FindPasswordService.php index 09af488..c6540b6 100644 --- a/app/Services/FindPasswordService.php +++ b/app/Services/FindPasswordService.php @@ -229,9 +229,11 @@ class FindPasswordService $this->members->logPasswordResetSuccess( $memNo, (string) $request->ip(), - (string) $request->userAgent() + (string) $request->userAgent(), + 'E' ); + // 6) 세션 정리 $request->session()->forget('find_pw'); $request->session()->save(); diff --git a/app/Services/MypageInfoService.php b/app/Services/MypageInfoService.php index ad39825..0d48f73 100644 --- a/app/Services/MypageInfoService.php +++ b/app/Services/MypageInfoService.php @@ -50,19 +50,19 @@ final class MypageInfoService // PASS 전화번호 암호화 (CI 레거시 방식) $encPassPhone = (string) $this->seed->encrypt($rawPhone); - // (1) 기존 회원 전화번호와 동일하면 변경 불가 + // 기존 회원 전화번호와 동일하면 변경 불가 $encMemberPhone = (string) ($sess['_mcell'] ?? ''); if ($encMemberPhone !== '' && $encPassPhone !== '' && hash_equals($encMemberPhone, $encPassPhone)) { return $this->fail('현재 등록된 연락처와 동일합니다. 다른 번호로 인증해 주세요.'); } - // (2) PASS 전화번호가 DB에 존재하면 변경 불가 - // 요구사항: "존재한다면 이전에 가입된 전화번호가 있습니다. 관리자 문의" + // PASS 전화번호가 DB에 존재하면 변경 불가 + // "존재한다면 이전에 가입된 전화번호가 있습니다. 관리자 문의" if ($this->repo->existsEncryptedCell($encPassPhone, $memNo)) { return $this->fail('이미 가입된 휴대폰 번호가 있습니다. 관리자에게 문의해 주세요.'); } - // (3) CI가 회원정보 mem_info.ci와 일치해야 통과 + // CI가 회원정보 mem_info.ci와 일치해야 통과 $memberCi = $this->repo->getMemberCi($memNo); if ($memberCi === '' || !hash_equals($memberCi, $ci)) { return $this->fail('가입된 회원정보와 일치하지 않습니다. 관리자에게 문의해 주세요.'); @@ -80,8 +80,6 @@ final class MypageInfoService /** * 최종 저장: mem_info.cell 업데이트 - * - 컨트롤러는 DB 처리 안 한다고 했으니, /mypage/info 저장 버튼에서 이 메서드만 호출하면 됨. - * * @return array ['ok'=>bool, 'message'=>string, '_cell'=>string] */ public function commitPhoneChange(int $memNo, array $passPayload): array diff --git a/config/bank_code.php b/config/bank_code.php new file mode 100644 index 0000000..ba0c9bf --- /dev/null +++ b/config/bank_code.php @@ -0,0 +1,214 @@ + list + * - Grouped for UI rendering + flat for fast lookup + * + * Usage: + * - name by code: config('bank_code.flat.004') // "KB국민은행" + * - groups: config('bank_code.groups.bank_1st') + */ + +return [ + + // ✅ UI용 그룹 (원하는 순서대로 렌더링) + 'groups' => [ + 'bank_1st' => [ + 'label' => '메이저 1금융권', + 'items' => [ + '001' => '한국은행', + '002' => '산업은행', + '003' => '기업은행', + '004' => 'KB국민은행', + '011' => 'NH농협은행', + '020' => '우리은행', + '023' => 'SC제일은행', + '027' => '한국씨티은행', + '031' => '대구은행', + '032' => '부산은행', + '034' => '광주은행', + '035' => '제주은행', + '037' => '전북은행', + '039' => '경남은행', + '071' => '우체국', + '081' => '하나은행', + '088' => '신한은행', + '089' => '케이뱅크', + '090' => '카카오뱅크', + '007' => '수협은행', + '008' => '수출입은행', + ], + ], + + 'bank_2nd' => [ + 'label' => '2금융권/협동조합/서민금융', + 'items' => [ + '012' => '농․축협', + '045' => '새마을금고', + '048' => '신협', + '050' => '저축은행', + '064' => '산림조합중앙회', + '102' => '대신저축은행', + '103' => '에스비아이저축은행', + '104' => '에이치케이저축은행', + '105' => '웰컴저축은행', + '106' => '신한저축은행', + ], + ], + + 'global' => [ + 'label' => '글로벌/외국계 은행', + 'items' => [ + '052' => '모건스탠리은행', + '054' => 'HSBC은행', + '055' => '도이치은행', + '057' => '제이피모간체이스은행', + '058' => '미즈호은행', + '059' => '엠유에프지은행', + '060' => 'BOA은행', + '061' => '비엔피파리바은행', + '062' => '중국공상은행', + '063' => '중국은행', + '065' => '대화은행', + '066' => '교통은행', + '067' => '중국건설은행', + ], + ], + + 'securities' => [ + 'label' => '증권사', + 'items' => [ + '209' => '유안타증권', + '218' => 'KB증권', + '221' => '상상인증권', + '222' => '한양증권', + '223' => '리딩투자증권', + '224' => 'BNK투자증권', + '225' => 'IBK투자증권', + '227' => 'KTB투자증권', + '238' => '미래에셋대우', + '240' => '삼성증권', + '243' => '한국투자증권', + '247' => 'NH투자증권', + '261' => '교보증권', + '262' => '하이투자증권', + '263' => '현대차증권', + '264' => '키움증권', + '265' => '이베스트투자증권', + '266' => 'SK증권', + '267' => '대신증권', + '269' => '한화투자증권', + '270' => '하나금융투자', + '278' => '신한금융투자', + '279' => 'DB금융투자', + '280' => '유진투자증권', + '287' => '메리츠증권', + '288' => '카카오페이증권', + '290' => '부국증권', + '291' => '신영증권', + '292' => '케이프투자증권', + '293' => '한국증권금융', + '294' => '한국포스증권', + ], + ], + + // 필요하면 UI에서 숨겨도 됨 (flat에는 유지) + 'others' => [ + 'label' => '기타/유관기관', + 'items' => [ + '041' => '우리카드', + '044' => '외환카드', + '076' => '신용보증기금', + '077' => '기술보증기금', + '101' => '한국신용정보원', + ], + ], + ], + + /** + * ✅ 코드→이름 빠른 조회용 flat 맵 + */ + 'flat' => [ + '001' => '한국은행', + '002' => '산업은행', + '003' => '기업은행', + '004' => 'KB국민은행', + '007' => '수협은행', + '008' => '수출입은행', + '011' => 'NH농협은행', + '012' => '농․축협', + '020' => '우리은행', + '023' => 'SC제일은행', + '027' => '한국씨티은행', + '031' => '대구은행', + '032' => '부산은행', + '034' => '광주은행', + '035' => '제주은행', + '037' => '전북은행', + '039' => '경남은행', + '041' => '우리카드', + '044' => '외환카드', + '045' => '새마을금고', + '048' => '신협', + '050' => '저축은행', + '052' => '모건스탠리은행', + '054' => 'HSBC은행', + '055' => '도이치은행', + '057' => '제이피모간체이스은행', + '058' => '미즈호은행', + '059' => '엠유에프지은행', + '060' => 'BOA은행', + '061' => '비엔피파리바은행', + '062' => '중국공상은행', + '063' => '중국은행', + '064' => '산림조합중앙회', + '065' => '대화은행', + '066' => '교통은행', + '067' => '중국건설은행', + '071' => '우체국', + '076' => '신용보증기금', + '077' => '기술보증기금', + '081' => '하나은행', + '088' => '신한은행', + '089' => '케이뱅크', + '090' => '카카오뱅크', + '101' => '한국신용정보원', + '102' => '대신저축은행', + '103' => '에스비아이저축은행', + '104' => '에이치케이저축은행', + '105' => '웰컴저축은행', + '106' => '신한저축은행', + '209' => '유안타증권', + '218' => 'KB증권', + '221' => '상상인증권', + '222' => '한양증권', + '223' => '리딩투자증권', + '224' => 'BNK투자증권', + '225' => 'IBK투자증권', + '227' => 'KTB투자증권', + '238' => '미래에셋대우', + '240' => '삼성증권', + '243' => '한국투자증권', + '247' => 'NH투자증권', + '261' => '교보증권', + '262' => '하이투자증권', + '263' => '현대차증권', + '264' => '키움증권', + '265' => '이베스트투자증권', + '266' => 'SK증권', + '267' => '대신증권', + '269' => '한화투자증권', + '270' => '하나금융투자', + '278' => '신한금융투자', + '279' => 'DB금융투자', + '280' => '유진투자증권', + '287' => '메리츠증권', + '288' => '카카오페이증권', + '290' => '부국증권', + '291' => '신영증권', + '292' => '케이프투자증권', + '293' => '한국증권금융', + '294' => '한국포스증권', + ], +]; diff --git a/config/services.php b/config/services.php index 25365aa..dfd3632 100644 --- a/config/services.php +++ b/config/services.php @@ -41,4 +41,13 @@ return [ 'min_score' => (float) env('RECAPTCHA_MIN_SCORE', 0.5), ], + 'dozn' => [ + 'mode' => env('DOZN_MODE', 'real'), // real|test + 'org_code' => env('DOZN_ORG_CODE', ''), + 'api_key' => env('DOZN_API_KEY', ''), + 'url_real' => env('DOZN_URL_REAL', ''), + 'url_test' => env('DOZN_URL_TEST', ''), + 'timeout' => (int) env('DOZN_TIMEOUT', 10), + ], + ]; diff --git a/public/assets/js/mypage_renew.js b/public/assets/js/mypage_renew.js new file mode 100644 index 0000000..5214731 --- /dev/null +++ b/public/assets/js/mypage_renew.js @@ -0,0 +1,963 @@ +/* public/assets/js/mypage_renew.js */ +(function () { + 'use strict'; + + const CFG = window.mypageRenew || {}; + const URLS = (CFG.urls || {}); + + const $ = (sel, root = document) => root.querySelector(sel); + + function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + } + + function isMobileUA() { + const ua = navigator.userAgent || ''; + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); + } + + // ----------------------------- + // 1) 재인증 타이머 (항상 실행) + // - view는 data-expire(unix ts), 기존은 data-until(ISO) + // - 둘 다 지원하도록 수정 + // ----------------------------- + (function reauthCountdown() { + const box = document.querySelector('.mypage-reauth--countdown'); + const out = document.getElementById('reauthCountdown'); + if (!box || !out) return; + + const untilStr = (box.getAttribute('data-until') || '').trim(); + const expireTs = parseInt((box.getAttribute('data-expire') || '0').trim(), 10); + const remainFallback = parseInt(box.getAttribute('data-remain') || '0', 10); + + function parseUntilMsFromISO(s) { + if (!s) return null; + const isoLike = s.includes('T') ? s : s.replace(' ', 'T'); + const ms = Date.parse(isoLike); + return Number.isFinite(ms) ? ms : null; + } + + let untilMs = parseUntilMsFromISO(untilStr); + + // ✅ data-expire가 있으면 우선 사용 (unix seconds) + if (!untilMs && Number.isFinite(expireTs) && expireTs > 0) { + untilMs = expireTs * 1000; + } + + let remain = Math.max(0, Number.isFinite(remainFallback) ? remainFallback : 0); + let timer = null; + let expiredOnce = false; + + function fmt(sec) { + sec = Math.max(0, sec | 0); + const m = Math.floor(sec / 60); + const s = sec % 60; + return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); + } + + function getRemainSec() { + if (untilMs !== null) { + const diffMs = untilMs - Date.now(); + return Math.max(0, Math.floor(diffMs / 1000)); + } + return Math.max(0, remain); + } + + async function onExpiredOnce() { + if (expiredOnce) return; + expiredOnce = true; + + await showMsg( + "인증 허용 시간이 만료되었습니다.\n\n보안을 위해 다시 재인증이 필요합니다.", + { type: 'alert', title: '인증 만료' } + ); + + window.location.href = URLS.gateReset || '/mypage/info'; + } + + function tick() { + const sec = getRemainSec(); + out.textContent = fmt(sec); + + if (sec <= 0) { + if (timer) clearInterval(timer); + onExpiredOnce(); + return; + } + if (untilMs === null) remain -= 1; + } + + tick(); + timer = setInterval(tick, 1000); + })(); + + // ------------------------------------------- + // 2) PASS 연락처 변경 (기존 로직 유지) + // ------------------------------------------- + (function phoneChange() { + const btn = document.querySelector('[data-action="phone-change"]'); + if (!btn) return; + + const readyUrl = btn.getAttribute('data-ready-url') || URLS.passReady; + if (!readyUrl) return; + + function openIframeModal(popupName = 'danal_authtel_popup', w = 420, h = 750) { + const old = document.getElementById(popupName); + if (old) old.remove(); + + const wrap = document.createElement('div'); + wrap.id = popupName; + + wrap.innerHTML = ` +
+
+
+
PASS 본인인증
+ +
+ +
+ `; + + document.body.appendChild(wrap); + + function removeModal() { + const el = document.getElementById(popupName); + if (el) el.remove(); + } + + async function askCancelAndGo() { + const ok = await showMsg( + "인증을 중단하시겠습니까?\n\n닫으면 현재 변경 진행이 취소됩니다.", + { + type: "confirm", + title: "인증취소", + closeOnBackdrop: false, + closeOnX: false, + closeOnEsc: false, + } + ); + + if (!ok) return; + + const ifr = document.getElementById(popupName + "_iframe"); + if (ifr) ifr.src = "about:blank"; + + removeModal(); + } + + const closeBtn = wrap.querySelector('#' + popupName + '_close'); + if (closeBtn) closeBtn.addEventListener('click', askCancelAndGo); + + window.closeIframe = function () { + removeModal(); + }; + + return popupName + '_iframe'; + } + + function postToIframe(url, targetName, fieldsObj) { + const temp = document.createElement('form'); + temp.method = 'POST'; + temp.action = url; + temp.target = targetName; + + const csrf = document.createElement('input'); + csrf.type = 'hidden'; + csrf.name = '_token'; + csrf.value = csrfToken(); + temp.appendChild(csrf); + + const fields = document.createElement('input'); + fields.type = 'hidden'; + fields.name = 'fields'; + fields.value = JSON.stringify(fieldsObj || {}); + temp.appendChild(fields); + + const plat = document.createElement('input'); + plat.type = 'hidden'; + plat.name = 'platform'; + plat.value = isMobileUA() ? 'mobile' : 'web'; + temp.appendChild(plat); + + document.body.appendChild(temp); + temp.submit(); + temp.remove(); + } + + window.addEventListener('message', async (ev) => { + const d = ev.data || {}; + if (d.type !== 'danal_result') return; + + if (typeof window.closeIframe === 'function') window.closeIframe(); + + await showMsg( + d.message || (d.ok ? '본인인증이 완료되었습니다.' : '본인인증에 실패했습니다.'), + { type: 'alert', title: d.ok ? '인증 완료' : '인증 실패' } + ); + + if (d.redirect) window.location.href = d.redirect; + }); + + btn.addEventListener('click', async () => { + const ok = await showMsg( + `연락처를 변경하시겠습니까? + +• PASS 본인인증은 가입자 본인 명의 휴대전화로만 가능합니다. +• 인증 정보가 기존 회원정보와 일치하지 않으면 변경할 수 없습니다. + +계속 진행할까요?`, + { type: 'confirm', title: '연락처 변경' } + ); + if (!ok) return; + + btn.disabled = true; + + try { + const res = await fetch(readyUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ purpose: 'mypage_phone_change' }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok || data.ok === false) { + await showMsg(data.message || '본인인증 준비에 실패했습니다.', { type: 'alert', title: '오류' }); + return; + } + + if (data.reason === 'danal_ready' && data.popup && data.popup.url) { + const targetName = openIframeModal('danal_authtel_popup', 420, 750); + postToIframe(data.popup.url, targetName, data.popup.fields || {}); + return; + } + + await showMsg('ready 응답이 올바르지 않습니다. 서버 응답 형식을 확인해 주세요.', { type: 'alert', title: '오류' }); + } catch (e) { + await showMsg('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' }); + } finally { + btn.disabled = false; + } + }); + })(); + + // ------------------------------------------- + // 공통: 모달 스타일/유틸 + // ------------------------------------------- + function ensureCommonModalStyle(styleId, cssText) { + if (document.getElementById(styleId)) return; + const st = document.createElement('style'); + st.id = styleId; + st.textContent = cssText; + document.head.appendChild(st); + } + + function makeErrorSetter(wrap, errSel) { + const errBox = $(errSel, wrap); + return function setError(message) { + if (!errBox) return; + const msg = (message || '').trim(); + if (msg === '') { + errBox.style.display = 'none'; + errBox.textContent = ''; + return; + } + errBox.textContent = msg; + errBox.style.display = 'block'; + }; + } + + // ------------------------------------------- + // 3) 비밀번호 변경 레이어 팝업 (기존 유지 + 내부 에러) + // ------------------------------------------- + (function passwordChange() { + const trigger = document.querySelector('[data-action="pw-change"]'); + if (!trigger) return; + + const postUrl = URLS.passwordUpdate; + if (!postUrl) { + trigger.addEventListener('click', async () => { + await showMsg('비밀번호 변경 URL이 설정되지 않았습니다.', { type: 'alert', title: '오류' }); + }); + return; + } + + ensureCommonModalStyle('mypagePwModalStyle', ` + .mypage-pwmodal{position:fixed;inset:0;z-index:220000;display:flex;align-items:center;justify-content:center} + .mypage-pwmodal__dim{position:absolute;inset:0;background:#000;opacity:.55} + .mypage-pwmodal__box{position:relative;width:min(420px,calc(100% - 28px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35)} + .mypage-pwmodal__hd{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(0,0,0,.04);border-bottom:1px solid rgba(0,0,0,.08)} + .mypage-pwmodal__ttl{font-weight:900;font-size:14px;color:#111} + .mypage-pwmodal__close{width:34px;height:34px;border-radius:10px;border:1px solid rgba(0,0,0,.12);background:#fff;cursor:pointer;font-size:18px;line-height:1;color:#111} + .mypage-pwmodal__bd{padding:14px} + .mypage-pwmodal__row{margin-top:10px} + .mypage-pwmodal__label{display:block;font-size:12px;font-weight:800;color:#667085;margin-bottom:6px} + .mypage-pwmodal__inp{width:100%;height:42px;border-radius:12px;border:1px solid #e5e7eb;padding:0 12px;font-size:14px;outline:none} + .mypage-pwmodal__hint{margin-top:10px;font-size:12px;color:#667085;line-height:1.4} + .mypage-pwmodal__error{margin-top:12px;padding:10px 12px;border-radius:12px;background:rgba(220,38,38,.08);border:1px solid rgba(220,38,38,.25);color:#b91c1c;font-weight:800;font-size:12px;display:none;white-space:pre-line} + .mypage-pwmodal__ft{display:flex;gap:10px;padding:12px 14px;border-top:1px solid rgba(0,0,0,.08);background:#fff} + .mypage-pwmodal__btn{flex:1;height:42px;border-radius:12px;border:1px solid rgba(0,0,0,.12);background:#fff;font-weight:900;cursor:pointer} + .mypage-pwmodal__btn--primary{border:none;background:#111;color:#fff} + .mypage-pwmodal__btn[disabled]{opacity:.6;cursor:not-allowed} + `); + + function validateNewPw(pw) { + if (!pw) return '비밀번호를 입력해 주세요.'; + if (pw.length < 8) return '비밀번호는 8자리 이상이어야 합니다.'; + if (pw.length > 20) return '비밀번호는 20자리를 초과할 수 없습니다.'; + const re = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,20}$/; + if (!re.test(pw)) return '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.'; + return ''; + } + + function openModal() { + const old = $('#mypagePwModal'); + if (old) old.remove(); + + const wrap = document.createElement('div'); + wrap.className = 'mypage-pwmodal'; + wrap.id = 'mypagePwModal'; + + wrap.innerHTML = ` +
+ + `; + + document.body.appendChild(wrap); + + const setError = makeErrorSetter(wrap, '#pw_error'); + + function close() { wrap.remove(); } + + // ✅ 닫기: X / 취소만 + wrap.querySelector('.mypage-pwmodal__close')?.addEventListener('click', close); + wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close); + + setTimeout(() => $('#pw_current', wrap)?.focus(), 10); + + async function submit() { + setError(''); + + const cur = ($('#pw_current', wrap)?.value || '').trim(); + const pw1 = ($('#pw_new', wrap)?.value || '').trim(); + const pw2 = ($('#pw_new2', wrap)?.value || '').trim(); + + if (!cur) { setError('현재 비밀번호를 입력해 주세요.'); $('#pw_current', wrap)?.focus(); return; } + + const err = validateNewPw(pw1); + if (err) { setError(err); $('#pw_new', wrap)?.focus(); return; } + + if (pw1 !== pw2) { setError('변경할 비밀번호 확인이 일치하지 않습니다.'); $('#pw_new2', wrap)?.focus(); return; } + + const ok = await showMsg('비밀번호를 변경하시겠습니까?', { type: 'confirm', title: '비밀번호 변경' }); + if (!ok) return; + + const btn = wrap.querySelector('[data-act="submit"]'); + if (btn) btn.disabled = true; + + try { + const res = await fetch(postUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ + current_password: cur, + password: pw1, + password_confirmation: pw2, + }), + }); + + const data = await res.json().catch(() => ({})); + + if (res.status === 401 && data.redirect) { + await showMsg(data.message || '인증이 필요합니다.', { type: 'alert', title: '인증 필요' }); + window.location.href = data.redirect; + return; + } + + if (!res.ok || data.ok === false) { + const msg = + (data && data.message) || + (data && data.errors && (data.errors.password?.[0] || data.errors.current_password?.[0])) || + '비밀번호 변경에 실패했습니다.'; + setError(msg); + return; + } + + await showMsg(data.message || '비밀번호가 변경되었습니다.', { type: 'alert', title: '완료' }); + close(); + } catch (e) { + setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + if (btn) btn.disabled = false; + } + } + + wrap.querySelector('[data-act="submit"]')?.addEventListener('click', submit); + + wrap.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }); + } + + trigger.addEventListener('click', openModal); + })(); + + // ------------------------------------------- + // 4) 2차 비밀번호 변경 레이어 팝업 (신규) + // - 로그인 비밀번호 + 현재 2차비번 둘 다 검증 후 변경 + // - 에러는 내부 붉은 박스 + // - 닫힘: 취소 / X만 + // ------------------------------------------- + (function pin2Change() { + const trigger = document.querySelector('[data-action="pw2-change"]'); + if (!trigger) return; + + const postUrl = URLS.pin2Update; // Blade에서 주입 필요 + if (!postUrl) { + trigger.addEventListener('click', async () => { + await showMsg('2차 비밀번호 변경 URL이 설정되지 않았습니다.', { type: 'alert', title: '오류' }); + }); + return; + } + + ensureCommonModalStyle('mypagePin2ModalStyle', ` + .mypage-pin2modal{position:fixed;inset:0;z-index:220000;display:flex;align-items:center;justify-content:center} + .mypage-pin2modal__dim{position:absolute;inset:0;background:#000;opacity:.55} + .mypage-pin2modal__box{position:relative;width:min(420px,calc(100% - 28px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35)} + .mypage-pin2modal__hd{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(0,0,0,.04);border-bottom:1px solid rgba(0,0,0,.08)} + .mypage-pin2modal__ttl{font-weight:900;font-size:14px;color:#111} + .mypage-pin2modal__close{width:34px;height:34px;border-radius:10px;border:1px solid rgba(0,0,0,.12);background:#fff;cursor:pointer;font-size:18px;line-height:1;color:#111} + .mypage-pin2modal__bd{padding:14px} + .mypage-pin2modal__row{margin-top:10px} + .mypage-pin2modal__label{display:block;font-size:12px;font-weight:800;color:#667085;margin-bottom:6px} + .mypage-pin2modal__inp{width:100%;height:42px;border-radius:12px;border:1px solid #e5e7eb;padding:0 12px;font-size:14px;outline:none} + .mypage-pin2modal__inp--pin{letter-spacing:6px;text-align:center;font-weight:900} + .mypage-pin2modal__hint{margin-top:10px;font-size:12px;color:#667085;line-height:1.4} + .mypage-pin2modal__error{margin-top:12px;padding:10px 12px;border-radius:12px;background:rgba(220,38,38,.08);border:1px solid rgba(220,38,38,.25);color:#b91c1c;font-weight:800;font-size:12px;display:none;white-space:pre-line} + .mypage-pin2modal__ft{display:flex;gap:10px;padding:12px 14px;border-top:1px solid rgba(0,0,0,.08);background:#fff} + .mypage-pin2modal__btn{flex:1;height:42px;border-radius:12px;border:1px solid rgba(0,0,0,.12);background:#fff;font-weight:900;cursor:pointer} + .mypage-pin2modal__btn--primary{border:none;background:#111;color:#fff} + .mypage-pin2modal__btn[disabled]{opacity:.6;cursor:not-allowed} + `); + + function isPin4(v) { + return /^\d{4}$/.test(v || ''); + } + + function openModal() { + const old = $('#mypagePin2Modal'); + if (old) old.remove(); + + const wrap = document.createElement('div'); + wrap.className = 'mypage-pin2modal'; + wrap.id = 'mypagePin2Modal'; + + wrap.innerHTML = ` +
+ + `; + + document.body.appendChild(wrap); + + const setError = makeErrorSetter(wrap, '#pin2_error'); + + function close() { wrap.remove(); } + + // ✅ 닫기: X / 취소만 + wrap.querySelector('.mypage-pin2modal__close')?.addEventListener('click', close); + wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close); + + setTimeout(() => $('#pin2_current_password', wrap)?.focus(), 10); + + // 숫자만 입력 보정(편의) — 필요 없으면 빼도 됨 + function onlyDigitsMax4(el) { + if (!el) return; + el.addEventListener('input', () => { + el.value = (el.value || '').replace(/[^\d]/g, '').slice(0, 4); + }); + } + onlyDigitsMax4($('#pin2_current', wrap)); + onlyDigitsMax4($('#pin2_new', wrap)); + onlyDigitsMax4($('#pin2_new2', wrap)); + + async function submit() { + setError(''); + + const curPw = ($('#pin2_current_password', wrap)?.value || '').trim(); + const curPin2 = ($('#pin2_current', wrap)?.value || '').trim(); + const pin2 = ($('#pin2_new', wrap)?.value || '').trim(); + const pin2c = ($('#pin2_new2', wrap)?.value || '').trim(); + + if (!curPw) { setError('이전 비밀번호를 입력해 주세요.'); $('#pin2_current_password', wrap)?.focus(); return; } + if (!isPin4(curPin2)) { setError('이전 2차 비밀번호는 숫자 4자리여야 합니다.'); $('#pin2_current', wrap)?.focus(); return; } + if (!isPin4(pin2)) { setError('2차 비밀번호는 숫자 4자리여야 합니다.'); $('#pin2_new', wrap)?.focus(); return; } + if (pin2 !== pin2c) { setError('2차 비밀번호 확인이 일치하지 않습니다.'); $('#pin2_new2', wrap)?.focus(); return; } + + const ok = await showMsg('2차 비밀번호를 변경하시겠습니까?', { type: 'confirm', title: '2차 비밀번호 변경' }); + if (!ok) return; + + const btn = wrap.querySelector('[data-act="submit"]'); + if (btn) btn.disabled = true; + + try { + const res = await fetch(postUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ + current_password: curPw, + current_pin2: curPin2, + pin2: pin2, + pin2_confirmation: pin2c, + }), + }); + + const data = await res.json().catch(() => ({})); + + if (res.status === 401 && data.redirect) { + await showMsg(data.message || '인증이 필요합니다.', { type: 'alert', title: '인증 필요' }); + window.location.href = data.redirect; + return; + } + + if (!res.ok || data.ok === false) { + // 422 validate or mismatch + const msg = + (data && data.message) || + (data && data.errors && ( + data.errors.current_password?.[0] || + data.errors.current_pin2?.[0] || + data.errors.pin2?.[0] || + data.errors.pin2_confirmation?.[0] + )) || + '2차 비밀번호 변경에 실패했습니다.'; + + setError(msg); + return; + } + + await showMsg(data.message || '2차 비밀번호가 변경되었습니다.', { type: 'alert', title: '완료' }); + close(); + } catch (e) { + setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + if (btn) btn.disabled = false; + } + } + + wrap.querySelector('[data-act="submit"]')?.addEventListener('click', submit); + + wrap.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }); + } + + trigger.addEventListener('click', openModal); + })(); + + // ------------------------------------------- +// X) 출금계좌 등록/수정 (Dozn 성명인증 + 저장) +// - 입력: 2차 비밀번호(4자리), 금융권, 은행, 계좌번호(숫자만), 예금주 +// - showMsg confirm/alert 사용 +// - 에러는 모달 내부 붉은 글씨 +// - 닫기: X / 취소만 (dim 클릭/ESC 닫기 없음) +// ------------------------------------------- + (function withdrawAccount() { + const trigger = document.querySelector('[data-action="withdraw-account"]'); + if (!trigger) return; + + const postUrl = URLS.withdrawVerifyOut; + const defaultDepositor = (CFG.memberName || '').trim(); + + // ✅ bankGroups는 config(bank_code.php)의 groups 구조(label/items)로 전달된다고 가정 + const BANK_GROUPS = (CFG.bankGroups || {}); + + if (!postUrl) { + trigger.addEventListener('click', async () => { + await showMsg('출금계좌 인증 URL이 설정되지 않았습니다.', { type: 'alert', title: '오류' }); + }); + return; + } + + ensureCommonModalStyle('mypageWithdrawModalStyle', ` + .mypage-withmodal{position:fixed;inset:0;z-index:220000;display:flex;align-items:center;justify-content:center} + .mypage-withmodal__dim{position:absolute;inset:0;background:#000;opacity:.55} + .mypage-withmodal__box{position:relative;width:min(460px,calc(100% - 28px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35)} + .mypage-withmodal__hd{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(0,0,0,.04);border-bottom:1px solid rgba(0,0,0,.08)} + .mypage-withmodal__ttl{font-weight:900;font-size:14px;color:#111} + .mypage-withmodal__close{width:34px;height:34px;border-radius:10px;border:1px solid rgba(0,0,0,.12);background:#fff;cursor:pointer;font-size:18px;line-height:1;color:#111} + .mypage-withmodal__bd{padding:14px} + .mypage-withmodal__row{margin-top:10px} + .mypage-withmodal__label{display:block;font-size:12px;font-weight:800;color:#667085;margin-bottom:6px} + .mypage-withmodal__inp{width:100%;height:42px;border-radius:12px;border:1px solid #e5e7eb;padding:0 12px;font-size:14px;outline:none} + .mypage-withmodal__inp--pin{letter-spacing:6px;text-align:center;font-weight:900} + .mypage-withmodal__select{width:100%;height:42px;border-radius:12px;border:1px solid #e5e7eb;padding:0 10px;font-size:14px;outline:none;background:#fff} + .mypage-withmodal__hint{margin-top:10px;font-size:12px;color:#667085;line-height:1.4} + .mypage-withmodal__error{margin-top:12px;padding:10px 12px;border-radius:12px;background:rgba(220,38,38,.08);border:1px solid rgba(220,38,38,.25);color:#b91c1c;font-weight:800;font-size:12px;display:none;white-space:pre-line} + .mypage-withmodal__ft{display:flex;gap:10px;padding:12px 14px;border-top:1px solid rgba(0,0,0,.08);background:#fff} + .mypage-withmodal__btn{flex:1;height:42px;border-radius:12px;border:1px solid rgba(0,0,0,.12);background:#fff;font-weight:900;cursor:pointer} + .mypage-withmodal__btn--primary{border:none;background:#111;color:#fff} + .mypage-withmodal__btn[disabled]{opacity:.6;cursor:not-allowed} + `); + + function isPin4(v){ return /^\d{4}$/.test(v || ''); } + function isDigits(v){ return /^\d+$/.test(v || ''); } + function isBankCode3(v){ return /^\d{3}$/.test(v || ''); } + + function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, m => ({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + }[m])); + } + + function maskAccount(v) { + const s = String(v || ''); + if (s.length <= 4) return s; + return '****' + s.slice(-4); + } + + function groupOptionsHtml() { + const keys = Object.keys(BANK_GROUPS || {}); + const opts = ['']; + for (const k of keys) { + const label = BANK_GROUPS[k]?.label || k; + opts.push(``); + } + return opts.join(''); + } + + function bankOptionsByGroupHtml(groupKey) { + const group = BANK_GROUPS[groupKey]; + const items = group?.items || {}; + const codes = Object.keys(items); + + if (!groupKey || !group || codes.length === 0) { + return ''; + } + + // 코드 오름차순 + codes.sort(); + const opts = ['']; + for (const code of codes) { + const name = items[code]; + opts.push(``); + } + return opts.join(''); + } + + function getBankName(groupKey, bankCode) { + return (BANK_GROUPS[groupKey]?.items && BANK_GROUPS[groupKey].items[bankCode]) ? BANK_GROUPS[groupKey].items[bankCode] : ''; + } + + function openModal() { + const old = document.getElementById('mypageWithdrawModal'); + if (old) old.remove(); + + const isEdit = trigger.getAttribute('aria-label')?.includes('수정'); + + const wrap = document.createElement('div'); + wrap.className = 'mypage-withmodal'; + wrap.id = 'mypageWithdrawModal'; + + wrap.innerHTML = ` +
+ + `; + + document.body.appendChild(wrap); + + const setError = makeErrorSetter(wrap, '#with_error'); + const close = () => wrap.remove(); + + // ✅ 닫기: X / 취소만 + wrap.querySelector('.mypage-withmodal__close')?.addEventListener('click', close); + wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close); + + const pinEl = document.getElementById('with_pin2'); + const groupEl = document.getElementById('with_group'); + const bankEl = document.getElementById('with_bank'); + const accEl = document.getElementById('with_account'); + const depEl = document.getElementById('with_depositor'); + + // 숫자만 입력 보정 + if (pinEl) pinEl.addEventListener('input', () => { + pinEl.value = (pinEl.value || '').replace(/[^\d]/g, '').slice(0, 4); + }); + if (accEl) accEl.addEventListener('input', () => { + accEl.value = (accEl.value || '').replace(/[^\d]/g, ''); + }); + + // ✅ 금융권 선택 → 은행 목록 갱신 + groupEl?.addEventListener('change', () => { + const g = (groupEl.value || '').trim(); + bankEl.innerHTML = bankOptionsByGroupHtml(g); + bankEl.value = ''; + }); + + setTimeout(() => pinEl?.focus(), 10); + + async function submit() { + setError(''); + + const pin2 = (pinEl?.value || '').trim(); + const groupKey = (groupEl?.value || '').trim(); + const bankCode = (bankEl?.value || '').trim(); + const account = (accEl?.value || '').trim(); + const depositor = (depEl?.value || '').trim(); + + if (!isPin4(pin2)) { setError('2차 비밀번호는 숫자 4자리여야 합니다.'); pinEl?.focus(); return; } + if (!groupKey || !BANK_GROUPS[groupKey]) { setError('금융권을 선택해 주세요.'); groupEl?.focus(); return; } + if (!isBankCode3(bankCode)) { setError('은행을 선택해 주세요.'); bankEl?.focus(); return; } + + // ✅ 선택한 금융권 안에 실제로 존재하는 은행인지 (클라 방어) + const bankName = getBankName(groupKey, bankCode); + if (!bankName) { setError('선택한 은행 정보가 올바르지 않습니다. 다시 선택해 주세요.'); bankEl?.focus(); return; } + + if (!account || !isDigits(account)) { setError('계좌번호는 숫자만 입력해 주세요.'); accEl?.focus(); return; } + if (!depositor) { setError('예금주(성명)를 입력해 주세요.'); depEl?.focus(); return; } + + const ok = await showMsg( + `출금계좌를 인증 후 저장하시겠습니까?\n\n금융권: ${BANK_GROUPS[groupKey]?.label || groupKey}\n은행: ${bankName} (${bankCode})\n계좌: ${maskAccount(account)}\n예금주: ${depositor}`, + { type: 'confirm', title: '출금계좌 인증/저장' } + ); + if (!ok) return; + + const btn = wrap.querySelector('[data-act="submit"]'); + if (btn) btn.disabled = true; + + try { + const res = await fetch(postUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ + pin2: pin2, + bank_code: bankCode, + account: account, + depositor: depositor, + // groupKey는 서버 필수는 아니지만, 디버깅/검증 강화용으로 보내도 됨 + // bank_group: groupKey, + }), + }); + + const data = await res.json().catch(() => ({})); + + if (res.status === 401 && data.redirect) { + await showMsg(data.message || '인증이 필요합니다.', { type: 'alert', title: '인증 필요' }); + window.location.href = data.redirect; + return; + } + + if (!res.ok || data.ok === false) { + const msg = + (data && data.message) || + (data && data.errors && ( + data.errors.pin2?.[0] || + data.errors.bank_code?.[0] || + data.errors.account?.[0] || + data.errors.depositor?.[0] + )) || + '계좌 인증/저장에 실패했습니다.'; + + setError(msg); + return; + } + + await showMsg(data.message || '인증완료 및 계좌번호가 등록되었습니다.', { type: 'alert', title: '완료' }); + close(); + window.location.reload(); + } catch (e) { + setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + if (btn) btn.disabled = false; + } + } + + wrap.querySelector('[data-act="submit"]')?.addEventListener('click', submit); + wrap.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }); + } + + trigger.addEventListener('click', openModal); + + })(); + + + + // ------------------------------------------- + // 5) 기타 버튼들(준비중) + // ------------------------------------------- + (function others() { + + $('[data-action="consent-edit"]')?.addEventListener('click', async () => { + await showMsg('준비중입니다.', { type: 'alert', title: '수신 동의' }); + }); + + $('[data-action="withdraw-member"]')?.addEventListener('click', async () => { + const ok = await showMsg( + `회원탈퇴를 진행하시겠습니까? + +• 탈퇴 시 계정 복구가 어려울 수 있습니다. +• 진행 전 보유 내역/정산/환불 정책을 확인해 주세요.`, + { type: 'confirm', title: '회원탈퇴' } + ); + + if (!ok) return; + + await showMsg('준비중입니다.', { type: 'alert', title: '회원탈퇴' }); + }); + })(); + +})(); diff --git a/resources/css/ui-dialog.css b/resources/css/ui-dialog.css index 36fdfb3..397e4bb 100644 --- a/resources/css/ui-dialog.css +++ b/resources/css/ui-dialog.css @@ -18,7 +18,7 @@ box-shadow: 0 20px 60px rgba(0,0,0,.55); overflow: hidden; } -.ui-dialog { position: fixed; inset: 0; z-index: 200000; display: none; } +.ui-dialog { position: fixed; inset: 0; z-index: 300000; display: none; } .ui-dialog__header { display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.10); } .ui-dialog__title { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.2px; } .ui-dialog__x { diff --git a/resources/js/ui/dialog.js b/resources/js/ui/dialog.js index b9630f2..1c8505a 100644 --- a/resources/js/ui/dialog.js +++ b/resources/js/ui/dialog.js @@ -3,6 +3,13 @@ class UiDialog { this.root = document.getElementById(rootId); if (!this.root) return; + // ✅ stacking context 문제 방지: 무조건 body 직속으로 이동 + try { + if (this.root.parentElement !== document.body) { + document.body.appendChild(this.root); + } + } catch (e) {} + this.titleEl = this.root.querySelector("#uiDialogTitle"); this.msgEl = this.root.querySelector("#uiDialogMessage"); this.okBtn = this.root.querySelector("#uiDialogOk"); @@ -11,23 +18,21 @@ class UiDialog { this._resolver = null; this._type = "alert"; this._lastFocus = null; + this._closePolicy = { backdrop: false, x: false, esc: false, enter: true }; // close triggers this.root.addEventListener("click", (e) => { if (!e.target?.dataset?.uidialogClose) return; - // 어떤 close인지 구분 (backdrop / x) const isBackdrop = !!e.target.closest(".ui-dialog__backdrop"); const isX = !!e.target.closest(".ui-dialog__x"); - // 정책에 따라 차단 (기본: 모두 차단) if (isBackdrop && !this._closePolicy?.backdrop) return; if (isX && !this._closePolicy?.x) return; this._resolve(false); }); - // keyboard document.addEventListener("keydown", (e) => { if (!this.isOpen()) return; @@ -37,13 +42,28 @@ class UiDialog { return; } if (e.key === "Enter") { - // Enter는 OK로 처리 (기본 true) if (this._closePolicy?.enter !== false) this._resolve(true); return; } }); - this.okBtn.addEventListener("click", () => this._resolve(true)); - this.cancelBtn.addEventListener("click", () => this._resolve(false)); + + // 버튼 이벤트(존재할 때만) + this.okBtn?.addEventListener("click", () => this._resolve(true)); + this.cancelBtn?.addEventListener("click", () => this._resolve(false)); + } + + // ✅ 현재 페이지 최상단 z-index 탐색 (모달 겹침 대응) + static getTopZIndex() { + let maxZ = 0; + const nodes = document.querySelectorAll("body *"); + for (const el of nodes) { + const cs = getComputedStyle(el); + if (cs.position === "fixed" || cs.position === "absolute" || cs.position === "sticky") { + const z = parseInt(cs.zIndex, 10); + if (!Number.isNaN(z)) maxZ = Math.max(maxZ, z); + } + } + return maxZ; } isOpen() { @@ -74,6 +94,13 @@ class UiDialog { closeOnEnter = true, } = options; + // ✅ 항상 최상단: DOM 마지막 + z-index 최상단 + try { + document.body.appendChild(this.root); + const topZ = UiDialog.getTopZIndex(); + this.root.style.zIndex = String(Math.max(300000, topZ + 10)); // 300000 안전 마진 + } catch (e) {} + this._closePolicy = { backdrop: !!closeOnBackdrop, x: !!closeOnX, @@ -84,21 +111,22 @@ class UiDialog { this._type = type; this._lastFocus = document.activeElement; - this.titleEl.textContent = title; - this.msgEl.textContent = message ?? ""; + // 요소가 없으면 조용히 실패(페이지별 차이 방어) + if (this.titleEl) this.titleEl.textContent = title; + if (this.msgEl) this.msgEl.textContent = message ?? ""; - this.okBtn.textContent = okText; - this.cancelBtn.textContent = cancelText; + if (this.okBtn) this.okBtn.textContent = okText; + if (this.cancelBtn) this.cancelBtn.textContent = cancelText; // alert면 cancel 숨김 - if (type === "alert") { - this.cancelBtn.style.display = "none"; - } else { - this.cancelBtn.style.display = ""; + if (this.cancelBtn) { + this.cancelBtn.style.display = (type === "alert") ? "none" : ""; } // danger 스타일 - this.okBtn.classList.toggle("ui-dialog__btn--danger", !!dangerous); + if (this.okBtn) { + this.okBtn.classList.toggle("ui-dialog__btn--danger", !!dangerous); + } // open this.root.classList.add("is-open"); @@ -106,7 +134,9 @@ class UiDialog { document.documentElement.style.overflow = "hidden"; // focus - setTimeout(() => this.okBtn.focus(), 0); + setTimeout(() => { + try { this.okBtn?.focus?.(); } catch (e) {} + }, 0); return new Promise((resolve) => { this._resolver = resolve; @@ -137,33 +167,20 @@ window.uiDialog = new UiDialog(); // ====================================================== // ✅ Global showMsg / clearMsg (공통 사용) -// - 페이지에서는 showMsg/clearMsg만 호출 -// - await showMsg(...) 하면 버튼 클릭 전까지 다음 코드 진행이 멈춤 // ====================================================== (function () { let cachedHelpEl = null; function getHelpEl(opt = {}) { - // opt.helpId를 주면 그걸 우선 사용 if (opt.helpId) { const el = document.getElementById(opt.helpId); if (el) return el; } - // 기본 fallback id (너가 쓰는 reg_phone_help) if (cachedHelpEl && document.contains(cachedHelpEl)) return cachedHelpEl; cachedHelpEl = document.getElementById("reg_phone_help"); return cachedHelpEl; } - /** - * showMsg(msg, opt) - * opt: { - * type:'alert'|'confirm', - * title, okText, cancelText, dangerous, - * redirect, helpId - * } - * return: Promise (confirm: true/false, alert: true) - */ window.showMsg = async function (msg, opt = {}) { const d = Object.assign({ type: "alert", @@ -173,23 +190,23 @@ window.uiDialog = new UiDialog(); dangerous: false, redirect: "", helpId: "", + // ✅ 닫기 정책 기본값(너가 말한 “다른 공간 클릭해도 닫히지 않게”와 일치) + closeOnBackdrop: false, + closeOnX: false, + closeOnEsc: false, + closeOnEnter: true, }, opt || {}); - // ✅ 모달이 있으면 모달로 (여기서 await로 멈춤) if (window.uiDialog && typeof window.uiDialog[d.type] === "function") { const ok = await window.uiDialog[d.type](msg || "", d); - if (d.redirect) { - // alert: OK(true)일 때만 / confirm: OK(true)일 때만 - if (ok) { - window.location.href = d.redirect; - } + if (d.redirect && ok) { + window.location.href = d.redirect; } return d.type === "confirm" ? !!ok : true; } - // ✅ fallback (모달이 없으면 help 영역에 표시) const helpEl = getHelpEl(d); if (helpEl) { helpEl.style.display = "block"; @@ -197,14 +214,10 @@ window.uiDialog = new UiDialog(); return true; } - // 마지막 fallback alert(msg || ""); return true; }; - /** - * clearMsg(helpId?) - */ window.clearMsg = function (helpId = "") { const el = helpId ? document.getElementById(helpId) : getHelpEl({}); if (!el) return; @@ -223,21 +236,14 @@ document.addEventListener("DOMContentLoaded", async () => { let payload = null; try { payload = JSON.parse(flashEl.textContent || "{}"); } catch (e) {} - if (!payload) return; - /** - * payload 예시: - * { type:'alert'|'confirm', message:'...', title:'...', okText:'...', cancelText:'...', redirect:'/...' } - */ const type = payload.type || "alert"; const ok = await window.uiDialog[type](payload.message || "", payload); - // confirm이고 OK일 때만 redirect 같은 후속 처리 if (type === "confirm") { if (ok && payload.redirect) window.location.href = payload.redirect; } else { if (payload.redirect) window.location.href = payload.redirect; } }); - diff --git a/resources/views/mail/layouts/base.blade.php b/resources/views/mail/layouts/base.blade.php index 6078918..54ba621 100644 --- a/resources/views/mail/layouts/base.blade.php +++ b/resources/views/mail/layouts/base.blade.php @@ -50,7 +50,7 @@
- © {{ date('Y') }} {{ $brand }}. All rights reserved. + © 2018 {{ $brand }}. All rights reserved.
diff --git a/resources/views/web/auth/register_terms.blade.php b/resources/views/web/auth/register_terms.blade.php index a6e9f74..a3961a7 100644 --- a/resources/views/web/auth/register_terms.blade.php +++ b/resources/views/web/auth/register_terms.blade.php @@ -758,19 +758,28 @@ async function askCancelAndGo() { const msg = "인증을 중단합니다.\n처음부터 다시 진행 하시겠습니까?"; - // ✅ 시스템 confirm (레이어/모달 z-index 영향을 안 받음) - if (window.confirm(msg)) { - // iframe 강제 종료(선택) - const ifr = document.getElementById(popupName + '_iframe'); - if (ifr) ifr.src = 'about:blank'; + const ok = await showMsg(msg, { + type: "confirm", + title: "인증취소", + okText: "처음부터", + cancelText: "계속 진행", + closeOnBackdrop: false, + closeOnX: false, + closeOnEsc: false, + }); - // 모달 제거 - const el = document.getElementById(popupName); - if (el) el.remove(); + if (!ok) return; - // Step0로 이동 - window.location.href = @json(route('web.auth.register')); - } + // iframe 강제 종료(선택) + const ifr = document.getElementById(popupName + "_iframe"); + if (ifr) ifr.src = "about:blank"; + + // 모달 제거 + const el = document.getElementById(popupName); + if (el) el.remove(); + + // Step0로 이동 + window.location.href = @json(route('web.auth.register')); } diff --git a/resources/views/web/mypage/info/renew.blade.php b/resources/views/web/mypage/info/renew.blade.php index 1cb7dc0..033ca03 100644 --- a/resources/views/web/mypage/info/renew.blade.php +++ b/resources/views/web/mypage/info/renew.blade.php @@ -97,22 +97,28 @@
비밀번호 변경
보안을 위해 주기적으로 변경을 권장해요
-
준비중
+
변경
- + - +