인증 나의정보 정보수정 이메일인증 비밀번호 변경 메일발송

This commit is contained in:
sungro815 2026-02-02 09:27:01 +09:00
parent 5f950a4420
commit 5cb2bc299f
47 changed files with 3421 additions and 1305 deletions

View File

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Web\Auth;
use App\Http\Controllers\Controller;
use App\Services\EmailVerificationService;
use Illuminate\Http\Request;
class EmailVerificationController extends Controller
{
public function requiredPage(Request $request, EmailVerificationService $svc)
{
$au = $svc->getAuthUserFromSession($request);
if (!$au) {
return redirect()->route('web.home')
->with('ui_dialog', [
'type' => 'alert',
'title' => '안내',
'message' => '잘못된 접근입니다.',
]);
}
return view('web.auth.email_required', [
'email' => $au['email'],
'memNo' => $au['mem_no'],
]);
}
public function sendVerify(Request $request, EmailVerificationService $svc)
{
$au = $svc->getAuthUserFromSession($request);
if (!$au) {
return redirect()->route('web.home')
->with('ui_dialog', [
'type' => 'alert',
'title' => '안내',
'message' => '잘못된 접근입니다.',
]);
}
try {
$res = $svc->sendVerifyMail($request, (int)$au['mem_no'], (string)$au['email']);
return back()->with('ui_dialog', [
'type' => 'alert',
'title' => '안내',
'message' => $res['message'] ?? '인증메일을 발송했습니다. 메일함을 확인해 주세요.',
]);
} catch (\Throwable $e) {
\Log::error('Email verify send failed', [
'mem_no' => $au['mem_no'],
'email' => $au['email'],
'error' => $e->getMessage(),
]);
return back()->with('ui_dialog', [
'type' => 'alert',
'title' => '오류',
'message' => '인증메일 발송 중 오류가 발생했습니다.',
]);
}
}
public function verify(Request $request, EmailVerificationService $svc)
{
$memNo = (int)$request->query('mem_no', 0);
$k = (string)$request->query('k', '');
$res = $svc->verifySignedLink($request, $memNo, $k);
if (!($res['ok'] ?? false)) {
return redirect('/')->with('alert', $res['message'] ?? '잘못된 접근입니다.');
}
// 인증 완료 후 임시세션 제거
$request->session()->forget('auth_user');
return view('web.auth.email_verified', [
'email' => (string)($res['email'] ?? ''),
'loginUrl' => route('web.auth.login'),
]);
}
}

View File

@ -3,9 +3,10 @@
namespace App\Http\Controllers\Web\Auth; namespace App\Http\Controllers\Web\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\SmsService; use App\Repositories\Member\MemberAuthRepository;
use App\Support\LegacyCrypto\CiSeedCrypto; use App\Support\LegacyCrypto\CiSeedCrypto;
use App\Models\Member\MemInfo; use App\Rules\RecaptchaV3Rule;
use App\Services\SmsService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -33,37 +34,41 @@ class FindIdController extends Controller
{ {
logger()->info('HIT sendCode', ['path' => request()->path(), 'host' => request()->getHost()]); logger()->info('HIT sendCode', ['path' => request()->path(), 'host' => request()->getHost()]);
$v = Validator::make($request->all(), [ $rules = [
'phone' => ['required', 'string', 'max:20'], 'phone' => ['required', 'string', 'max:20'],
], [ ];
if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('find_id')];
}
$v = Validator::make($request->all(), $rules, [
'phone.required' => '휴대폰 번호를 입력해 주세요.', 'phone.required' => '휴대폰 번호를 입력해 주세요.',
'g-recaptcha-response.required' => '올바른 접근이 아닙니다.',
]); ]);
if ($v->fails()) { if ($v->fails()) {
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
} }
$phoneRaw = $request->input('phone'); $phoneRaw = (string) $request->input('phone');
$phone = $this->normalizeKoreanPhone($phoneRaw); $phone = $this->normalizeKoreanPhone($phoneRaw);
if (!$phone) { if (!$phone) {
return response()->json(['ok' => false, 'message' => '휴대폰 번호 형식이 올바르지 않습니다.'], 422); return response()->json(['ok' => false, 'message' => '휴대폰 번호 형식이 올바르지 않습니다.'], 422);
} }
// ✅ 0) DB에 가입된 휴대폰인지 먼저 확인
/** @var CiSeedCrypto $seed */ /** @var CiSeedCrypto $seed */
$seed = app(CiSeedCrypto::class); $seed = app(CiSeedCrypto::class);
// DB에 저장된 방식(동일)으로 암호화해서 비교 // DB에 저장된 방식(동일)으로 암호화해서 비교
$phoneEnc = $seed->encrypt($phone); $phoneEnc = $seed->encrypt($phone);
$exists = MemInfo::query() $repo = app(MemberAuthRepository::class);
->whereNotNull('email') $exists = $repo->existsByEncryptedPhone($phoneEnc);
->where('email', '<>', '')
->where('cell_phone', $phoneEnc)
->exists();
if (!$exists) { if (!$exists) {
//세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공 //\세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'code' => 'PHONE_NOT_FOUND', 'code' => 'PHONE_NOT_FOUND',
@ -80,7 +85,7 @@ class FindIdController extends Controller
} }
RateLimiter::hit($key, 600); RateLimiter::hit($key, 600);
// 6자리 OTP 생성 // 6자리 난수 생성
$code = (string) random_int(100000, 999999); $code = (string) random_int(100000, 999999);
// 세션 저장(보안: 실제로는 해시 저장 권장, 여기선 간단 구현) // 세션 저장(보안: 실제로는 해시 저장 권장, 여기선 간단 구현)
@ -134,7 +139,7 @@ class FindIdController extends Controller
'message' => '인증번호를 발송했습니다.', 'message' => '인증번호를 발송했습니다.',
'expires_in' => 180, 'expires_in' => 180,
'step' => 2, 'step' => 2,
'dev_code' => $isLocal ? $code : null, //'dev_code' => $isLocal ? $code : null,
]); ]);
} }
@ -189,15 +194,8 @@ class FindIdController extends Controller
// 키를 넘기지 말고, CiSeedCrypto가 생성자 주입된 userKey로 처리하게 통일 // 키를 넘기지 말고, CiSeedCrypto가 생성자 주입된 userKey로 처리하게 통일
$phoneEnc = $crypto->encrypt($phone); $phoneEnc = $crypto->encrypt($phone);
$emails = MemInfo::query() $repo = app(MemberAuthRepository::class);
->whereNotNull('email') $emails = $repo->getEmailsByEncryptedPhone($phoneEnc);
->where('email', '<>', '')
->where('cell_phone', $phoneEnc) //DB에 저장된 암호문과 동일하게 매칭됨
->orderByDesc('mem_no')
->pluck('email')
->unique()
->values()
->all();
if (empty($emails)) { if (empty($emails)) {
// 운영에서는 암호문 노출 절대 금지 (지금은 디버그용이면 로그로만) // 운영에서는 암호문 노출 절대 금지 (지금은 디버그용이면 로그로만)
@ -231,8 +229,6 @@ class FindIdController extends Controller
{ {
$digits = preg_replace('/\D+/', '', $input); $digits = preg_replace('/\D+/', '', $input);
if (!$digits) return null; if (!$digits) return null;
// 010XXXXXXXX (11), 01XXXXXXXXX (10) 정도만 허용 예시
if (Str::startsWith($digits, '010') && strlen($digits) === 11) return $digits; if (Str::startsWith($digits, '010') && strlen($digits) === 11) return $digits;
if (preg_match('/^01[0-9]{8,9}$/', $digits)) return $digits; // 필요시 범위 조정 if (preg_match('/^01[0-9]{8,9}$/', $digits)) return $digits; // 필요시 범위 조정
return null; return null;

View File

@ -1,22 +1,18 @@
<?php <?php
namespace App\Http\Controllers\Web\Auth;
namespace App\Http\Controllers\Web\Auth; namespace App\Http\Controllers\Web\Auth;
use App\Services\MailService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Member\MemInfo; use App\Rules\RecaptchaV3Rule;
use App\Services\FindPasswordService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
class FindPasswordController extends Controller class FindPasswordController extends Controller
{ {
public function show(Request $request) public function show(Request $request)
{ {
$sess = $request->session()->get('find_pw', []); $sess = (array) $request->session()->get('find_pw', []);
$step = 1; $step = 1;
if (!empty($sess['verified'])) $step = 3; if (!empty($sess['verified'])) $step = 3;
@ -24,238 +20,120 @@ class FindPasswordController extends Controller
return view('web.auth.find_password', [ return view('web.auth.find_password', [
'initialStep' => $step, 'initialStep' => $step,
'email' => $sess['email'] ?? null, 'email' => $sess['email'] ?? null,
'name' => $sess['name'] ?? null,
]); ]);
} }
public function sendCode(Request $request) public function sendMail(Request $request, FindPasswordService $svc)
{ {
$v = Validator::make($request->all(), [ $rules = [
'email' => ['required', 'email', 'max:120'], 'email' => ['required', 'email', 'max:120'],
], [ 'name' => ['required', 'string', 'max:50'],
];
if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('find_pass')];
}
$v = Validator::make($request->all(), $rules, [
'email.required' => '이메일을 입력해 주세요.', 'email.required' => '이메일을 입력해 주세요.',
'email.email' => '이메일 형식이 올바르지 않습니다.', 'email.email' => '이메일 형식이 올바르지 않습니다.',
'name.required' => '성명을 입력해 주세요.',
'g-recaptcha-response.required' => '올바른 접근이 아닙니다.',
]); ]);
if ($v->fails()) { if ($v->fails()) {
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
}
$email = mb_strtolower(trim((string) $request->input('email')));
// 0) 가입된 이메일인지 확인 (MemInfo 기준)
$exists = MemInfo::query()
->whereNotNull('email')
->where('email', '<>', '')
->whereRaw('LOWER(email) = ?', [$email])
->exists();
if (!$exists) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'code' => 'EMAIL_NOT_FOUND', 'message' => $v->errors()->first(),
'message' => '해당 이메일로 가입된 계정을 찾을 수 없습니다. 이메일을 다시 확인해 주세요.', ], 422);
'step' => 1,
], 404);
} }
try { $email = (string) $request->input('email');
app(MailService::class)->sendTemplate( $name = (string) $request->input('name');
$email,
'[PIN FOR YOU] 비밀번호 재설정 인증번호', $res = $svc->sendResetMail($request, $email, $name, 30);
'mail.legacy.noti_email_auth_1', // CI 템플릿명에 맞춰 선택
[ if (!($res['ok'] ?? false)) {
'code' => $code, return response()->json([
'expires_min' => 3, 'ok' => false,
'email' => $email, 'code' => $res['code'] ?? null,
], 'message' => $res['message'] ?? '요청 처리 중 오류가 발생했습니다.',
queue: true 'step' => $res['step'] ?? 1,
); ], (int)($res['status'] ?? 400));
} catch (\Throwable $e) {
$request->session()->forget('find_pw');
Log::error('FindPassword sendCode failed', ['email' => $email, 'error' => $e->getMessage()]);
return response()->json(['ok'=>false,'message'=>'인증번호 발송 중 오류가 발생했습니다.'], 500);
} }
// 1) 레이트리밋(이메일 기준) if (!empty($res['session'])) {
$key = 'findpw:send:' . $email; $request->session()->put('find_pw', $res['session']);
if (RateLimiter::tooManyAttempts($key, 5)) { // 10분 5회 예시
$sec = RateLimiter::availableIn($key);
return response()->json(['ok' => false, 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요."], 429);
} }
RateLimiter::hit($key, 600);
// 2) OTP 생성 return response()->json([
$code = (string) random_int(100000, 999999); 'ok' => true,
'message' => $res['message'] ?? '인증메일을 발송했습니다.',
// 3) 세션 저장 (코드는 해시로) 'expires_in' => $res['expires_in'] ?? 1800,
$request->session()->put('find_pw', [ 'step' => $res['step'] ?? 2,
'sent' => true,
'email' => $email,
'code' => password_hash($code, PASSWORD_DEFAULT),
'code_expires_at' => now()->addMinutes(3)->timestamp,
'verified' => false,
'verified_at' => null,
]); ]);
}
// 4) 실제 발송 연결 (메일/SMS 등)
try { public function verifyLink(Request $request, FindPasswordService $svc)
// TODO: 프로젝트에 맞게 구현 {
// 예: Mail::to($email)->send(new PasswordOtpMail($code)); $memNo = (int) $request->query('mem_no', 0);
} catch (\Throwable $e) {
$request->session()->forget('find_pw'); $res = $svc->verifyResetLink($request, $memNo);
Log::error('FindPassword sendCode failed', [
'email' => $email, if (!($res['ok'] ?? false)) {
'error' => $e->getMessage(), return redirect()->route('web.auth.find_password.show')
->with('ui_dialog', [
'type' => 'alert',
'title' => '안내',
'message' => $res['message'] ?? '잘못된 접근입니다.',
]);
}
// ✅ 인증완료 세션 세팅
if (!empty($res['session'])) {
$request->session()->put('find_pw', $res['session']);
}
return redirect()->route('web.auth.find_password.show')
->with('ui_dialog', [
'type' => 'alert',
'title' => '안내',
'message' => $res['message'] ?? '인증이 완료되었습니다.',
]); ]);
return response()->json([
'ok' => false,
'message' => '인증번호 발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
], 500);
}
$isLocal = app()->environment(['local', 'development', 'testing']);
return response()->json([
'ok' => true,
'message' => '인증번호를 발송했습니다.',
'expires_in' => 180,
'step' => 2,
'dev_code' => $isLocal ? $code : null,
]);
} }
public function verify(Request $request) public function reset(Request $request, FindPasswordService $svc)
{ {
$v = Validator::make($request->all(), [ $v = Validator::make($request->all(), [
'code' => ['required', 'digits:6'], 'new_password' => [
], [ 'required', 'string', 'min:8', 'max:20',
'code.required' => '인증번호를 입력해 주세요.', 'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/'
'code.digits' => '인증번호 6자리를 입력해 주세요.', ],
]); 'new_password_confirmation' => ['required', 'same:new_password'],
if ($v->fails()) {
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
}
$sess = $request->session()->get('find_pw');
if (!$sess || empty($sess['sent']) || empty($sess['email'])) {
return response()->json(['ok' => false, 'message' => '먼저 인증번호를 요청해 주세요.'], 400);
}
if (empty($sess['code_expires_at']) || now()->timestamp > (int)$sess['code_expires_at']) {
return response()->json(['ok' => false, 'message' => '인증번호가 만료되었습니다. 다시 요청해 주세요.'], 400);
}
// 검증 시도 레이트리밋
$key = 'findpw:verify:' . $sess['email'];
if (RateLimiter::tooManyAttempts($key, 10)) { // 10분 10회 예시
$sec = RateLimiter::availableIn($key);
return response()->json(['ok' => false, 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요."], 429);
}
RateLimiter::hit($key, 600);
$code = (string) $request->input('code');
$ok = password_verify($code, $sess['code'] ?? '');
if (!$ok) {
return response()->json(['ok' => false, 'message' => '인증번호가 일치하지 않습니다.'], 422);
}
// 인증 성공 → step3 허용
$sess['verified'] = true;
$sess['verified_at'] = now()->timestamp;
$request->session()->put('find_pw', $sess);
return response()->json([
'ok' => true,
'message' => '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.',
'step' => 3,
]);
}
public function reset(Request $request)
{
$v = Validator::make($request->all(), [
'new_password' => ['required', 'string', 'min:8', 'max:72', 'confirmed'],
], [ ], [
'new_password.required' => '새 비밀번호를 입력해 주세요.', 'new_password.required' => '새 비밀번호를 입력해 주세요.',
'new_password.min' => '비밀번호는 8자 이상으로 입력해 주세요.', 'new_password.min' => '비밀번호는 8자리 이상이어야 합니다.',
'new_password.confirmed' => '비밀번호 확인이 일치하지 않습니다.', 'new_password.max' => '비밀번호는 20자리를 초과할 수 없습니다.',
'new_password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.',
'new_password_confirmation.same' => '비밀번호 확인이 일치하지 않습니다.',
]); ]);
if ($v->fails()) { if ($v->fails()) {
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
} }
$sess = $request->session()->get('find_pw'); $pw = (string) $request->input('new_password');
if (!$sess || empty($sess['email']) || empty($sess['verified'])) {
return response()->json(['ok' => false, 'message' => '인증이 필요합니다.'], 403);
}
$email = (string) $sess['email']; $res = $svc->resetPasswordFinal($request, $pw);
// (선택) 인증 후 너무 오래 지나면 재인증 요구
$verifiedAt = (int)($sess['verified_at'] ?? 0);
if ($verifiedAt > 0 && now()->timestamp - $verifiedAt > 10 * 60) { // 10분 예시
$request->session()->forget('find_pw');
return response()->json(['ok' => false, 'message' => '인증이 만료되었습니다. 다시 진행해 주세요.'], 403);
}
$newPassword = (string) $request->input('new_password');
// 실제 비밀번호 저장 컬럼은 프로젝트마다 다를 수 있어 안전하게 처리
$member = MemInfo::query()
->whereNotNull('email')
->where('email', '<>', '')
->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])
->orderByDesc('mem_no')
->first();
if (!$member) {
$request->session()->forget('find_pw');
return response()->json(['ok' => false, 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.'], 404);
}
// ✅ 여기서부터가 “진짜 저장 로직”
// MemInfo의 실제 컬럼명에 맞게 1개만 쓰면 됩니다.
// - password 컬럼을 쓰면 아래처럼
// - 레거시 passwd 컬럼이면 passwd로 교체
try {
if (isset($member->password)) {
$member->password = Hash::make($newPassword);
} elseif (isset($member->passwd)) {
$member->passwd = Hash::make($newPassword); // 레거시 규격이면 여기를 교체
} else {
// 컬럼을 모르면 여기서 명시적으로 막는게 안전
return response()->json([
'ok' => false,
'message' => '비밀번호 저장 컬럼 설정이 필요합니다. (MemInfo password/passwd 확인)',
], 500);
}
$member->save();
} catch (\Throwable $e) {
Log::error('FindPassword reset failed', [
'email' => $email,
'error' => $e->getMessage(),
]);
return response()->json(['ok' => false, 'message' => '비밀번호 변경 중 오류가 발생했습니다.'], 500);
}
$request->session()->forget('find_pw');
$request->session()->save();
return response()->json([ return response()->json([
'ok' => true, 'ok' => (bool)($res['ok'] ?? false),
'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.', 'message' => $res['message'] ?? '',
'redirect_url' => route('web.auth.login'), 'redirect_url' => $res['redirect_url'] ?? null,
]); ], (int)($res['status'] ?? 200));
} }
public function resetSession(Request $request)
{
$request->session()->forget('find_pw');
return response()->json(['ok' => true]);
}
} }

View File

@ -27,6 +27,8 @@ final class LoginController extends Controller
if (app()->environment('production')) { if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')]; $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')];
var_dump('google');
exit;
} }
$v = Validator::make($request->all(), $rules, [ $v = Validator::make($request->all(), $rules, [
@ -60,11 +62,25 @@ final class LoginController extends Controller
'return_url' => $returnUrl, 'return_url' => $returnUrl,
]); ]);
if (!$res['ok']) { if (!($res['ok'] ?? false)) {
// UI 처리 방식은 프로젝트 스타일에 맞춰
// (일단 errors로 처리) // 이메일 미인증이면 confirm 페이지로 이동
if (($res['reason'] ?? null) === 'email_unverified') {
// 세션 고정 공격 방지 (중요)
$request->session()->regenerate();
$request->session()->put('auth_user', [
'mem_no' => (int)($res['mem_no'] ?? 0),
'email' => (string)($res['email'] ?? $email),
'issued_at' => now()->timestamp,
'expires_at' => now()->addMinutes(30)->timestamp, // auth_user 세션 유효기간
]);
return redirect()->route('web.auth.email.required');
}
// 그 외 실패는 기존 방식 유지
return back() return back()
->withErrors(['login' => $res['message']]) ->withErrors(['login' => $res['message'] ?? '로그인에 실패했습니다.'])
->withInput(['mem_email' => $email]); ->withInput(['mem_email' => $email]);
} }
@ -89,18 +105,18 @@ final class LoginController extends Controller
public function logout(Request $request) public function logout(Request $request)
{ {
$request->session()->forget('_sess'); // $request->session()->forget('_sess');
//
// (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가 // // (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가
$request->session()->forget('signup'); // $request->session()->forget('signup');
$request->session()->forget('register'); // $request->session()->forget('register');
//
// (선택) 디버그 세션 정리 // // (선택) 디버그 세션 정리
$request->session()->forget('debug'); // $request->session()->forget('debug');
//
// ✅ 세션 저장 // $request->session()->save();
$request->session()->save(); $request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('web.home') return redirect()->route('web.home')
->with('ui_dialog', [ ->with('ui_dialog', [
'type' => 'alert', 'type' => 'alert',

View File

@ -0,0 +1,335 @@
<?php
namespace App\Http\Controllers\Web\Mypage;
use App\Http\Controllers\Controller;
use App\Services\MemInfoService;
use App\Services\Danal\DanalAuthtelService;
use App\Repositories\Member\MemberAuthRepository;
use App\Support\LegacyCrypto\CiSeedCrypto;
use App\Support\LegacyCrypto\CiPassword;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Arr;
final class InfoGateController extends Controller
{
public function __construct(
private readonly CiSeedCrypto $seed,
) {}
/**
* 비밀번호 재인증 화면
*/
public function show(Request $request)
{
$gate = (array) $request->session()->get('mypage_gate', []);
$ok = (bool) ($gate['ok'] ?? false);
$at = (int) ($gate['at'] ?? 0);
$ttlSeconds = 5 * 60;
$isValid = $ok && $at > 0 && (time() - $at) <= $ttlSeconds;
if ($isValid) {
return redirect()->to('/mypage/info_renew');
}
return view('web.mypage.info.gate');
}
public function info_renew(Request $request)
{
// ✅ gate (기존 그대로)
$gate = (array) $request->session()->get('mypage_gate', []);
$gateOk = (bool) Arr::get($gate, 'ok', false);
$gateAt = (int) Arr::get($gate, 'at', 0);
$ttlSec = 5 * 60;
$nowTs = now()->timestamp;
$expireTs = ($gateOk && $gateAt > 0) ? ($gateAt + $ttlSec) : 0;
$remainSec = ($expireTs > 0) ? max(0, $expireTs - $nowTs) : 0;
$isGateValid = ($remainSec > 0);
// ✅ 회원정보: _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();
$withdrawBankName = (string)($user->withdraw_bank_name ?? $user->bank_name ?? '');
$withdrawAccount = (string)($user->withdraw_account ?? $user->bank_account ?? '');
$hasWithdrawAccount = ($withdrawBankName !== '' && $withdrawAccount !== '');
$agreeEmail = (string)($user->agree_marketing_email ?? $user->agree_email ?? 'n');
$agreeSms = (string)($user->agree_marketing_sms ?? $user->agree_sms ?? 'n');
return view('web.mypage.info.renew', [
// gate
'ttlSec' => $ttlSec,
'expireTs' => (int) $expireTs,
'remainSec' => (int) $remainSec,
'isGateValid' => (bool) $isGateValid,
// member (sess)
'memberName' => $memberName,
'memberEmail' => $memberEmail,
'memberPhone' => $memberPhone,
'memberDtReg' => $dtReg,
// etc
'hasWithdrawAccount' => (bool) $hasWithdrawAccount,
'agreeEmail' => $agreeEmail,
'agreeSms' => $agreeSms,
]);
}
/**
* 재인증을 위한 세셔초기화
*/
public function gateReset(Request $request)
{
// 게이트 인증 세션만 초기화
$request->session()->forget('mypage_gate');
// (선택) reauth도 같이 초기화하고 싶으면
$request->session()->forget('mypage.reauth.at');
$request->session()->forget('mypage.reauth.until');
$request->session()->save();
// 게이트 페이지로 이동
return redirect()->route('web.mypage.info.index');
}
/**
* 비밀번호 재인증 처리
*/
public function verify(Request $request, MemInfoService $memInfoService)
{
$request->validate([
'password' => ['required', 'string'],
], [
'password.required' => '비밀번호를 입력해 주세요.',
]);
$sess = (array) $request->session()->get('_sess', []);
$isLogin = (bool) ($sess['_login_'] ?? false);
$email = (string) ($sess['_mid'] ?? '');
if (!$isLogin || $email === '') {
return redirect()->route('web.auth.login', ['return_url' => url('/mypage/info')]);
}
$pw = (string) $request->input('password');
$res = $memInfoService->attemptLegacyLogin([
'email' => $email,
'password' => $pw,
'ip' => $request->ip(),
'ua' => substr((string) $request->userAgent(), 0, 500),
'return_url' => url('/mypage/info'),
]);
$ok = (bool)($res['ok'] ?? $res['success'] ?? false);
if (!$ok) {
$msg = (string)($res['message'] ?? '비밀번호가 일치하지 않습니다.');
return back()
->withErrors(['password' => $msg]) // 레이어 알림 스크립트가 이걸 잡음
->withInput($request->except('password'));
}
// 게이트 통과 세션 (예: 30분)
$request->session()->put('mypage_gate', [
'ok' => true,
'email' => $email,
'at' => time(),
]);
return redirect()->route('web.mypage.info.renew');
}
public function passReady(Request $request)
{
// 목적 저장 (result에서 분기용)
$purpose = (string) $request->input('purpose', 'mypage_phone_change');
$request->session()->put('mypage.pass_purpose', $purpose);
$request->session()->save();
$danal = app(\App\Services\Danal\DanalAuthtelService::class)->prepare([
'targetUrl' => route('web.mypage.info.danal.result'),
'backUrl' => route('web.mypage.info.renew'), // 취소/뒤로가기
'cpTitle' => request()->getHost(),
]);
if (!($danal['ok'] ?? false)) {
return response()->json([
'ok' => false,
'message' => $danal['message'] ?? '본인인증 준비에 실패했습니다. 잠시 후 다시 시도해 주세요.',
], 500);
}
// 필요하면 txid 저장 (회원가입과 동일)
$request->session()->put('mypage.danal', [
'txid' => $danal['txid'] ?? null,
'created_at' => now()->toDateTimeString(),
'purpose' => $purpose,
]);
$request->session()->save();
return response()->json([
'ok' => true,
'reason' => 'danal_ready',
'popup' => [
'url' => route('web.mypage.info.danal.start'),
'fields' => $danal['fields'],
],
]);
}
public function danalStart(Request $request)
{
$fieldsJson = (string) $request->input('fields', '');
$fields = json_decode($fieldsJson, true);
if (!is_array($fields) || empty($fields)) {
abort(400, 'Invalid Danal fields');
}
$platform = strtolower((string) $request->input('platform', ($fields['platform'] ?? '')));
$isMobile = false;
if ($platform === 'mobile') {
$isMobile = true;
} elseif ($platform === 'web') {
$isMobile = false;
} else {
$ua = (string) $request->header('User-Agent', '');
$isMobile = (bool) preg_match('/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i', $ua);
}
$action = $isMobile
? 'https://wauth.teledit.com/Danal/WebAuth/Mobile/Start.php'
: 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php';
unset($fields['platform']);
// 기존 autosubmit 뷰 재사용 OK
return view('web.auth.danal_autosubmit', [
'action' => $action,
'fields' => $fields,
'isMobile' => $isMobile,
]);
}
public function danalResult(
Request $request,
DanalAuthtelService $danal,
MemberAuthRepository $repo
) {
$payload = $request->all();
if (config('app.debug')) {
Log::info('[MYPAGE][DANAL][RESULT] keys', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'keys' => array_keys($payload),
]);
}
$tid = (string)($payload['TID'] ?? '');
if ($tid === '') {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => 'TID가 없습니다.',
'redirect' => route('web.mypage.info.index'),
]);
}
// CI와 동일: TID로 CONFIRM
$res = $danal->confirm($tid, 0, 1);
// 로그 저장 (성공/실패 무조건)
$logSeq = $repo->insertDanalAuthLog('M', (array) $res);
if ($logSeq > 0) {
$request->session()->put('mypage.pass.danal_log_seq', $logSeq);
}
$ok = (($res['RETURNCODE'] ?? '') === '0000');
if (!$ok) {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => ($res['RETURNMSG'] ?? '본인인증에 실패했습니다.') . ' (' . ($res['RETURNCODE'] ?? 'NO_CODE') . ')',
'redirect' => route('web.mypage.info.index'),
]);
}
// ✅ 목적이 마이페이지 연락처 변경일 때만 추가 검증
$purpose = (string) $request->session()->get('mypage.pass_purpose', '');
if ($purpose === 'mypage_phone_change') {
$sess = (array) $request->session()->get('_sess', []);
$memNo = (int) ($sess['_mno'] ?? 0);
if ($memNo <= 0) {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => '로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.',
'redirect' => route('web.auth.login'),
]);
}
$svc = app(\App\Services\MypageInfoService::class);
//연락처 검증
$check = $svc->validatePassPhoneChange($sess, (array) $res);
if (!($check['ok'] ?? false)) {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => $check['message'] ?? '연락처 변경 검증에 실패했습니다.',
'redirect' => route('web.mypage.info.index'),
]);
}
//전화번호 저장
$check = $svc->commitPhoneChange($memNo, (array) $res);
if (!($check['ok'] ?? false)) {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => $check['message'] ?? '연락처 저장에 실패했습니다.',
'redirect' => route('web.mypage.info.index'),
]);
}
$request->session()->put('_sess._mcell', $check['_cell'] ?? ''); //전화번호 변경
}else{
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => $check['message'] ?? '연락처 변경에 실패했습니다.\n\n관리자에게 문의하세요!',
'redirect' => route('web.mypage.info.index'),
]);
}
// 성공: 마이페이지 인증 플래그 세션 저장
$request->session()->forget('mypage.pass');
$request->session()->forget('mypage.danal ');
$request->session()->save();
return response()->view('web.auth.danal_finish_top', [
'ok' => true,
'message' => '본인인증이 완료되었습니다.',
'redirect' => url('/mypage/info_renew'),
]);
}
}

View File

@ -2,14 +2,12 @@
namespace App\Mail; namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
class TemplateMail extends Mailable implements ShouldQueue class TemplateMail extends Mailable
{ {
use Queueable, SerializesModels; use SerializesModels;
public function __construct( public function __construct(
public string $subjectText, public string $subjectText,

View File

@ -1,34 +0,0 @@
<?php
namespace App\Repositories\Member;
use Illuminate\Support\Facades\DB;
final class MemStRingRepository
{
public function getLegacyPass1(int $memNo, int $set = 0): ?string
{
$col = 'str_' . (int)$set; // str_0 / str_1 / str_2
$row = DB::table('mem_st_ring')
->select($col)
->where('mem_no', $memNo)
->first();
if (!$row) return null;
$val = $row->{$col} ?? null;
$val = is_string($val) ? trim($val) : null;
return $val !== '' ? $val : null;
}
public function getLegacyPass2(int $memNo): ?string
{
$val = DB::table('mem_st_ring')
->where('mem_no', $memNo)
->value('passwd2');
$val = is_string($val) ? trim($val) : null;
return $val !== '' ? $val : null;
}
}

View File

@ -0,0 +1,208 @@
<?php
namespace App\Repositories\Member;
use Illuminate\Support\Facades\DB;
class EmailAuthRepository
{
/**
* 메일 발송 처리:
* - mem_auth upsert (email, N)
* - mem_auth_log insert (P)
* - mem_auth_info upsert (auth_info.email 세팅)
* return: ['email' => ..., 'auth_key' => ..., 'expires_at' => 'Y-m-d H:i:s']
*/
public function prepareEmailVerify(int $memNo, string $email, string $ip, int $expiresMinutes = 30): array
{
return DB::transaction(function () use ($memNo, $email, $ip, $expiresMinutes) {
// 1) mem_auth: row lock (없으면 생성, 있으면 N으로 초기화)
$row = DB::table('mem_auth')
->where('mem_no', $memNo)
->where('auth_type', 'email')
->lockForUpdate()
->first();
$today = now()->toDateString();
if (!$row) {
DB::table('mem_auth')->insert([
'mem_no' => $memNo,
'auth_type' => 'email',
'auth_state' => 'N',
'auth_date' => $today,
]);
} else {
DB::table('mem_auth')
->where('mem_no', $memNo)
->where('auth_type', 'email')
->update([
'auth_state' => 'N',
'auth_date' => $today,
]);
}
// 2) auth_key 생성
$authKey = now()->format('HisYmd') . $ip . '-' . bin2hex(random_bytes(8));
$expiresAt = now()->addMinutes($expiresMinutes)->format('Y-m-d H:i:s');
$emailInfo = [
'type' => 'mem_level',
'auth_hit' => 'n',
'user_email' => $email,
'auth_key' => $authKey,
'auth_effective_time' => $expiresAt,
'redate' => now()->format('Y-m-d H:i:s'),
];
// 3) mem_auth_log: P (Pending)
DB::table('mem_auth_log')->insert([
'mem_no' => $memNo,
'type' => 'email',
'state' => 'P',
'info' => json_encode($emailInfo, JSON_UNESCAPED_UNICODE),
'rgdate' => now()->format('Y-m-d H:i:s'),
]);
// 4) mem_auth_info upsert (auth_info JSON)
$authInfoRow = DB::table('mem_auth_info')
->where('mem_no', $memNo)
->lockForUpdate()
->first();
if (!$authInfoRow) {
DB::table('mem_auth_info')->insert([
'mem_no' => $memNo,
'auth_info' => json_encode(['email' => $emailInfo], JSON_UNESCAPED_UNICODE),
]);
} else {
$current = json_decode((string)$authInfoRow->auth_info, true) ?: [];
$current['email'] = $emailInfo;
DB::table('mem_auth_info')
->where('mem_no', $memNo)
->update([
'auth_info' => json_encode($current, JSON_UNESCAPED_UNICODE),
]);
}
return [
'email' => $email,
'auth_key' => $authKey,
'expires_at' => $expiresAt,
];
});
}
/**
* 인증 완료 처리 (CI3 로직 동일):
* - 이미 Y면 예외
* - auth_info.email 존재//시간 체크
* - mem_auth: Y 업데이트
* - mem_auth_info: auth_hit=y + remote/agent/시간 merge
* - mem_auth_log: S insert
*/
public function confirmEmailVerify(int $memNo, string $encKey, string $ip, string $agent): array
{
return DB::transaction(function () use ($memNo, $encKey, $ip, $agent) {
// 1) mem_auth 상태 확인 (lock)
$auth = DB::table('mem_auth')
->where('mem_no', $memNo)
->where('auth_type', 'email')
->lockForUpdate()
->first();
if ($auth && $auth->auth_state === 'Y') {
return ['ok' => false, 'message' => '이미 인증이 완료되었습니다.'];
}
// 2) mem_auth_info 가져오기 (lock)
$infoRow = DB::table('mem_auth_info')
->where('mem_no', $memNo)
->lockForUpdate()
->first();
if (!$infoRow || empty($infoRow->auth_info)) {
return ['ok' => false, 'message' => '잘못된 접근입니다.'];
}
$authJson = json_decode((string)$infoRow->auth_info, true);
$emailAuth = $authJson['email'] ?? null;
if (!$emailAuth || empty($emailAuth['auth_hit'])) {
return ['ok' => false, 'message' => '정상적인 경로로 이용하세요.'];
}
if (($emailAuth['auth_hit'] ?? '') === 'y') {
return ['ok' => false, 'message' => '이미 인증되었습니다.'];
}
if (($emailAuth['auth_key'] ?? '') !== $encKey) {
return ['ok' => false, 'message' => '잘못된 접근입니다.'];
}
$effective = (string)($emailAuth['auth_effective_time'] ?? '');
if ($effective === '' || $effective < now()->format('Y-m-d H:i:s')) {
return ['ok' => false, 'message' => '인증시간이 초과되었습니다.'];
}
// 3) auth_hit = y + merge info
$emailAuth['auth_hit'] = 'y';
$emailAuth = array_merge($emailAuth, [
'remote_addr' => $ip,
'agent' => $agent,
'auth_redate' => now()->format('Y-m-d H:i:s'),
]);
$authJson['email'] = $emailAuth;
// 4) mem_auth: Y 업데이트 (없으면 insert)
$today = now()->toDateString();
if (!$auth) {
DB::table('mem_auth')->insert([
'mem_no' => $memNo,
'auth_type' => 'email',
'auth_state' => 'Y',
'auth_date' => $today,
]);
} else {
DB::table('mem_auth')
->where('mem_no', $memNo)
->where('auth_type', 'email')
->update([
'auth_state' => 'Y',
'auth_date' => $today,
]);
}
// 5) mem_auth_info 업데이트
DB::table('mem_auth_info')
->where('mem_no', $memNo)
->update([
'auth_info' => json_encode($authJson, JSON_UNESCAPED_UNICODE),
]);
// 6) mem_auth_log: S (Success)
$logInfo = [
'remote_addr' => $ip,
'agent' => $agent,
'auth_redate' => now()->format('Y-m-d H:i:s'),
];
DB::table('mem_auth_log')->insert([
'mem_no' => $memNo,
'type' => 'email',
'state' => 'S',
'info' => json_encode($logInfo, JSON_UNESCAPED_UNICODE),
'rgdate' => now()->format('Y-m-d H:i:s'),
]);
return [
'ok' => true,
'message' => '이메일 인증이 확인되었습니다.',
'email' => (string)($emailAuth['user_email'] ?? ''),
];
});
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Repositories\Member;
use App\Models\Member\MemInfo;
class MemInfoRepository
{
public function findByMemNo(int $memNo): ?MemInfo
{
return MemInfo::query()->where('mem_no', $memNo)->first();
}
public function emailsMatch(int $memNo, string $email): bool
{
$mem = $this->findByMemNo($memNo);
if (!$mem || empty($mem->email)) return false;
return strcasecmp((string)$mem->email, $email) === 0;
}
}

View File

@ -1,64 +1,34 @@
<?php <?php
namespace App\Repositories\Member; namespace App\Repositories\Member;
use App\Support\LegacyCrypto\CiPassword;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class MemStRingRepository final class MemStRingRepository
{ {
/** public function getLegacyPass1(int $memNo, int $set = 0): ?string
* CI3 기본(1+2) 저장과 동일한 upsert
*/
public function upsertBoth(int $memNo, string $plainPassword, string $pin2, ?string $dtNow = null): void
{ {
$dt = $dtNow ?: Carbon::now()->toDateTimeString(); $col = 'str_' . (int)$set; // str_0 / str_1 / str_2
[$str0, $str1, $str2] = CiPassword::makeAll($plainPassword); $row = DB::table('mem_st_ring')
$pass2 = CiPassword::makePass2($pin2); ->select($col)
->where('mem_no', $memNo)
->first();
DB::statement( if (!$row) return null;
"INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg, passwd2, passwd2_reg)
VALUES (?, ?, ?, ?, ?, ?, ?) $val = $row->{$col} ?? null;
ON DUPLICATE KEY UPDATE $val = is_string($val) ? trim($val) : null;
str_0 = ?, str_1 = ?, str_2 = ?, dt_reg = ?, passwd2 = ?, passwd2_reg = ?",
[ return $val !== '' ? $val : null;
$memNo, $str0, $str1, $str2, $dt, $pass2, $dt,
$str0, $str1, $str2, $dt, $pass2, $dt,
]
);
} }
/** public function getLegacyPass2(int $memNo): ?string
* CI3 modify_type == "1_passwd" 대응 (1차만)
*/
public function upsertPassword1(int $memNo, string $plainPassword, ?string $dtNow = null): void
{ {
$dt = $dtNow ?: Carbon::now()->toDateTimeString(); $val = DB::table('mem_st_ring')
[$str0, $str1, $str2] = CiPassword::makeAll($plainPassword); ->where('mem_no', $memNo)
->value('passwd2');
DB::statement( $val = is_string($val) ? trim($val) : null;
"INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg) return $val !== '' ? $val : null;
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE str_0=?, str_1=?, str_2=?, dt_reg=?",
[$memNo, $str0, $str1, $str2, $dt, $str0, $str1, $str2, $dt]
);
}
/**
* CI3 modify_type == "2_passwd" 대응 (2차만)
*/
public function upsertPassword2(int $memNo, string $pin2, ?string $dtNow = null): void
{
$dt = $dtNow ?: Carbon::now()->toDateTimeString();
$pass2 = CiPassword::makePass2($pin2);
DB::statement(
"INSERT INTO mem_st_ring (mem_no, passwd2, passwd2_reg)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE passwd2=?, passwd2_reg=?",
[$memNo, $pass2, $dt, $pass2, $dt]
);
} }
} }

View File

@ -206,16 +206,16 @@ class MemberAuthRepository
// 2) 이미 회원인지 체크 // 2) 이미 회원인지 체크
$member = $this->findMemberByPhone($phone, $carrier); $member = $this->findMemberByPhone($phone, $carrier);
// if ($member && !empty($member->mem_no)) { if ($member && !empty($member->mem_no)) {
// return array_merge($base, [ return array_merge($base, [
// 'ok' => false, 'ok' => false,
// 'reason' => 'already_member', 'reason' => 'already_member',
// 'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?", 'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?",
// 'redirect' => route('web.auth.find_id'), 'redirect' => route('web.auth.find_id'),
// 'matched_mem_no' => (int) $member->mem_no, 'matched_mem_no' => (int) $member->mem_no,
// 'matched_cell_corp' => $member->cell_corp ?? null, // ✅ 필요시 'matched_cell_corp' => $member->cell_corp ?? null, // ✅ 필요시
// ]); ]);
// } }
// 3) 기존 phone+ip 필터 // 3) 기존 phone+ip 필터
$filter = $this->checkJoinFilter($phone, $ip4, $ip4c); $filter = $this->checkJoinFilter($phone, $ip4, $ip4c);
@ -601,6 +601,92 @@ class MemberAuthRepository
]); ]);
} }
public function findByEmailAndName(string $emailLower, string $name): ?MemInfo
{
// ⚠️ 성명 컬럼이 name이 아니면 여기만 바꾸면 됨
return MemInfo::query()
->whereNotNull('email')->where('email', '<>', '')
->whereRaw('LOWER(email) = ?', [$emailLower])
->where('name', $name)
->orderByDesc('mem_no')
->first();
}
public function findByMemNo(int $memNo): ?MemInfo
{
return MemInfo::query()
->where('mem_no', $memNo)
->first();
}
public function existsByEncryptedPhone(string $phoneEnc): bool
{
return DB::table('mem_info')
->whereNotNull('email')
->where('email', '<>', '')
->where('cell_phone', $phoneEnc)
->exists();
}
/**
* 해당 암호화 휴대폰으로 가입된 이메일 목록(중복 제거)
*/
public function getEmailsByEncryptedPhone(string $phoneEnc): array
{
// 여러 mem_no에서 같은 email이 나올 수 있으니 distinct 처리
return DB::table('mem_info')
->select('email')
->whereNotNull('email')
->where('email', '<>', '')
->where('cell_phone', $phoneEnc)
->orderByDesc('mem_no')
->distinct()
->pluck('email')
->values()
->all();
}
//비밀번호 수정
public function updatePasswordOnly(int $memNo, string $pwPlain): void
{
if ($memNo <= 0 || $pwPlain === '') {
throw new \InvalidArgumentException('invalid_payload');
}
[$str0, $str1, $str2] = CiPassword::makeAll($pwPlain);
$now = now()->format('Y-m-d H:i:s');
$affected = DB::table('mem_st_ring')
->where('mem_no', $memNo)
->update([
'str_0' => $str0,
'str_1' => $str1,
'str_2' => $str2,
'dt_reg' => $now,
]);
// ✅ 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리
if ($affected <= 0) {
throw new \RuntimeException('mem_st_ring_not_found');
}
}
public function logPasswordResetSuccess(int $memNo, string $ip, string $agent): void
{
$now = now()->format('Y-m-d H:i:s');
DB::table('mem_passwd_modify')->insert([
'state' => 'E',
'info' => json_encode([
'mem_no' => (string)$memNo,
'redate' => $now,
'remote_addr' => $ip,
'agent' => $agent,
], JSON_UNESCAPED_UNICODE),
'rgdate' => $now,
]);
}
} }

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Repositories\Mypage;
use Illuminate\Support\Facades\DB;
final class MypageInfoRepository
{
private const TABLE_MEM_INFO = 'mem_info';
private const COL_MEM_NO = 'mem_no';
private const COL_CI = 'ci';
private const COL_CELL = 'cell_phone';
/**
* mem_info 기본 정보 조회 (필요한 컬럼만)
*/
public function findMemberCore(int $memNo): ?object
{
return DB::table(self::TABLE_MEM_INFO)
->select([
self::COL_MEM_NO,
self::COL_CI,
self::COL_CELL,
])
->where(self::COL_MEM_NO, $memNo)
->first();
}
/**
* mem_info.ci 조회
*/
public function getMemberCi(int $memNo): string
{
$row = DB::table(self::TABLE_MEM_INFO)
->select([self::COL_CI])
->where(self::COL_MEM_NO, $memNo)
->first();
return (string) ($row->{self::COL_CI} ?? '');
}
/**
* 암호화된 휴대폰(cell) 값이 이미 존재하는지 체크
* - excludeMemNo가 있으면 해당 회원은 제외하고 검색
*/
public function existsEncryptedCell(string $encCell, ?int $excludeMemNo = null): bool
{
if ($encCell === '') return false;
$q = DB::table(self::TABLE_MEM_INFO)
->where(self::COL_CELL, $encCell);
if ($excludeMemNo !== null && $excludeMemNo > 0) {
$q->where(self::COL_MEM_NO, '!=', $excludeMemNo);
}
return $q->exists();
}
/**
* mem_info.cell 업데이트
* @return int 영향을 받은 row
*/
public function updateEncryptedCell(int $memNo, string $encCell): int
{
return DB::table(self::TABLE_MEM_INFO)
->where(self::COL_MEM_NO, $memNo)
->update([
self::COL_CELL => $encCell,
]);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Services;
use App\Repositories\Member\EmailAuthRepository;
use App\Services\MailService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
class EmailVerificationService
{
public function __construct(
private readonly EmailAuthRepository $emailAuthRepo,
private readonly MailService $mail,
) {}
public function getAuthUserFromSession(Request $request): array
{
$au = (array) $request->session()->get('auth_user', []);
$memNo = (int)($au['mem_no'] ?? 0);
$email = (string)($au['email'] ?? '');
$expiresAt = (int)($au['expires_at'] ?? 0);
if ($memNo <= 0 || $email === '' || $expiresAt <= 0 || now()->timestamp > $expiresAt) {
$request->session()->forget('auth_user');
return [];
}
return ['mem_no' => $memNo, 'email' => $email, 'expires_at' => $expiresAt];
}
public function sendVerifyMail(Request $request, int $memNo, string $email): array
{
$prep = $this->emailAuthRepo->prepareEmailVerify(
$memNo,
$email,
(string)$request->ip(),
30
);
$link = URL::temporarySignedRoute(
'web.auth.email.verify',
now()->addMinutes(30),
[
'mem_no' => $memNo,
'k' => $prep['auth_key'],
]
);
$this->mail->sendTemplate(
$email,
'[PIN FOR YOU] 이메일 인증을 완료해 주세요',
'mail.auth.verify_email',
[
'email' => $email,
'link' => $link,
'expires_min' => 30,
'accent' => '#E4574B',
'brand' => 'PIN FOR YOU',
'siteUrl' => config('app.url'),
],
queue: false
);
return ['ok' => true, 'status' => 200, 'message' => '인증메일을 발송했습니다. 메일함을 확인해 주세요.'];
}
public function verifySignedLink(Request $request, int $memNo, string $k): array
{
return $this->emailAuthRepo->confirmEmailVerify(
$memNo,
$k,
(string)$request->ip(),
(string)$request->userAgent()
);
}
}

View File

@ -0,0 +1,246 @@
<?php
namespace App\Services;
use App\Repositories\Member\MemberAuthRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Log;
class FindPasswordService
{
public function __construct(
private readonly MemberAuthRepository $members,
private readonly MailService $mail,
) {}
/**
* 이메일+성명 검증 재설정 링크 메일 발송
* - DB 조회는 여기서 처리
* - 컨트롤러는 결과만 받아서 JSON 응답
*/
public function sendResetMail(Request $request, string $email, string $name, int $expiresMinutes = 30): array
{
$emailLower = mb_strtolower(trim($email));
$name = trim($name);
// 레이트리밋 (email+ip) - DB 아니지만 정책/로직이므로 서비스에서 처리
$key = 'findpw:mail:' . $emailLower . ':' . (string)$request->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$sec = RateLimiter::availableIn($key);
return [
'ok' => false,
'status' => 429,
'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요.",
];
}
RateLimiter::hit($key, 600);
// ✅ 가입자 확인(DB)
$member = $this->members->findByEmailAndName($emailLower, $name);
if (!$member) {
return [
'ok' => false,
'status' => 404,
'code' => 'NOT_MATCHED',
'message' => '입력하신 이메일/성명 정보가 일치하지 않습니다.',
'step' => 1,
];
}
$memNo = (int)$member->mem_no;
// ✅ DB 저장 없이 signed URL만 생성
$nonce = bin2hex(random_bytes(10));
$link = URL::temporarySignedRoute(
'web.auth.find_password.verify',
now()->addMinutes($expiresMinutes),
[
'mem_no' => $memNo,
'n' => $nonce,
]
);
// ✅ 메일 발송
try {
$this->mail->sendTemplate(
$emailLower,
'[PIN FOR YOU] 비밀번호 재설정 링크 안내',
'mail.auth.reset_password',
[
'email' => $emailLower,
'name' => $name,
'link' => $link,
'expires_min' => $expiresMinutes,
'accent' => '#E4574B',
'brand' => 'PIN FOR YOU',
'siteUrl' => config('app.url'),
],
queue: false
);
} catch (\Throwable $e) {
Log::error('FindPassword sendResetMail failed', [
'mem_no' => $memNo,
'email' => $emailLower,
'error' => $e->getMessage(),
]);
return [
'ok' => false,
'status' => 500,
'message' => '메일 발송 중 오류가 발생했습니다.',
];
}
// 컨트롤러가 세션에 저장할 payload를 서비스에서 만들어줌
return [
'ok' => true,
'status' => 200,
'message' => '재설정 메일을 발송했습니다. 메일함을 확인해 주세요.',
'expires_in' => $expiresMinutes * 60,
'step' => 2,
'session' => [
'sent' => true,
'verified' => false,
'mem_no' => $memNo,
'email' => $emailLower,
'name' => $name,
'sent_at' => now()->timestamp,
],
];
}
/**
* 링크 클릭 검증(서명/만료 + 회원존재)
* - DB 조회는 여기서 처리
* - 컨트롤러는 redirect만 담당
*/
public function verifyResetLink(Request $request, int $memNo): array
{
// signed 미들웨어를 라우트에 붙이면 여기 체크는 사실상 안전장치
if (!$request->hasValidSignature()) {
return [
'ok' => false,
'message' => '링크가 유효하지 않거나 만료되었습니다. 다시 진행해 주세요.',
];
}
if ($memNo <= 0) {
return [
'ok' => false,
'message' => '잘못된 접근입니다.',
];
}
$member = $this->members->findByMemNo($memNo);
if (!$member) {
return [
'ok' => false,
'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.',
];
}
return [
'ok' => true,
'message' => '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.',
'session' => [
'sent' => true,
'verified' => true,
'verified_at' => now()->timestamp,
'mem_no' => $memNo,
'email' => (string)($member->email ?? ''),
'name' => (string)($member->name ?? ''),
],
];
}
public function resetPasswordFinal(Request $request, string $newPassword): array
{
$sess = (array) $request->session()->get('find_pw', []);
// 0) 세션 체크: 이메일 링크 인증 완료 상태인지
if (empty($sess['verified']) || empty($sess['mem_no'])) {
return [
'ok' => false,
'status' => 403,
'message' => '이메일 인증이 필요합니다. 메일 링크를 통해 다시 진행해 주세요.',
];
}
$memNo = (int) ($sess['mem_no'] ?? 0);
if ($memNo <= 0) {
$request->session()->forget('find_pw');
return [
'ok' => false,
'status' => 403,
'message' => '세션 정보가 올바르지 않습니다. 다시 진행해 주세요.',
];
}
// 1) 인증 후 유효시간 정책 (예: 인증 후 10분 내 변경)
$verifiedAt = (int) ($sess['verified_at'] ?? 0);
if ($verifiedAt <= 0 || (now()->timestamp - $verifiedAt) > (10 * 60)) {
$request->session()->forget('find_pw');
return [
'ok' => false,
'status' => 403,
'message' => '인증이 만료되었습니다. 비밀번호 찾기를 다시 진행해 주세요.',
];
}
// 2) 레이트리밋 (비번 변경 시도)
$key = 'findpw:reset:' . $memNo . ':' . (string)$request->ip();
if (RateLimiter::tooManyAttempts($key, 10)) {
$sec = RateLimiter::availableIn($key);
return [
'ok' => false,
'status' => 429,
'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요.",
];
}
RateLimiter::hit($key, 600);
// 3) 방어적 비번 정책 체크(컨트롤러에서 검증하지만 한번 더)
if (!is_string($newPassword) || $newPassword === '') {
return ['ok'=>false,'status'=>422,'message'=>'새 비밀번호를 입력해 주세요.'];
}
if (strlen($newPassword) < 8 || strlen($newPassword) > 20) {
return ['ok'=>false,'status'=>422,'message'=>'비밀번호는 8~20자리로 입력해 주세요.'];
}
if (!preg_match('/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/', $newPassword)) {
return ['ok'=>false,'status'=>422,'message'=>'비밀번호는 영문+숫자+특수문자를 포함해야 합니다.'];
}
// 4) 회원 존재 확인 (DB는 Repo 통해서)
$member = $this->members->findByMemNo($memNo);
if (!$member) {
$request->session()->forget('find_pw');
return [
'ok' => false,
'status' => 404,
'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.',
];
}
// 5) 비밀번호 저장 (mem_st_ring str_0~2) + 성공 로그
$this->members->updatePasswordOnly($memNo, $newPassword);
$this->members->logPasswordResetSuccess(
$memNo,
(string) $request->ip(),
(string) $request->userAgent()
);
// 6) 세션 정리
$request->session()->forget('find_pw');
$request->session()->save();
return [
'ok' => true,
'status' => 200,
'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.',
'redirect_url' => route('web.auth.login'),
];
}
}

View File

@ -2,23 +2,51 @@
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\Mail;
use App\Mail\TemplateMail; use App\Mail\TemplateMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class MailService class MailService
{ {
/** public function sendTemplate($to, string $subject, string $view, array $data = [], bool $queue = false): void
* CI macro.sendmail 같은 역할
* @param string|array $to
*/
public function sendTemplate($to, string $subject, string $view, array $data = []): void
{ {
$toList = is_array($to) ? $to : [$to]; $toList = is_array($to) ? $to : [$to];
// ✅ 웹 요청 기준으로 실제 mail 설정을 로그로 남김
Log::info('mail_send_debug', [
'queue' => $queue,
'queue_default' => config('queue.default'),
'mail_default' => config('mail.default'),
'smtp' => [
'host' => config('mail.mailers.smtp.host'),
'port' => config('mail.mailers.smtp.port'),
'encryption' => config('mail.mailers.smtp.encryption'),
'timeout' => config('mail.mailers.smtp.timeout'),
'local_domain' => config('mail.mailers.smtp.local_domain'),
],
'from' => config('mail.from'),
]);
foreach ($toList as $toEmail) { foreach ($toList as $toEmail) {
Mail::send($view, $data, function ($m) use ($toEmail, $subject) { $mailable = new TemplateMail(
$m->to($toEmail)->subject($subject); subjectText: $subject,
}); viewName: $view,
payload: $data
);
Log::info('mail_send_attempt', [
'to' => $toEmail,
'subject' => $subject,
'view' => $view,
]);
if ($queue) {
Mail::to($toEmail)->queue($mailable);
Log::info('mail_send_queued', ['to' => $toEmail]);
} else {
Mail::to($toEmail)->send($mailable);
Log::info('mail_send_done', ['to' => $toEmail]);
}
} }
} }
} }

View File

@ -408,7 +408,7 @@ class MemInfoService
$this->insertLoginLog($yearTable, $log); $this->insertLoginLog($yearTable, $log);
if ($failCnt >= 4) { if ($failCnt >= 4) {
return ['ok'=>false, 'message'=>"<a href='/member/find_pass?menu=pass' style='color: #fff;font-size:13px'>5회이상 실패시 비밀번호찾기 후 이용 바랍니다.(클릭)</a>"]; return ['ok'=>false, 'message'=>"비밀번호 입력 5회이상 실패 하셨습니다.\n 비밀번호찾기 후 이용 바랍니다."];
} }
return ['ok'=>false, 'message'=>"비밀번호가 일치하지 않습니다.\n비밀번호 실패횟수 : ".($failCnt+1)."\n5회 이상 실패시 인증을 다시받아야 합니다."]; return ['ok'=>false, 'message'=>"비밀번호가 일치하지 않습니다.\n비밀번호 실패횟수 : ".($failCnt+1)."\n5회 이상 실패시 인증을 다시받아야 합니다."];
@ -418,8 +418,11 @@ class MemInfoService
$levelInfo = $this->getMemLevel((int)$mem->mem_no); $levelInfo = $this->getMemLevel((int)$mem->mem_no);
if (($levelInfo['level'] ?? 0) < 1 || empty($levelInfo['auth_state']['email'])) { if (($levelInfo['level'] ?? 0) < 1 || empty($levelInfo['auth_state']['email'])) {
return [ return [
'ok'=>false, 'ok' => false,
'message'=>"<br>이메일 인증 완료후 이용가능합니다. \n이메일주소(".$mem->email.") 메일을 확인하세요\n", 'reason' => 'email_unverified',
'email' => (string)$mem->email,
'mem_no' => (int)$mem->mem_no,
'message' => '이메일 인증이 필요합니다.',
]; ];
} }

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\Mypage\MypageInfoRepository;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
final class MypageInfoService
{
public function __construct(
private readonly MypageInfoRepository $repo,
private readonly CiSeedCrypto $seed,
) {}
/**
* 마이페이지 연락처 변경(PASS) 검증
*
* 입력:
* - $sess: session('_sess') 배열
* - $passPayload: session('mypage.pass.payload') 또는 session('pass.payload') 배열
*
* 반환:
* - ['ok'=>bool, 'message'=>string, 'enc_phone'=>string, 'raw_phone'=>string, 'ci'=>string, 'carrier'=>string]
*/
public function validatePassPhoneChange(array $sess, array $passPayload): array
{
$memNo = (int) ($sess['_mno'] ?? 0);
if ($memNo <= 0) {
return $this->fail('로그인 정보가 확인되지 않습니다. 다시 로그인 후 시도해 주세요.');
}
$rawPhone = $this->normalizePhone((string) Arr::get($passPayload, 'PHONE', ''));
$ci = (string) Arr::get($passPayload, 'CI', '');
$carrier = strtoupper((string) Arr::get($passPayload, 'CARRIER', ''));
if ($rawPhone === '' || $ci === '') {
return $this->fail('인증 정보가 올바르지 않습니다. 다시 시도해 주세요.');
}
// 통신사 제한: SKT/KTF/LGT만 허용, 나머지 전부 차단 + MVNO 차단
$allowCarriers = ['SKT', 'KTF', 'LGT'];
if (!in_array($carrier, $allowCarriers, true) || $carrier === 'MVNO') {
return $this->fail('죄송합니다. SKT/KT/LG U+ 휴대전화만 인증할 수 있습니다. (알뜰폰/기타 통신사 불가)');
}
// 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에 존재하면 변경 불가
// 요구사항: "존재한다면 이전에 가입된 전화번호가 있습니다. 관리자 문의"
if ($this->repo->existsEncryptedCell($encPassPhone, $memNo)) {
return $this->fail('이미 가입된 휴대폰 번호가 있습니다. 관리자에게 문의해 주세요.');
}
// (3) CI가 회원정보 mem_info.ci와 일치해야 통과
$memberCi = $this->repo->getMemberCi($memNo);
if ($memberCi === '' || !hash_equals($memberCi, $ci)) {
return $this->fail('가입된 회원정보와 일치하지 않습니다. 관리자에게 문의해 주세요.');
}
return [
'ok' => true,
'message' => '검증이 완료되었습니다.',
'enc_phone' => $encPassPhone,
'raw_phone' => $rawPhone,
'ci' => $ci,
'carrier' => $carrier,
];
}
/**
* 최종 저장: mem_info.cell 업데이트
* - 컨트롤러는 DB 처리 한다고 했으니, /mypage/info 저장 버튼에서 메서드만 호출하면 .
*
* @return array ['ok'=>bool, 'message'=>string, '_cell'=>string]
*/
public function commitPhoneChange(int $memNo, array $passPayload): array
{
$Phone = $this->normalizePhone((string) Arr::get($passPayload, 'PHONE', ''));
$encPhone = (string) $this->seed->encrypt($Phone);
if ($memNo <= 0 || $encPhone === '') {
return $this->fail('저장할 정보가 올바르지 않습니다.');
}
return DB::transaction(function () use ($memNo, $encPhone) {
// 한번 더 중복 방어(레이스 컨디션)
if ($this->repo->existsEncryptedCell($encPhone, $memNo)) {
return $this->fail('이미 가입된 휴대폰 번호가 있습니다. 관리자에게 문의해 주세요.');
}
$affected = $this->repo->updateEncryptedCell($memNo, $encPhone);
if ($affected < 1) {
return $this->fail('연락처 저장에 실패했습니다. 잠시 후 다시 시도해 주세요.');
}
return [
'ok'=>true,
'message'=>'연락처가 변경되었습니다.',
'_cell' => $encPhone,
];
});
}
/**
* 휴대폰 정규화 (숫자만)
*/
private function normalizePhone(string $v): string
{
return preg_replace('/\D+/', '', $v) ?: '';
}
private function fail(string $message): array
{
return [
'ok' => false,
'message' => $message,
'enc_phone' => '',
'raw_phone' => '',
'ci' => '',
'carrier' => '',
];
}
}

View File

@ -4,6 +4,7 @@ use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Exceptions\InvalidSignatureException;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
// (A) Routing: 도메인별 라우트 분리 // (A) Routing: 도메인별 라우트 분리
@ -40,7 +41,8 @@ return Application::configure(basePath: dirname(__DIR__))
// - 도메인 제외, path만 // - 도메인 제외, path만
// - 네 라우트 정의 기준: POST register/danal/result // - 네 라우트 정의 기준: POST register/danal/result
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'auth/register/danal/result', //다날 PASS 콜백 (외부 서버가 호출) 'auth/register/danal/result', //다날 PASS 회원가입 콜백 (외부 서버가 호출)
'mypage/info/danal/result', //다날 PASS 전화번호 변경 콜백 (외부 서버가 호출)
]); ]);
//페이지 접근권한 미들웨어 등록 //페이지 접근권한 미들웨어 등록
@ -49,10 +51,15 @@ return Application::configure(basePath: dirname(__DIR__))
'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, //게스트 접근가능페이지 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, //게스트 접근가능페이지
]); ]);
})
})
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
//
//서명 만료/위변조(temporarySignedRoute + signed middleware)
$exceptions->render(function (InvalidSignatureException $e, $request) {
return redirect('/')->with('alert', '잘못된 접근입니다.');
});
}) })
->create(); ->create();

View File

@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => 'UTC', 'timezone' => env('APP_TIMEZONE', 'Asia/Seoul'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -35,70 +35,25 @@ return [
| |
*/ */
'mailers' => [
// config/mail.php
'mailers' => [
'smtp' => [ 'smtp' => [
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'), 'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525), 'port' => (int) env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'), // STARTTLS
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, 'timeout' => (int) env('MAIL_TIMEOUT', 60), //timeout 적용
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 'local_domain' => env(
'MAIL_EHLO_DOMAIN',
parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)
),
], ],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
], ],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Global "From" Address | Global "From" Address

View File

@ -1,3 +1,6 @@
@charset "UTF-8";
@import "./web/mypage.css";
/* ========================================= /* =========================================
Voucher Mall Design System Voucher Mall Design System
Theme: White base + Blue accent Theme: White base + Blue accent

View File

@ -0,0 +1,485 @@
/* =========================================
MYPAGE 공통: Gate 카드 (비밀번호 재확인)
적용 범위: body.is-mypage 내부만
========================================= */
/* ✅ 래퍼: 가운데 정렬 + 여백 */
.is-mypage .mypage-gate-wrap{
display:flex;
justify-content:center;
padding: 6px 0 18px;
}
/* ✅ 카드 */
.is-mypage .mypage-gate-card{
width:100%;
max-width: 680px;
background:#fff;
border:1px solid rgba(16,24,40,.08);
border-radius: 18px;
box-shadow: 0 10px 30px rgba(16,24,40,.08);
overflow:hidden;
margin-top: 50px;
margin-bottom: 50px;
}
/* ✅ 바디 */
.is-mypage .mypage-gate-body{
padding: 28px;
}
/* ✅ 타이틀/설명 */
.is-mypage .mypage-gate-title{
font-size: 20px;
font-weight: 800;
letter-spacing: -.2px;
margin: 0 0 6px;
color: #101828;
}
.is-mypage .mypage-gate-desc{
margin: 0 0 14px;
color:#667085;
font-size: 14px;
line-height: 1.45;
}
/* ✅ 안내 박스 */
.is-mypage .mypage-gate-note{
margin: 0 0 18px;
padding: 12px 14px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid rgba(16,24,40,.06);
color:#344054;
font-size: 13px;
line-height: 1.5;
}
/* ✅ 한 줄 입력 + 버튼 */
.is-mypage .mypage-gate-row{
display:flex;
gap: 10px;
align-items:center;
}
.is-mypage .mypage-gate-input{
flex: 1;
height: 46px;
border-radius: 12px;
border: 1px solid #d0d5dd;
padding: 0 14px;
}
.is-mypage .mypage-gate-input:focus{
border-color: #ff7a00;
box-shadow: 0 0 0 4px rgba(255,122,0,.14);
}
/* ✅ 버튼 */
.is-mypage .mypage-gate-btn{
height: 46px;
padding: 0 18px;
border-radius: 12px;
font-weight: 800;
white-space: nowrap;
}
/* primary 버튼 */
.is-mypage .btn-mypage-primary{
background: linear-gradient(135deg, #ff7a00, #ff3d00);
border: none;
color: #fff;
}
.is-mypage .btn-mypage-primary:hover{
filter: brightness(.98);
color:#fff;
}
.is-mypage .mypage-gate-card .mypage-gate-input,
.is-mypage .mypage-gate-card input.form-control {
height: 52px;
min-height: 52px;
padding: 0 16px;
font-size: 16px; /* ✅ 모바일에서 얇아보이는/줌 이슈 방지 */
line-height: 52px; /* ✅ 세로 가운데 정렬 확실 */
border-radius: 14px;
border: 1px solid #d0d5dd;
box-sizing: border-box;
}
.is-mypage .mypage-gate-card .mypage-gate-input:focus,
.is-mypage .mypage-gate-card input.form-control:focus {
border-color: #ff7a00;
box-shadow: 0 0 0 4px rgba(255,122,0,.15);
}
/* textarea 같은 멀티라인이 섞일 가능성 대비(선택) */
.is-mypage .mypage-gate-card textarea.form-control {
line-height: 1.4;
padding: 12px 16px;
height: auto;
min-height: 120px;
}
/* ✅ 모바일: 버튼 아래로 */
@media (max-width: 575.98px){
.is-mypage .mypage-gate-card{
margin-top: 0px;
margin-bottom: 0px;
}
.is-mypage .mypage-gate-body{
padding: 20px;
}
.is-mypage .mypage-gate-row{
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.is-mypage .mypage-gate-btn{
width: 100%;
}
}
/* ================================
MYPAGE Renew (Info hub)
================================ */
.is-mypage .mypage-hero{
border: 1px solid #eef0f4;
border-radius: 16px;
background: linear-gradient(135deg, #ffffff, #fbfcff);
box-shadow: 0 10px 28px rgba(16, 24, 40, 0.06);
}
.is-mypage .mypage-hero__inner{
display: grid;
grid-template-columns: 1.2fr .8fr;
gap: 18px;
padding: 18px;
align-items: stretch;
}
.is-mypage .mypage-hero__kicker{
font-size: 12px;
font-weight: 800;
letter-spacing: .08em;
color: #667085;
}
.is-mypage .mypage-hero__title{
margin-top: 6px;
font-size: 22px;
font-weight: 900;
line-height: 1.2;
color: #101828;
}
.is-mypage .mypage-hero__desc{
margin-top: 8px;
color: #667085;
font-size: 13px;
line-height: 1.55;
}
.is-mypage .mypage-hero__right{
border-radius: 14px;
border: 1px solid #eef0f4;
background: #fff;
padding: 14px 14px 12px;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: space-between;
}
/* 상태 chip */
.is-mypage .mypage-chip{
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 12px;
border-radius: 999px;
font-weight: 800;
font-size: 12px;
border: 1px solid #e4e7ec;
background: #f9fafb;
color: #344054;
width: fit-content;
}
.is-mypage .mypage-chip.is-ok{
border-color: rgba(16,185,129,.35);
background: rgba(16,185,129,.10);
color: #067647;
}
.is-mypage .mypage-chip.is-warn{
border-color: rgba(245,158,11,.35);
background: rgba(245,158,11,.10);
color: #92400e;
}
/* 인증 상태 표 */
.is-mypage .mypage-reauth{
border-radius: 12px;
background: #f8fafc;
border: 1px solid #eef0f4;
padding: 10px 12px;
}
.is-mypage .mypage-reauth__row{
display: flex;
justify-content: space-between;
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.is-mypage .mypage-reauth__label{ color: #667085; font-weight: 700; }
.is-mypage .mypage-reauth__value{ color: #101828; font-weight: 800; }
/* 우측 버튼 */
.is-mypage .mypage-hero__actions .btn{
width: 100%;
height: 44px;
border-radius: 12px;
font-weight: 800;
}
/* 카드 그리드 */
.is-mypage .mypage-grid{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.is-mypage .mypage-card{
border: 1px solid #eef0f4;
border-radius: 16px;
background: #fff;
padding: 14px;
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.05);
display: grid;
grid-template-columns: 42px 1fr 16px;
align-items: center;
gap: 12px;
text-decoration: none;
transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease;
}
.is-mypage .mypage-card:hover{
transform: translateY(-1px);
border-color: rgba(255,122,0,.35);
box-shadow: 0 14px 30px rgba(16, 24, 40, 0.08);
}
.is-mypage .mypage-card__icon{
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
background: radial-gradient(600px 180px at 30% 30%, rgba(255,140,0,.18), transparent 55%),
radial-gradient(520px 160px at 70% 30%, rgba(94,92,230,.14), transparent 55%),
#f8fafc;
font-size: 18px;
}
.is-mypage .mypage-card__title{
font-weight: 900;
color: #101828;
font-size: 14px;
margin: 0;
}
.is-mypage .mypage-card__desc{
margin-top: 4px;
color: #667085;
font-size: 12.5px;
line-height: 1.45;
}
.is-mypage .mypage-card__meta{
margin-top: 8px;
font-size: 12px;
font-weight: 800;
color: #98a2b3;
}
.is-mypage .mypage-card__arrow{
color: #98a2b3;
font-size: 20px;
font-weight: 900;
line-height: 1;
}
/* 하단 안내 */
.is-mypage .mypage-note{
border: 1px solid #eef0f4;
border-radius: 16px;
background: #fff;
padding: 14px 16px;
}
.is-mypage .mypage-note__title{
font-weight: 900;
color: #101828;
margin-bottom: 8px;
}
.is-mypage .mypage-note__list{
margin: 0;
padding-left: 18px;
color: #475467;
font-size: 13px;
line-height: 1.55;
}
/* 반응형 */
@media (max-width: 991.98px){
.is-mypage .mypage-hero__inner{
grid-template-columns: 1fr;
}
}
@media (max-width: 575.98px){
.is-mypage .mypage-grid{
grid-template-columns: 1fr;
}
.is-mypage .mypage-hero__inner{
padding: 14px;
}
}
/* 카드가 button일 때 기본 버튼 스타일 제거 */
.is-mypage .mypage-card--btn{
width: 100%;
text-align: left;
border: 1px solid #eef0f4;
background: #fff;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.is-mypage .mypage-card--btn:focus{
outline: none;
}
.is-mypage .mypage-card--btn:focus-visible{
border-color: rgba(255,122,0,.55);
box-shadow: 0 0 0 4px rgba(255,122,0,.14);
transform: translateY(-1px);
}
/* =========================
MYPAGE: info_renew 섹션 카드 UI
========================= */
.is-mypage .mypage-section { margin-bottom: 18px; }
.is-mypage .mypage-card{
border: 1px solid #eef0f4;
border-radius: 16px;
background: #fff;
box-shadow: 0 10px 30px rgba(16,24,40,.06);
overflow: hidden;
}
.is-mypage .mypage-card__head{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap: 12px;
padding: 18px 18px 14px;
border-bottom: 1px solid #f2f4f7;
}
.is-mypage .mypage-card__title{
font-size: 16px;
font-weight: 800;
line-height: 1.2;
margin: 0 0 6px;
}
.is-mypage .mypage-card__desc{
font-size: 13px;
color: #667085;
margin: 0;
}
.is-mypage .mypage-card__body{
padding: 16px 18px 18px;
}
.is-mypage .mypage-btn{
height: 44px;
padding: 0 14px;
border-radius: 12px;
font-weight: 800;
white-space: nowrap;
}
.is-mypage .mypage-kv{
display:flex;
gap: 14px;
flex-wrap: wrap;
}
.is-mypage .mypage-kv__item{
flex: 1 1 220px;
border: 1px solid #f2f4f7;
border-radius: 14px;
padding: 12px 14px;
background: #fcfcfd;
}
.is-mypage .mypage-kv__label{
font-size: 12px;
color: #667085;
margin-bottom: 6px;
}
.is-mypage .mypage-kv__value{
font-size: 14px;
font-weight: 800;
color: #101828;
}
.is-mypage .mypage-badge{
display:inline-flex;
align-items:center;
height: 26px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid #e4e7ec;
background: #fff;
font-size: 12px;
font-weight: 800;
color: #344054;
}
.is-mypage .mypage-badge--ok{
border-color: rgba(255,122,0,.35);
background: rgba(255,122,0,.10);
color: #b54708;
}
.is-mypage .mypage-help{
border: 1px solid #eef0f4;
border-radius: 14px;
background: #f8fafc;
padding: 12px 14px;
color: #344054;
font-size: 13px;
}
.is-mypage .mypage-help ul{
margin: 0;
padding-left: 18px;
}
.is-mypage .mypage-help li{
margin: 3px 0;
}
/* 모바일에서도 버튼/정렬 깔끔하게 */
@media (max-width: 575.98px){
.is-mypage .mypage-card__head{
flex-direction: column;
align-items: stretch;
}
.is-mypage .mypage-btn{
width: 100%;
}
}

View File

@ -0,0 +1,28 @@
@extends('mail.layouts.base')
@section('content')
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;color:#101828;">
<div style="font-size:18px;font-weight:800;letter-spacing:-.2px;">{{ $title ?? '인증번호 안내' }}</div>
<div style="height:10px"></div>
<div style="font-size:13px;line-height:1.8;color:#344054;">
{{ $email }} 회원님,<br>
아래 인증번호를 입력해 주세요.<br>
<span style="color:#667085;">(인증번호는 {{ $expires_min ?? 3 }} 만료됩니다.)</span>
</div>
<div style="height:16px"></div>
<div style="background:#F2F4F7;border:1px solid #EAECF0;border-radius:14px;padding:16px 16px;text-align:center;">
<div style="font-size:28px;font-weight:900;letter-spacing:6px;color:#101828;">
{{ $code }}
</div>
</div>
<div style="height:14px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
본인이 요청하지 않았다면 메일을 무시해 주세요.
</div>
</div>
@endsection

View File

@ -0,0 +1,23 @@
@extends('mail.layouts.base')
@section('content')
<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto; max-width:640px; margin:0 auto; padding:24px;">
<h2 style="margin:0 0 12px 0;">비밀번호 재설정 안내</h2>
<p style="margin:0 0 16px 0; color:#444; line-height:1.6;">
아래 버튼을 눌러 비밀번호 재설정을 진행해 주세요.<br>
링크 유효시간: <b>{{ $expires_min ?? 30 }}</b>
</p>
<p style="margin:0 0 18px 0;">
<a href="{{ $link }}" style="display:inline-block; padding:12px 16px; background:#E4574B; color:#fff; border-radius:10px; text-decoration:none; font-weight:700;">
비밀번호 재설정하기
</a>
</p>
<p style="margin:0; color:#777; font-size:13px; line-height:1.6;">
버튼이 동작하지 않으면 아래 링크를 복사해 브라우저에 붙여넣어 주세요.<br>
<span style="word-break:break-all;">{{ $link }}</span>
</p>
</div>
@endsection

View File

@ -0,0 +1,35 @@
@extends('mail.layouts.base')
@section('content')
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;color:#101828;">
<div style="font-size:18px;font-weight:800;letter-spacing:-.2px;">이메일 주소 인증</div>
<div style="height:10px"></div>
<div style="font-size:13px;line-height:1.8;color:#344054;">
{{ $email }} 회원님,<br>
아래 버튼을 클릭하여 이메일 인증을 완료해 주세요.<br>
<span style="color:#667085;">(인증 링크는 {{ $expires_min ?? 30 }} 만료됩니다.)</span>
</div>
<div style="height:18px"></div>
<table role="presentation" cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius:12px;background:{{ $accent ?? '#E4574B' }};">
<a href="{{ $link }}" target="_blank"
style="display:inline-block;padding:12px 18px;font-size:14px;font-weight:800;
color:#fff;text-decoration:none;font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;">
이메일 인증하기
</a>
</td>
</tr>
</table>
<div style="height:16px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
버튼이 동작하지 않으면 아래 주소를 복사해 브라우저에 붙여넣으세요.<br>
<span style="word-break:break-all;color:#475467;">{{ $link }}</span>
</div>
</div>
@endsection

View File

@ -0,0 +1,65 @@
@php
$brand = $brand ?? 'PIN FOR YOU';
$accent = $accent ?? '#E4574B';
$siteUrl = $siteUrl ?? config('app.url');
@endphp
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="margin:0;padding:0;background:#f6f7fb;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f6f7fb;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0"
style="width:600px;max-width:600px;background:#ffffff;border-radius:16px;overflow:hidden;
box-shadow:0 6px 24px rgba(16,24,40,.08);">
{{-- Header --}}
<tr>
<td style="background:{{ $accent }};padding:18px 20px;">
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;
color:#fff;font-size:18px;font-weight:800;letter-spacing:-.2px;">
{{ $brand }}
</div>
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;
color:rgba(255,255,255,.9);font-size:12px;margin-top:4px;">
안전하고 빠른 상품권 거래
</div>
</td>
</tr>
{{-- Body --}}
<tr>
<td style="padding:22px 20px 10px 20px;">
@yield('content')
</td>
</tr>
{{-- Footer --}}
<tr>
<td style="padding:14px 20px 22px 20px;border-top:1px solid #eef0f4;">
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;
color:#667085;font-size:12px;line-height:1.6;">
메일은 발신전용입니다. 문의는 고객센터를 이용해 주세요.<br>
<a href="{{ $siteUrl }}" target="_blank" style="color:{{ $accent }};text-decoration:none;font-weight:700;">
{{ $brand }} 바로가기
</a>
</div>
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;
color:#98A2B3;font-size:11px;margin-top:10px;">
© {{ date('Y') }} {{ $brand }}. All rights reserved.
</div>
</td>
</tr>
</table>
{{-- small safe spacing --}}
<div style="height:18px"></div>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,51 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">출금 알림</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
ㅇㅇㅇ님!<br>
회원님의 입금계좌로 KRW가 출금되어 하기와 같이 안내 드립니다.
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:10px 5px;border-top:1px solid #000;border-bottom:1px solid #BABABA">
<p style="margin:0;padding: 0 0 10px;color:#E4574B; font-weight: bold">출금 정보</p>
출금일시 : 2017 00.00. 00:00:00<br>
출금 금액 : 0000 KRW<br>
은행 : 00은행<br>
계좌번호 : 0000000000<br>
예금주 : 000
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding: 30px 0 0;">
<a href="#" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">출금내역 확인하기</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?= $const[" _ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,157 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; max-width:800px; padding:0; text-align:left; background-color:#fff; margin:0 auto; font-family:'맑은 고딕', malgun, dotum,'돋움'; color:#454545; letter-spacing:-1px !important; word-break:keep-all; line-height:1.3 !important;">
<!-- top logo -->
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%;">
<tbody>
<tr>
<td style="padding:3% 3%; background-color:#ccd5f9">
<h1 style="margin:0; font-size:0;">
<img src="https://www.pinforyou.com/img/top_logo.png" border="0" style="width:100px; height:auto; vertical-align:top;"></a>
</h1>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- // top logo -->
<!-- container -->
<tr>
<td style="padding:0 7.5%;" align='center'>
<p style="margin:20px 0 0; font-size:20px; line-height:26px;">
<strong style="color:#0669fa; font-weight:bold;"> 회원님 </strong>온라인 상품권 쇼핑채널 <strong style="color:#0669fa; font-weight:bold;">"핀포유"</strong> 입니다.
</p>
<h3 style="font-size:30px; margin:12px 0 0; line-height:46px;">
고객님은 <strong style="color:#0669fa; font-weight:bold;"><?=$_PRODUCT_?> <?=$_MONEY_?></strong> 지급대상입니다.
</h3>
</td>
</tr>
<tr>
<td style="padding:0 7.5%;" align='left'>
<div style="margin-top:25px; padding:3.38% 4.41% 3.82%; background-color:#f9f9f9; border:1px solid #eee; border-radius:18px;">
<div style="color:#222; font-size:18px; font-weight:bold; line-height:30px;text-align:center">
상품권 수령방법
</div>
<div style="margin:24px 0 0; padding-top:27px; border-top:1px solid #eee;">
<span style="display:inline-block; color:#444; font-size:16px;line-height:35px">
1. 핀포유(pinforyou.com) 접속 <br>
2. 로그인 "고객센터>1:1문의" 접수 <br>
&nbsp;&nbsp;ㄴ문의분류선택 : "이벤트문의" <br>
&nbsp;&nbsp;ㄴ문의제목 : 이벤트참여 <br>
&nbsp;&nbsp;ㄴ문의내용 : <?=$_PRODUCT_?> 주세요. <br>
</span>
</div>
</div>
<div style="text-align:center;">
<a href="https://cash.pinforyou.com/theme/mong/tk/exchange.php#form" target="_blank" style="display:inline-block; margin-top:30px; padding:18px 29px 16px; color:#222; font-size:16px; font-weight:bold; text-decoration:none; border:1px solid #444; border-radius:9px;" rel="noreferrer noopener">PINFORYOU 바로가기</a>
</div>
<table border="0" cellpadding="0" cellspacing="0" style="margin-top:41px;">
<tbody>
<tr>
<td style="width:15px; padding-top:6px; vertical-align:top;">
<span style="display:inline-block; width:5px; height:5px; margin-bottom:2px; background-color:#444; border-radius:50%; vertical-align:middle;"></span>
</td>
<td style="color:#222; font-size:14px; line-height:28px;">1:1문의로 이벤트참여 문의글만 남겨도 <?=$_PRODUCT_?>(을)를 100% 무조건 지급!</td>
</tr>
<tr>
<td style="width:15px; padding-top:6px; vertical-align:top;">
<span style="display:inline-block; width:5px; height:5px; margin-bottom:2px; background-color:#444; border-radius:50%; vertical-align:middle;"></span>
</td>
<td style="color:#222; font-size:14px; line-height:28px;">상품권은 핀번호 형태로 지급되며, 상담사가 대상자 확인 답변으로 지급드립니다.</td>
</tr>
<tr>
<td style="width:15px; padding-top:6px; vertical-align:top;">
<span style="display:inline-block; width:5px; height:5px; margin-bottom:2px; background-color:#444; border-radius:50%; vertical-align:middle;"></span>
</td>
<td style="color:#222; font-size:14px; line-height:28px;"> 이벤트 추가 지급 이벤트가 있으니 지금 바로 참여해서 상품권 받고 추가 이벤트도 참여하세요!</td>
</tr>
</tbody>
</table>
<div style="margin-top:50px;">
<a href="https://pinforyou.com" target="_blank" style="text-decoration:none;" rel="noreferrer noopener">
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; background:#ddf9fb; border-radius:20px;">
<tbody>
<tr>
<td style="width:25%; padding-left:7.35%;">
<img src="https://cash.pinforyou.com/theme/mong/img/logo_sample_wht.png" alt="핀포유매입" style="width:100%; min-width:40px; display:block; border:0;" loading="lazy">
</td>
<td style="width:auto; padding-top:4.41%; padding-bottom:4.41%; padding-left:38px; color:#222; font-size:18px; font-weight:bold; line-height:28px;">
핀포유만의 특별혜택<BR>
"입금수수료 무료! 1분안에 자동입금!"
</td>
<td style="width:8%; padding-right: 5.88%;">
<img src="https://img.credit.co.kr/resource/img/zkm/rzm/em/banner_arrow.png" alt="컴퓨터 아이콘" style="display:block; border:0;" loading="lazy">
</td>
</tr>
</tbody>
</table>
</a>
</div>
</td>
</tr>
<tr>
<td>
<!-- 수신동의 이메일 안내 -->
<div style="margin-top:47px; padding:4% 7.5%; color:#767676; font-size:12px; border-top:1px solid #bababa;">
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="width:12px; padding-top:6px; vertical-align:top;">
<span style="display:inline-block; width:5px; height:5px; margin-bottom:2px; background-color:#bababa; border-radius:50%;"></span>
</td>
<td style="padding:2px 0 2px; line-height:22px;">2023 06 23 발송 이메일입니다.</td>
</tr>
<tr>
<td style="width:12px; padding-top:6px; vertical-align:top;">
<span style="display:inline-block; width:5px; height:5px; margin-bottom:2px; background-color:#bababa; border-radius:50%;"></span>
</td>
<td style="padding:2px 0 2px; line-height:22px;"> 이메일은 발신전용 이메일로 [2023 06 23]기준,<br>이메일 수신동의 여부를 확인한 결과 회원님께서 수신에 동의하신 것으로 확인되어 발송되었습니다.</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
<!-- // container -->
<!-- footer -->
<tr>
<td style="padding:4.365% 7.5% 6.25%; background-color:#f5f5f5;">
<div style="color:#767676; font-size:12px; line-height:20px;">
<span style="display:inline-block; margin-right:10px; padding-right:10px; background:url('https://img.credit.co.kr/resource/img/zkm/rzm/em/footer_line.png') no-repeat right 5px;">
<strong style="display:inline-block; margin-right:10px; font-weight:bold;">주소</strong>전북특별자치도 전주시 완산구 용머리로 94, 4 451
</span>
<span style="display:inline-block; margin-right:10px; padding-right:10px; background:url('https://img.credit.co.kr/resource/img/zkm/rzm/em/footer_line.png') no-repeat right 5px;">
<strong style="display:inline-block; margin-right:10px; font-weight:bold;">대표이사</strong>송병수
</span>
<span style="display:inline-block;">
<strong style="display:inline-block; margin-right:10px; font-weight:bold;">사업자등록번호</strong>121-88-01191
</span>
</div>
<div style="margin-top:1px; color:#767676; font-size:12px; line-height:20px;">
<span style="display:inline-block; margin-right:10px; padding-right:10px; background:url('https://img.credit.co.kr/resource/img/zkm/rzm/em/footer_line.png') no-repeat right 5px;">
<strong style="display:inline-block; margin-right:10px; font-weight:bold;">전화</strong>1833-4856
</span>
<span style="display:inline-block; margin-right:10px; padding-right:10px; background:url('https://img.credit.co.kr/resource/img/zkm/rzm/em/footer_line.png') no-repeat right 5px;">
<strong style="display:inline-block; margin-right:10px; font-weight:bold;">통신판매업신고번호</strong> 2018-전주완산-0705
</span>
<span style="display:inline-block;">master@plusmaker.co.kr</span>
</div>
<p style="margin:14px 0 0; color:#444; font-size:12px; letter-spacing:-0.5px;">Copyright (c) 2018 Pin For You. All Rights Reserved.</p>
</td>
</tr>
<!-- // footer -->
</tbody>
</table>

View File

@ -1,40 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">고객센터 답변</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
<a href='mailto:<?=$_EMAIL_?>' target='_blank' style='color: rgb(17, 85, 204);'><?=$_EMAIL_?></a> 회원님.<br>
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:30px 0">
<a href="<?=$_LINK_?>" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">고객센터</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?=$const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,55 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">입금 알림</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
ㅇㅇㅇ님!<br>
회원님의 계정으로 암호화폐가 입금되어 하기와 같이 안내 드립니다.
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:10px 5px;border-top:1px solid #000;border-bottom:1px solid #BABABA">
<p style="margin:0;padding: 0 0 10px;color:#E4574B; font-weight: bold">입금 정보</p>
충전 일시 : 2017 00.00. 00:00:00<br>
금액 : 0000 KRW<br>
보내는사람 : 외부 지갑<br>
거래번호 : 00000000000000000000000000000
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:15px 0">
하기 입금 내역 확인하기를 클릭 하시면 회원님의 입금 내역을 확인 하실 있습니다.
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding: 30px 0 0;">
<a href="#" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">입금내역 확인하기</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?= $const[" _ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,42 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">휴면계정 해제하기</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
<a href='mailto:<?=$_EMAIL_?>' target='_blank' style='color: rgb(17, 85, 204);'><?=$_EMAIL_?></a> 회원님.<br>
아래 버튼을 클릭해 휴면계정 해제 페이지로 이동하세요.<br>
해당 URL은 3시간 동안만 유효합니다.
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:30px 0">
<a href="<?=$_LINK_?>" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">휴면계정 해제</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?=$const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,41 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">이메일 주소 인증</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
<?=$_EMAIL_?> 회원님.<br>
이메일 인증 버튼을 클릭하여 이메일 인증을 완료 하시기 바랍니다.
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:30px 0">
<a href="<?=$_LINK_?>" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">이메일 인증</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?= $const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,57 +0,0 @@
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">로그인 알림</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
ㅇㅇㅇ님!<br> 핀포유(<a href="http://<?= $const["_ROOT_DOMAIN_"] ?>">http://<?= $const["_ROOT_DOMAIN_"] ?></a>)에 로그인 되었습니다.
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:10px 5px;border-top:1px solid #000;border-bottom:1px solid #BABABA">
<p style="margin:0;padding: 0 0 10px;color:#E4574B; font-weight: bold">접속 정보</p>
로그인 일시 : 2017 00.00<br>
접속장소 : 국가<br>
IP : <br>
접속기기<br>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:15px 0">
회원님이 로그인 하시지 않은 알림을 받으신 경우, 계정의 보안을 위해
<span style="color:#E4574B">비밀번호를 즉시 변경</span> 하시기 바라며, 고객센터로 문의 하시기 바랍니다.
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding: 30px 0 0;">
<a href="#" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">비밀번호 변경</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:15px 20px">
비밀번호 변경을 누르시면 비밀번호 변경 페이지로 이동합니다.
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="#" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">고객센터 문의하기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,41 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">회원정보수정</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
회원님.<br>
<a href='mailto:<?=$_EMAIL_?>' target='_blank' style='color: rgb(17, 85, 204);'><?=$_EMAIL_?></a> 계정의 회원정보가 수정되었음을 안내해 드립니다.
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:30px 0">
<a href="<?=$_LINK_?>" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">고객센터</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?=$const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,46 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">고객센터 1:1 고객문의 알림</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
<a href='mailto:<?=$_EMAIL_?>' target='_blank' style='color: rgb(17, 85, 204);'><?=$_EMAIL_?></a><br>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:30px 0">
<B>제목 : <?=$_TITLE_?></B><br>
등록시간 : <?=$_DATETIME_?>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:30px 0">
<?=nl2br($_CONTENT_)?>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?=$const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,42 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">비밀번호 찾기</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
<a href='mailto:<?=$_EMAIL_?>' target='_blank' style='color: rgb(17, 85, 204);'><?=$_EMAIL_?></a> 회원님.<br>
아래 버튼을 클릭해 비밀번호 변경페이지로 이동하세요.<br>
해당 URL은 3시간 동안만 유효합니다.
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:30px 0">
<a href="<?=$_LINK_?>" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">비밀번호 변경</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?=$const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -1,70 +0,0 @@
<?php if (!defined("BASEPATH")) exit("No direct script access allowed");
$const = get_defined_constants();
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E4574B;color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;max-width:600px;letter-spacing:-1px;line-height:1.5">
<tbody>
<tr>
<td align="left" valign="top" style="background-color: #E4574B;color:#FFF">
<h1 style="margin:0;padding:2px 20px;font-size:20px">핀포유 (PinForYou)</h1>
</td>
</tr>
<tr>
<td alidn="left" valign="top" style="padding:15px 20px">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:#000;font-family:'맑은 고딕', Malgun Gothic, '돋움', Dotum, arial, sans-serif;font-size:12px; background-color:#FFF;letter-spacing:-1px">
<tbody>
<tr>
<td align="left" valign="top" style="color:#E4574B">
<h2 style="margin:0;padding:0 0 15px;font-size:20px">회원가입 인증메일</h2>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding-bottom:10px;font-weight: bold;line-height:1.8">
온라인 상품권 쇼핑채널 '핀포유' 가입을 축하드립니다!<br><br>
<?=$_EMAIL_?> 회원님. 핀포유 가입을 진심으로 환영하며, 가입한 계정정보 확인 후 이메일 인증 버튼을 클릭하여 이메일 인증을 완료 하시기 바랍니다.
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:10px 5px;border-top:1px solid #000;border-bottom:1px solid #BABABA">
<p style="margin:0;padding: 0 0 10px;color:#1a19e4; font-weight: bold">가입 계정 정보</p>
핀포유 : http://<?= $const["_ROOT_DOMAIN_"]?>/<br>
아이디 : <?=$_EMAIL_?>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:15px 0">
<div style="width: 100%;border: 1px solid red;border-radius: 10px;padding:10px; background-color:#ffe8ba; line-height:2em">
<span style="color:red; font-size:14px"><b> - SNS지인사칭, 공무원사칭 피싱사기 주의 (필독) -</b></span><br><br>
1. <b style="color:red;text-decoration:underline">카카오톡/SNS메신저</b> 가족, 지인을 사칭한 피싱사기가 급증하고 있습니다. 누군가의 부탁으로 개인정보, 휴대폰 인증번호 노출 또는 결제부탁을 받으신 경우
메신저 대화를 중단 하시고 반드시 해당 <b style="color:red;text-decoration:underline">지인과 통화를 하여 사실관계</b> 확인바랍니다.<br><br>
2. <b style="color:red;">검찰 또는 수사관(공무원)</b> 사칭하는 사람에게 전화를 받고 회원 가입을 하거나, 구인광고를 통한 구매대행/고액알바 등의 아르바이트를 이유로 저희 “핀포유” 가입
<span style="color:red;text-decoration:underline">상품권 구매를 지시/요청 받았다면 99.9% 보이스피싱과 같은 금융 사고/사기</span> 가능성이 높습니다.<br><br>
이점 이용에 유의하여 주시기 바랍니다.
</div>
</td>
</tr>
<tr>
<td align="center" valign="top" style="padding:30px 0">
<a href="<?=$_LINK_?>" style="text-decoration:none;padding:10px 20px;border-radius:3px;color:#FFF;background-color:#E4574B;font-size:14px;font-weight:bold">이메일 인증</a>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding:15px 0">
이메일 인증후 사이트 이용이 가능합니다.
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="right" valign="top" style="padding:15px 20px">
<a href="http://<?= $const["_ROOT_DOMAIN_"]?>" target="_blank" style="color:#E4574B;font-weight:bold;font-size:14px;text-decoration:none">핀포유 바로가기</a>
</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,148 @@
@extends('web.layouts.auth')
@section('title', '이메일 인증 필요 | PIN FOR YOU')
@section('meta_description', 'PIN FOR YOU 이메일 인증 안내 페이지입니다.')
@section('canonical', url('/auth/email-required'))
@section('h1', '이메일 인증이 필요합니다')
@section('desc', '인증 후 사이트 이용이 가능합니다. 등록된 이메일로 인증 메일을 보내드릴게요.')
@section('card_aria', '이메일 인증 안내')
@section('show_cs_links', true)
@section('auth_content')
<form method="POST" action="{{ route('web.auth.email.send_verify') }}">
@csrf
{{-- HERO 이미지 (아이디 찾기와 동일 스타일) --}}
<img
class="reg-step0-hero__img"
src="{{ asset('assets/images/web/member/idpwfind.webp') }}"
alt=""
loading="lazy"
onerror="this.style.display='none';"
/>
<div class="auth-panel is-active" data-step="1">
<div class="auth-field">
<label class="auth-label">인증 메일 수신 주소</label>
<input
class="auth-input"
type="text"
value="{{ $email }}"
readonly
aria-readonly="true"
/>
<div class="auth-help">
이메일 주소로 인증 링크를 발송합니다. 링크는 <b>30</b> 동안만 유효합니다.
</div>
</div>
<div class="auth-actions">
{{-- id 추가 (JS가 찾을 있게) --}}
<button id="btnSendVerify" class="auth-btn auth-btn--primary" type="submit">
인증메일 발송
</button>
</div>
</div>
</form>
@endsection
@section('auth_bottom')
{{-- 필요 하단 문구 --}}
@endsection
@push('scripts')
<script>
(function () {
const form = document.querySelector('form[action="{{ route('web.auth.email.send_verify') }}"]');
const btn = document.getElementById('btnSendVerify');
if (!form || !btn) return;
const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
let animTimer = null;
// ✅ 발송중 + 점 애니메이션
const setBusy = (busy) => {
btn.disabled = !!busy;
if (busy) {
// 원래 문구 저장(최초 1회)
btn.dataset.prevText = btn.dataset.prevText || btn.textContent.trim();
btn.setAttribute('aria-busy', 'true');
let dots = 0;
btn.textContent = '발송중';
animTimer = setInterval(() => {
dots = (dots + 1) % 4; // 0~3
btn.textContent = '발송중' + '.'.repeat(dots);
}, 320);
} else {
btn.removeAttribute('aria-busy');
if (animTimer) {
clearInterval(animTimer);
animTimer = null;
}
}
};
// ✅ 성공/실패 후 버튼 문구를 "이메일 다시발송"으로 고정
const setResendLabel = () => {
btn.dataset.prevText = '이메일 다시발송';
btn.textContent = '이메일 다시발송';
};
// ✅ submit(기본 폼 전송) 막고 AJAX만 수행
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (btn.disabled) return;
setBusy(true);
try {
const res = await fetch(@json(route('web.auth.email.send_verify')), {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf(),
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
body: JSON.stringify({})
});
const ct = res.headers.get('content-type') || '';
const raw = await res.text();
let json = null;
if (ct.includes('application/json')) {
try { json = JSON.parse(raw); } catch (e) {}
}
if (!res.ok) {
const msg = json?.message || `요청 실패 (${res.status})`;
alert(msg);
setResendLabel();
return;
}
alert(json?.message || '인증메일을 발송했습니다. 메일함을 확인해 주세요.');
setResendLabel();
} catch (e) {
alert('인증메일 발송 중 오류가 발생했습니다.');
setResendLabel();
} finally {
setBusy(false);
// busy 해제 후에도 문구는 유지
if (!btn.textContent.includes('다시발송')) setResendLabel();
}
});
})();
</script>
@endpush

View File

@ -0,0 +1,60 @@
@extends('web.layouts.auth')
@section('title', '이메일 인증 완료 | PIN FOR YOU')
@section('meta_description', 'PIN FOR YOU 이메일 인증 완료 안내 페이지입니다.')
@section('canonical', url('/auth/email-verified'))
@section('h1', '이메일 인증이 완료되었습니다')
@section('desc', '인증이 정상적으로 확인되었습니다. 잠시 후 로그인 페이지로 이동합니다.')
@section('card_aria', '이메일 인증 완료 안내')
@section('show_cs_links', true)
@section('auth_content')
<div class="auth-panel is-active" data-step="done">
<div class="auth-field" style="margin-bottom:14px;">
<div class="auth-help" style="font-size:14px; line-height:1.7;">
<b>{{ $email }}</b><br>
인증이 완료되었습니다.
</div>
</div>
<div class="auth-actions">
<a class="auth-btn auth-btn--primary" href="{{ $loginUrl }}">
로그인하기
</a>
</div>
<div class="auth-help" style="margin-top:12px;">
<span id="redirectMsg">5 로그인 페이지로 이동합니다.</span>
</div>
</div>
@endsection
@section('auth_bottom')
{{-- 필요 하단 문구 --}}
@endsection
@push('scripts')
<script>
(function () {
const url = @json($loginUrl);
const total = 5;
const el = document.getElementById('redirectMsg');
let left = total;
const tick = () => {
if (el) el.textContent = `${left}초 후 로그인 페이지로 이동합니다.`;
if (left <= 0) {
location.href = url;
return;
}
left -= 1;
setTimeout(tick, 1000);
};
// 시작
tick();
})();
</script>
@endpush

View File

@ -8,6 +8,13 @@
@section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.') @section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.')
@section('card_aria', '아이디 찾기 폼') @section('card_aria', '아이디 찾기 폼')
@section('show_cs_links', true) @section('show_cs_links', true)
{{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
@push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
@endpush
@section('auth_content') @section('auth_content')
<form class="auth-form" id="findIdForm" onsubmit="return false;"> <form class="auth-form" id="findIdForm" onsubmit="return false;">
@ -37,7 +44,6 @@
<div class="auth-field"> <div class="auth-field">
<label class="auth-label" for="fi_code">인증번호</label> <label class="auth-label" for="fi_code">인증번호</label>
<input class="auth-input" id="fi_code" type="text" placeholder="6자리 인증번호" inputmode="numeric"> <input class="auth-input" id="fi_code" type="text" placeholder="6자리 인증번호" inputmode="numeric">
<div class="auth-help"> 현재는 UI만 구성되어 있어 실제 발송/검증은 동작하지 않습니다.</div>
</div> </div>
<div class="auth-actions"> <div class="auth-actions">
@ -58,7 +64,7 @@
<div class="auth-actions"> <div class="auth-actions">
<a class="auth-btn auth-btn--primary" href="{{ route('web.auth.login') }}">로그인 하기</a> <a class="auth-btn auth-btn--primary" href="{{ route('web.auth.login') }}">로그인 하기</a>
<a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.find_password') }}">비밀번호 찾기</a> <a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.find_password.show') }}">비밀번호 찾기</a>
</div> </div>
</div> </div>
</form> </form>
@ -153,6 +159,29 @@
// -------- helpers ---------- // -------- helpers ----------
const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const getRecaptchaToken = async (action) => {
try {
// recaptcha-v3.js에서 제공하는 함수명이 다를 수 있어서 2가지 케이스 처리
if (typeof window.recaptchaV3Execute === 'function') {
const t = await window.recaptchaV3Execute(action);
return (t || '').toString();
}
// fallback: grecaptcha 직접 호출
if (window.grecaptcha && window.__recaptchaSiteKey) {
return await new Promise((resolve) => {
grecaptcha.ready(() => {
grecaptcha.execute(window.__recaptchaSiteKey, { action })
.then((t) => resolve((t || '').toString()))
.catch(() => resolve(''));
});
});
}
} catch (e) {}
return '';
};
const postJson = async (url, data) => { const postJson = async (url, data) => {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@ -268,7 +297,11 @@
setMsg('확인 중입니다...', 'info'); setMsg('확인 중입니다...', 'info');
try { try {
const json = await postJson(@json(route('web.auth.find_id.send_code')), { phone: raw }); const token = await getRecaptchaToken('find_id'); // ✅ 컨트롤러 Rule action과 동일
const json = await postJson(@json(route('web.auth.find_id.send_code')), {
phone: raw,
'g-recaptcha-response': token,
});
// ✅ 성공 (ok true) // ✅ 성공 (ok true)
setMsg(json.message || '인증번호를 발송했습니다.', 'success'); setMsg(json.message || '인증번호를 발송했습니다.', 'success');
@ -278,9 +311,9 @@
startTimer(json.expires_in || 180); startTimer(json.expires_in || 180);
if(json.dev_code){ // if(json.dev_code){
setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info'); // setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info');
} // }
} catch (err) { } catch (err) {
// ✅ 여기서 404(PHONE_NOT_FOUND)도 UX로 처리 // ✅ 여기서 404(PHONE_NOT_FOUND)도 UX로 처리
@ -310,7 +343,11 @@
setMsg('인증 확인 중입니다...', 'info'); setMsg('인증 확인 중입니다...', 'info');
const json = await postJson(@json(route('web.auth.find_id.verify')), { code }); const token = await getRecaptchaToken('find_id'); // 또는 'find_id_verify'로 분리해도 됨(서버와 동일해야 함)
const json = await postJson(@json(route('web.auth.find_id.verify')), {
code,
'g-recaptcha-response': token,
});
// ✅ 먼저 step 이동 + 렌더 (패널 표시 보장) // ✅ 먼저 step 이동 + 렌더 (패널 표시 보장)

View File

@ -5,19 +5,21 @@
@section('canonical', url('/auth/find-password')) @section('canonical', url('/auth/find-password'))
@section('h1', '비밀번호 찾기') @section('h1', '비밀번호 찾기')
@section('desc', '가입된 이메일과 인증을 통해 새 비밀번호를 설정합니다.') @section('desc', '가입된 이메일과 성명 확인 후, 비밀번호 재설정 링크를 이메일로 보내드립니다.')
@section('card_aria', '비밀번호 찾기 폼') @section('card_aria', '비밀번호 찾기 폼')
@section('show_cs_links', true) @section('show_cs_links', true)
@section('auth_content') {{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
<div class="auth-steps" aria-label="진행 단계"> @push('recaptcha')
<div class="auth-step is-active" data-step-ind="1">1. 계정 확인</div> <script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<div class="auth-step" data-step-ind="2">2. 인증</div> <script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<div class="auth-step" data-step-ind="3">3. 재설정</div> <script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
</div> @endpush
@section('auth_content')
<form class="auth-form" id="findPwForm" onsubmit="return false;"> <form class="auth-form" id="findPwForm" onsubmit="return false;">
{{-- STEP 1 --}} <input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
{{-- STEP 1: 이메일 + 성명 --}}
<div class="auth-panel is-active" data-step="1"> <div class="auth-panel is-active" data-step="1">
<div class="auth-field"> <div class="auth-field">
<label class="auth-label" for="fp_email">아이디(이메일)</label> <label class="auth-label" for="fp_email">아이디(이메일)</label>
@ -27,37 +29,58 @@
placeholder="example@domain.com" placeholder="example@domain.com"
autocomplete="username" autocomplete="username"
value="{{ $email ?? '' }}"> value="{{ $email ?? '' }}">
<div class="auth-help">가입된 이메일을 입력하면 인증번호를 발송합니다.</div> <div class="auth-help">가입된 이메일을 입력해 주세요.</div>
</div>
<div class="auth-field">
<label class="auth-label" for="fp_name">성명</label>
<input class="auth-input"
id="fp_name"
type="text"
placeholder="가입 시 등록한 성명"
autocomplete="name"
value="{{ $name ?? '' }}">
<div class="auth-help">가입 등록한 성명을 입력해 주세요.</div>
</div> </div>
<div class="auth-actions"> <div class="auth-actions">
<button class="auth-btn auth-btn--primary" type="button" data-next>인증번호 받기</button> <button id="btnSendMail" class="auth-btn auth-btn--primary" type="button" data-send>
재설정 메일 발송
</button>
<a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">로그인으로 돌아가기</a> <a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">로그인으로 돌아가기</a>
</div> </div>
</div> </div>
{{-- STEP 2 --}} {{-- STEP 2: 메일 발송 안내 + 유효시간 + 재발송 --}}
<div class="auth-panel" data-step="2"> <div class="auth-panel" data-step="2">
<div class="auth-field"> <div class="auth-field">
<label class="auth-label" for="fp_code">인증번호</label> <div class="auth-help" style="line-height:1.7;">
<input class="auth-input" 입력하신 정보가 확인되면 <b>비밀번호 재설정 링크</b> 이메일로 보내드립니다.<br>
id="fp_code" 메일이 오지 않으면 스팸함/격리함을 확인해 주세요.
type="text" </div>
placeholder="6자리 인증번호"
inputmode="numeric"> {{-- 타이머 + 재발송 UI --}}
<div class="auth-help">인증번호 유효시간 내에 입력해 주세요.</div> <div class="fp-timer" style="margin-top:10px; display:flex; gap:10px; align-items:center;">
<span class="fp-timer__text" style="font-size:13px; color:#c7c7c7;"></span>
<button id="btnResendMail" type="button" class="auth-btn auth-btn--ghost" data-resend style="padding:10px 12px;">
메일 재발송
</button>
</div>
<div class="auth-help" style="margin-top:10px;">
링크는 <b>30</b> 동안만 유효합니다.
</div>
</div> </div>
<div class="auth-actions"> <div class="auth-actions">
<button class="auth-btn auth-btn--primary" type="button" data-next>확인</button>
<button class="auth-btn auth-btn--ghost" type="button" data-prev>이전</button> <button class="auth-btn auth-btn--ghost" type="button" data-prev>이전</button>
</div> </div>
</div> </div>
{{-- STEP 3 --}} {{-- STEP 3: 비밀번호 입력 (링크 검증 완료 진입) --}}
<div class="auth-panel" data-step="3"> <div class="auth-panel" data-step="3">
<div class="auth-field"> <div class="auth-field">
<label class="auth-label" for="fp_new"> 비밀번호 <small>영문/숫자/특수문자 권장</small></label> <label class="auth-label" for="fp_new"> 비밀번호 <small>8 이상 권장</small></label>
<input class="auth-input" <input class="auth-input"
id="fp_new" id="fp_new"
type="password" type="password"
@ -65,7 +88,7 @@
autocomplete="new-password"> autocomplete="new-password">
</div> </div>
<div class="auth-field"> <div class="auth-field" style="margin-top:20px">
<label class="auth-label" for="fp_new2"> 비밀번호 확인</label> <label class="auth-label" for="fp_new2"> 비밀번호 확인</label>
<input class="auth-input" <input class="auth-input"
id="fp_new2" id="fp_new2"
@ -76,11 +99,11 @@
<div class="auth-actions"> <div class="auth-actions">
<button class="auth-btn auth-btn--primary" type="button" data-reset>비밀번호 변경</button> <button class="auth-btn auth-btn--primary" type="button" data-reset>비밀번호 변경</button>
<button class="auth-btn auth-btn--ghost" type="button" data-prev>이전</button> {{-- <a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">로그인하기</a>--}}
</div> </div>
<div class="auth-help" style="margin-top:10px;"> <div class="auth-help" style="margin-top:10px;">
인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다. 이메일 링크 인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다.
</div> </div>
</div> </div>
</form> </form>
@ -101,11 +124,28 @@
let step = Number(@json($initialStep ?? 1)); let step = Number(@json($initialStep ?? 1));
const $email = document.getElementById('fp_email'); const $email = document.getElementById('fp_email');
const $code = document.getElementById('fp_code'); const $name = document.getElementById('fp_name');
const $newPw = document.getElementById('fp_new'); const $newPw = document.getElementById('fp_new');
const $newPw2= document.getElementById('fp_new2'); const $newPw2= document.getElementById('fp_new2');
// 메시지 영역: 항상 활성 패널의 actions 위에 위치 const btnSend = document.getElementById('btnSendMail');
const btnResend = document.getElementById('btnResendMail');
// recaptcha hidden input (없으면 생성)
const ensureRecaptchaInput = () => {
let el = root.querySelector('input[name="g-recaptcha-response"]');
if(!el){
el = document.createElement('input');
el.type = 'hidden';
el.name = 'g-recaptcha-response';
el.id = 'g-recaptcha-response';
el.value = '';
root.prepend(el);
}
return el;
};
// ---------- message ----------
const mkMsg = () => { const mkMsg = () => {
let el = root.querySelector('.auth-msg'); let el = root.querySelector('.auth-msg');
if(!el){ if(!el){
@ -132,7 +172,6 @@
}; };
const render = () => { const render = () => {
// 전환 전 포커스 제거
const activeEl = document.activeElement; const activeEl = document.activeElement;
if (activeEl && root.contains(activeEl)) activeEl.blur(); if (activeEl && root.contains(activeEl)) activeEl.blur();
@ -157,13 +196,37 @@
mkMsg(); mkMsg();
// 전환 후 포커스 이동
const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`); const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`);
target?.focus?.(); target?.focus?.();
}; };
// -------- helpers ---------- // -------- helpers ----------
const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
// ✅ recaptcha token getter
const getRecaptchaToken = async (action) => {
// production에서만 검증하지만, 프론트는 그냥 항상 시도해도 OK
const siteKey = window.__recaptchaSiteKey || '';
if(!siteKey) return '';
// grecaptcha 로딩 체크
if(typeof window.grecaptcha === 'undefined' || !window.grecaptcha?.execute){
return '';
}
try{
// ready 보장
await new Promise((resolve) => window.grecaptcha.ready(resolve));
const token = await window.grecaptcha.execute(siteKey, { action: action || 'find_pass' });
const input = ensureRecaptchaInput();
input.value = token || '';
return token || '';
}catch(e){
return '';
}
};
const postJson = async (url, data) => { const postJson = async (url, data) => {
const res = await fetch(url, { const res = await fetch(url, {
@ -198,50 +261,57 @@
return json ?? { ok: true }; return json ?? { ok: true };
}; };
// -------- sending button animation ----------
const makeSendingAnimator = () => {
let timer = null;
return (btn, busy, finalTextAfter = null) => {
if (!btn) return;
if (busy) {
btn.disabled = true;
btn.dataset.prevText = btn.dataset.prevText || btn.textContent.trim();
btn.setAttribute('aria-busy', 'true');
let dots = 0;
btn.textContent = '발송중';
timer = setInterval(() => {
dots = (dots + 1) % 4;
btn.textContent = '발송중' + '.'.repeat(dots);
}, 320);
} else {
btn.disabled = false;
btn.removeAttribute('aria-busy');
if (timer) { clearInterval(timer); timer = null; }
if (finalTextAfter) {
btn.dataset.prevText = finalTextAfter;
btn.textContent = finalTextAfter;
} else {
btn.textContent = btn.dataset.prevText || btn.textContent;
}
}
};
};
const setBtnBusy = makeSendingAnimator();
const setResendLabel = () => {
if (btnSend) btnSend.textContent = '메일 재발송';
if (btnResend) btnResend.textContent = '메일 재발송';
};
// -------- timer ---------- // -------- timer ----------
let timerId = null; let timerId = null;
let remain = 0; let remain = 0;
const ensureTimerUI = () => {
let wrap = root.querySelector('.fp-timer');
if(!wrap){
wrap = document.createElement('div');
wrap.className = 'fp-timer';
wrap.style.marginTop = '8px';
wrap.style.display = 'flex';
wrap.style.gap = '10px';
wrap.style.alignItems = 'center';
const t = document.createElement('span');
t.className = 'fp-timer__text';
t.style.fontSize = '13px';
t.style.color = '#c7c7c7';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'auth-btn auth-btn--ghost fp-resend';
btn.textContent = '재전송';
btn.style.padding = '10px 12px';
wrap.appendChild(t);
wrap.appendChild(btn);
const step2Field = root.querySelector('[data-step="2"] .auth-field');
step2Field?.appendChild(wrap);
}
return wrap;
};
const tick = () => { const tick = () => {
const wrap = ensureTimerUI(); const t = root.querySelector('.fp-timer__text');
const t = wrap.querySelector('.fp-timer__text');
const btn = wrap.querySelector('.fp-resend');
const mm = String(Math.floor(remain/60)).padStart(2,'0'); const mm = String(Math.floor(remain/60)).padStart(2,'0');
const ss = String(remain%60).padStart(2,'0'); const ss = String(remain%60).padStart(2,'0');
t.textContent = remain > 0 ? `유효시간 ${mm}:${ss}` : '인증번호가 만료되었습니다. 재전송 해주세요.'; if (t) {
btn.disabled = remain > 0; t.textContent = remain > 0
? `링크 유효시간 ${mm}:${ss}`
: '링크가 만료되었습니다. 메일 재발송을 진행해 주세요.';
}
if(remain <= 0){ if(remain <= 0){
clearInterval(timerId); clearInterval(timerId);
@ -252,67 +322,62 @@
}; };
const startTimer = (sec) => { const startTimer = (sec) => {
remain = Number(sec || 180); remain = Number(sec || 1800); // 30분 기본
if(timerId) clearInterval(timerId); if(timerId) clearInterval(timerId);
timerId = setInterval(tick, 1000); timerId = setInterval(tick, 1000);
tick(); tick();
}; };
// -------- actions ---------- // -------- actions ----------
const sendCode = async () => { const sendResetMail = async (fromResend = false) => {
const email = ($email?.value || '').trim(); const email = ($email?.value || '').trim();
const name = ($name?.value || '').trim();
if(!email){ if(!email){
setMsg('이메일을 입력해 주세요.', 'error'); setMsg('이메일을 입력해 주세요.', 'error');
step = 1; render();
return;
}
if(!name){
setMsg('성명을 입력해 주세요.', 'error');
step = 1; render();
return; return;
} }
setMsg('확인 중입니다...', 'info'); setMsg('확인 중입니다...', 'info');
try { const targetBtn = fromResend ? btnResend : btnSend;
const json = await postJson(@json(route('web.auth.find_password.send_code')), { email }); setBtnBusy(targetBtn, true);
setMsg(json.message || '인증번호를 발송했습니다.', 'success'); try {
// ✅ 요청 직전에 토큰 생성해서 body에 포함
const token = await getRecaptchaToken('find_pass');
if (!token) {
// 로컬/개발에선 괜찮을 수 있지만, production이면 여기서 막아도 됨
// 너 정책대로: production에서만 required니까 일단 안내만.
setMsg('보안 검증(캡챠) 로딩에 실패했습니다. 새로고침 후 다시 시도해 주세요.', 'error');
return;
}
const json = await postJson(@json(route('web.auth.find_password.send_mail')), {
email,
name,
'g-recaptcha-response': token,
});
setMsg(json.message || '재설정 메일을 발송했습니다. 메일함을 확인해 주세요.', 'success');
step = 2; step = 2;
render(); render();
startTimer(json.expires_in || 180); startTimer(json.expires_in || 1800);
setResendLabel();
if(json.dev_code){
setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info');
}
} catch (err) {
const p = err.payload || {};
if (err.status === 404 && p.code === 'EMAIL_NOT_FOUND') {
step = 1;
render();
setMsg(p.message || '해당 이메일로 가입된 계정을 찾을 수 없습니다.', 'error');
return;
}
setMsg(err.message || '오류가 발생했습니다.', 'error');
}
};
const verifyCode = async () => {
const code = ($code?.value || '').trim();
if(!/^\d{6}$/.test(code)){
setMsg('인증번호 6자리를 입력해 주세요.', 'error');
return;
}
setMsg('인증 확인 중입니다...', 'info');
try {
const json = await postJson(@json(route('web.auth.find_password.verify')), { code });
step = 3;
render();
setMsg(json.message || '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.', 'success');
} catch (err) { } catch (err) {
setMsg(err.message || '오류가 발생했습니다.', 'error'); setMsg(err.message || '오류가 발생했습니다.', 'error');
} finally {
setBtnBusy(targetBtn, false, '메일 재발송');
} }
}; };
@ -332,6 +397,7 @@
setMsg('변경 처리 중입니다...', 'info'); setMsg('변경 처리 중입니다...', 'info');
try { try {
// (비번 변경도 캡챠 걸 거면 여기에도 token 추가하면 됨)
const json = await postJson(@json(route('web.auth.find_password.reset')), { const json = await postJson(@json(route('web.auth.find_password.reset')), {
new_password: pw1, new_password: pw1,
new_password_confirmation: pw2 new_password_confirmation: pw2
@ -349,66 +415,30 @@
// -------- events ---------- // -------- events ----------
root.addEventListener('click', async (e) => { root.addEventListener('click', async (e) => {
const resend = e.target.closest('.fp-resend'); const send = e.target.closest('[data-send]');
const next = e.target.closest('[data-next]'); const resend = e.target.closest('[data-resend]');
const prev = e.target.closest('[data-prev]'); const prev = e.target.closest('[data-prev]');
const reset = e.target.closest('[data-reset]'); const reset = e.target.closest('[data-reset]');
try{ if(send){ await sendResetMail(false); return; }
if(resend){ if(resend){ await sendResetMail(true); return; }
await sendCode(); if(prev){ step = Math.max(1, step - 1); render(); return; }
return; if(reset){ await resetPassword(); return; }
}
if(next){
if(step === 1) await sendCode();
else if(step === 2) await verifyCode();
return;
}
if(reset){
await resetPassword();
return;
}
if(prev){
step = Math.max(1, step - 1);
render();
return;
}
}catch(err){
const stepFromServer = err?.payload?.step;
if(stepFromServer){
step = Number(stepFromServer);
render();
}
setMsg(err.message || '오류가 발생했습니다.', 'error');
}
}); });
// Enter UX // Enter UX
$email?.addEventListener('keydown', (e) => { $email?.addEventListener('keydown', (e) => {
if(e.key === 'Enter'){ if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); }
e.preventDefault();
root.querySelector('[data-step="1"] [data-next]')?.click();
}
}); });
$name?.addEventListener('keydown', (e) => {
$code?.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); }
if(e.key === 'Enter'){
e.preventDefault();
root.querySelector('[data-step="2"] [data-next]')?.click();
}
}); });
$newPw2?.addEventListener('keydown', (e) => { $newPw2?.addEventListener('keydown', (e) => {
if(e.key === 'Enter'){ if(e.key === 'Enter'){ e.preventDefault(); root.querySelector('[data-reset]')?.click(); }
e.preventDefault();
root.querySelector('[data-step="3"] [data-reset]')?.click();
}
}); });
render(); render();
})(); })();
</script> </script>
@endpush @endpush

View File

@ -52,7 +52,7 @@
<div class="auth-links-inline"> <div class="auth-links-inline">
<a class="auth-link" href="{{ route('web.auth.find_id') }}">아이디 찾기</a> <a class="auth-link" href="{{ route('web.auth.find_id') }}">아이디 찾기</a>
<span class="auth-dot">·</span> <span class="auth-dot">·</span>
<a class="auth-link" href="{{ route('web.auth.find_password') }}">비밀번호 찾기</a> <a class="auth-link" href="{{ route('web.auth.find_password.show') }}">비밀번호 찾기</a>
</div> </div>
</div> </div>
@ -73,93 +73,150 @@
</div> </div>
@endsection @endsection
@push('scripts') @push('scripts')
@push('scripts') <script>
<script> (function(){
(function(){ const form = document.getElementById('loginForm');
const form = document.getElementById('loginForm'); if (!form) return;
if (!form) return;
const emailEl = document.getElementById('login_id'); // ✅ 너 템플릿이 id를 뭘 쓰든 대응 (id 우선, 없으면 name으로 fallback)
const pwEl = document.getElementById('login_pw'); const emailEl =
const btn = form.querySelector('button[type="submit"]'); document.getElementById('login_id')
|| form.querySelector('input[name="mem_email"]')
|| form.querySelector('input[type="email"]');
// showMsg / clearMsg 가 공통으로 있으면 그대로 활용 const pwEl =
async function alertMsg(msg) { document.getElementById('login_pw')
if (typeof showMsg === 'function') { || form.querySelector('input[name="mem_pw"]')
await showMsg(msg, { type: 'alert', title: '입력오류' }); || form.querySelector('input[type="password"]');
} else {
alert(msg); const btn = form.querySelector('button[type="submit"]');
}
function isEmail(v){
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
}
function ensureHiddenRecaptcha(){
let el = document.getElementById('g-recaptcha-response');
if(!el){
el = document.createElement('input');
el.type = 'hidden';
el.id = 'g-recaptcha-response';
el.name = 'g-recaptcha-response';
form.appendChild(el);
} }
return el;
}
function isEmail(v){ function ensureReturnUrl(){
// 너무 빡세게 잡지 말고 기본만 let el = form.querySelector('input[name="return_url"]');
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); if(!el){
el = document.createElement('input');
el.type = 'hidden';
el.name = 'return_url';
el.value = '/';
form.appendChild(el);
} else if (!el.value) {
el.value = '/';
} }
return el;
}
form.addEventListener('submit', async function (e) { async function getRecaptchaToken(action){
e.preventDefault(); // 1) 프로젝트 공통 함수가 있으면 우선
if (typeof window.recaptchaV3Exec === 'function') {
if (typeof clearMsg === 'function') clearMsg();
const email = (emailEl?.value || '').trim();
const pw = (pwEl?.value || '');
if (!email) {
await alertMsg('아이디(이메일)를 입력해 주세요.');
emailEl?.focus();
return;
}
if (!isEmail(email)) {
await alertMsg('아이디는 이메일 형식이어야 합니다.');
emailEl?.focus();
return;
}
if (!pw) {
await alertMsg('비밀번호를 입력해 주세요.');
pwEl?.focus();
return;
}
// 버튼 잠금
if (btn) btn.disabled = true;
try { try {
// ✅ 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책) const t = await window.recaptchaV3Exec(action);
@if(app()->environment('production') && config('services.recaptcha.site_key')) return t || '';
const hidden = document.getElementById('g-recaptcha-response'); } catch (e) {
try { return '';
// recaptcha-v3.js 에 recaptchaV3Exec(action) 있다고 가정
const token = await window.recaptchaV3Exec('login');
if (hidden) hidden.value = token || '';
} catch (err) {
if (hidden) hidden.value = '';
}
@endif
form.submit(); // 실제 전송
} finally {
// submit() 호출 후 페이지 이동하므로 보통 의미 없지만
// 혹시 ajax로 바꾸면 필요함
// if (btn) btn.disabled = false;
} }
}); }
})();
// 2) fallback: grecaptcha.execute
const siteKey = window.__recaptchaSiteKey || '';
if (!siteKey) return '';
if (typeof window.grecaptcha === 'undefined') return '';
try {
await new Promise(r => window.grecaptcha.ready(r));
const t = await window.grecaptcha.execute(siteKey, { action });
return t || '';
} catch (e) {
return '';
}
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (typeof clearMsg === 'function') clearMsg();
const email = (emailEl?.value || '').trim();
const pw = (pwEl?.value || '');
if (!email) {
await alertMsg('아이디(이메일)를 입력해 주세요.');
await showMsg("아이디(이메일)를 입력해 주세요.", { type: 'alert', title: '폼체크' });
emailEl?.focus();
return;
}
if (!isEmail(email)) {
await showMsg("아이디는 이메일 형식이어야 합니다.", { type: 'alert', title: '폼체크' });
emailEl?.focus();
return;
}
if (!pw) {
await showMsg("비밀번호를 입력해 주세요.", { type: 'alert', title: '폼체크' });
pwEl?.focus();
return;
}
// return_url 없으면 기본 세팅
ensureReturnUrl();
// 버튼 잠금
if (btn) btn.disabled = true;
try {
// ✅ 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책)
const isProd = @json(app()->environment('production'));
const hasKey = @json((bool) config('services.recaptcha.site_key'));
if (isProd && hasKey) {
const hidden = ensureHiddenRecaptcha();
hidden.value = ''; // 초기화
const token = await getRecaptchaToken('login');
hidden.value = token || '';
// ✅ 토큰이 비면 submit 막아야 서버 required 안 터짐
if (!hidden.value) {
if (btn) btn.disabled = false;
await showMsg("보안 검증(reCAPTCHA) 토큰 생성에 실패했습니다. 새로고침 후 다시 시도해 주세요.", { type: 'alert', title: '보안검증 실패' });
return;
}
}
// ✅ 실제 전송
form.submit();
} catch (err) {
if (btn) btn.disabled = false;
await showMsg("로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", { type: 'alert', title: '오류' });
}
});
// 서버에서 내려온 로그인 실패 메시지 표시
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const msg = @json($errors->first('login')); const msg = @json($errors->first('login'));
if (msg) { if (msg) {
if (typeof showMsg === 'function') { await showMsg(msg, { type: 'alert', title: '로그인 실패' });
await showMsg(msg, { type: 'alert', title: '로그인 실패' });
} else if (typeof showAlert === 'function') {
await showAlert(msg, '로그인 실패');
} else {
alert(msg);
}
} }
}); });
</script> })();
@endpush </script>
@endpush @endpush

View File

@ -53,7 +53,7 @@
@yield('head') @yield('head')
</head> </head>
<body class="@yield('body_class')"> <body class="@yield('body_class') {{ !empty($mypageActive) ? 'is-mypage' : '' }}">
{{-- Header --}} {{-- Header --}}
@include('web.company.header') @include('web.company.header')

View File

@ -0,0 +1,132 @@
@php
$pageTitle = '나의정보';
$pageDesc = '연락처, 비밀번호, 보안 관련 설정을 여기서 정리하세요.';
$breadcrumbs = [
['label' => '홈', 'url' => url('/')],
['label' => '마이페이지', 'url' => url('/mypage/info')],
['label' => '나의정보', 'url' => url()->current()],
];
$mypageActive = 'info';
@endphp
@extends('web.layouts.subpage')
@section('title', '나의정보 | PIN FOR YOU')
@section('meta_description', 'PIN FOR YOU 마이페이지 나의정보 입니다. 회원 정보 및 설정을 확인하세요.')
@section('canonical', url('/mypage/info'))
@section('subcontent')
<div class="mypage-info-page">
@include('web.partials.content-head', [
'title' => '나의정보',
'desc' => '내 계정 정보를 확인하고 필요한 항목을 관리하세요.'
])
<div class="row flex-row-reverse mt-3">
<div class="mypage-gate-wrap">
<div class="mypage-gate-card mypage-gate-card--compact">
<div class="mypage-gate-body">
<div class="mypage-gate-head">
<h3 class="mypage-gate-title">비밀번호 재확인</h3>
<p class="mypage-gate-desc">회원정보 변경을 위해 비밀번호를 확인합니다.</p>
</div>
<div class="mypage-gate-note">
인증 일정 시간 동안만 정보 변경이 가능합니다.
공용 PC에서는 사용 반드시 로그아웃하세요.
</div>
<form action="{{ route('web.mypage.info.verify') }}"
method="post"
id="mypageGateForm"
class="mypage-gate-form">
@csrf
<div class="mypage-gate-row">
<input
type="password"
name="password"
id="password"
class="form-control mypage-gate-input @error('password') is-invalid @enderror"
placeholder="현재 비밀번호"
autocomplete="current-password"
required
>
<button type="submit"
class="btn btn-mypage-primary mypage-gate-btn"
id="btnGateSubmit">
확인
</button>
</div>
</form>
</div>
</div>
</div>
{{-- 서브메뉴(사이드바) --}}
<div class="col-lg-3 primary-sidebar sticky-sidebar">
@include('web.partials.mypage-quick-actions')
</div>
</div>
</div>
@push('scripts')
<script>
(function () {
const form = document.getElementById('mypageGateForm');
if (!form) return;
const pwEl = document.getElementById('password');
const btn = document.getElementById('btnGateSubmit');
// ✅ 공통 레이어 알림(showMsg) 우선 사용
async function alertMsg(msg, title = '오류') {
if (!msg) return;
if (typeof showMsg === 'function') {
await showMsg(msg, { type: 'alert', title });
} else if (typeof showAlert === 'function') {
await showAlert(msg, title);
} else {
alert(msg);
}
}
// ✅ 서버에서 내려온 에러를 레이어로 표시 (DOM 로드 후 1회)
document.addEventListener('DOMContentLoaded', async () => {
const pwErr = @json($errors->first('password'));
const loginErr = @json($errors->first('login')); // 혹시 login 키도 쓰는 경우 대비
const gateErr = @json($errors->first('gate')); // gate 키를 쓰면 여기에 걸림
const flashErr = @json(session('error') ?? '');
const msg = pwErr || gateErr || loginErr || flashErr;
if (msg) {
if (btn) btn.disabled = false; // ✅ 에러로 돌아온 경우 버튼 다시 활성화
await alertMsg(msg, '확인 실패');
}
});
// ✅ 제출 검증 + 버튼 잠금
form.addEventListener('submit', async function (e) {
const pw = (pwEl?.value || '').trim();
if (!pw) {
e.preventDefault();
await alertMsg('비밀번호를 입력해 주세요.', '입력오류');
pwEl?.focus();
return;
}
if (btn) btn.disabled = true;
});
})();
</script>
@endpush
@endsection

View File

@ -0,0 +1,557 @@
@php
$pageTitle = '나의정보';
$pageDesc = '계정 정보 관리';
$breadcrumbs = [
['label' => '홈', 'url' => url('/')],
['label' => '마이페이지', 'url' => url('/mypage/info')],
['label' => '나의정보 변경', 'url' => url()->current()],
];
$mypageActive = 'info';
@endphp
@extends('web.layouts.subpage')
@section('title', '나의정보 변경 | PIN FOR YOU')
@section('meta_description', 'PIN FOR YOU 나의정보 변경 페이지입니다.')
@section('canonical', url('/mypage/info_renew'))
@section('subcontent')
<div class="mypage-info-page">
@include('web.partials.content-head', [
'title' => '나의정보 변경',
'desc' => '계정 보안과 개인정보를 안전하게 관리하세요.'
])
{{-- 상단 상태 카드 --}}
<div class="mypage-hero mt-3">
<div class="mypage-hero__inner">
<div class="mypage-hero__left">
<div class="mypage-hero__kicker">ACCOUNT SETTINGS</div>
<div class="mypage-hero__title"> 정보 관리</div>
<div class="mypage-hero__me mt-2">
<div class="mypage-hero__me-row">
<span class="k">성명</span>
<span class="v">{{ $memberName ?: '-' }}</span>
</div>
<div class="mypage-hero__me-row">
<span class="k">이메일</span>
<span class="v">{{ $memberEmail ?: '-' }}</span>
</div>
<div class="mypage-hero__me-row">
<span class="k">휴대폰</span>
<span class="v">{{ $memberPhone ?: '-' }}</span>
</div>
<div class="mypage-hero__me-row">
<span class="k">가입일</span>
<span class="v">{{ $memberDtReg ?: '-' }}</span>
</div>
</div>
</div>
<div class="mypage-hero__right">
<div class="mypage-hero__desc">
연락처·비밀번호·보안 설정을 곳에서 관리합니다.
변경 작업은 보안을 위해 제한된 시간 동안만 가능합니다.
</div>
<div class="mypage-reauth mypage-reauth--countdown"
data-expire="{{ (int)$expireTs }}"
data-remain="{{ (int)$remainSec }}">
<div class="mypage-reauth__one">
<span class="mypage-reauth__label">인증 허용 잔여시간</span>
<span class="mypage-reauth__value">
<b id="reauthCountdown">
{{ sprintf('%02d:%02d', intdiv((int)$remainSec, 60), (int)$remainSec % 60) }}
</b>
</span>
</div>
</div>
<div class="mypage-hero__actions">
<a href="{{ route('web.mypage.info.gate_reset') }}" class="btn btn-mypage-primary">
인증 다시 하기
</a>
</div>
</div>
</div>
</div>
{{-- 설정 카드 그리드 --}}
<div class="mypage-grid mt-3">
<button type="button"
class="mypage-card mypage-card--btn"
data-action="phone-change"
data-ready-url="{{ route('web.mypage.info.pass.ready') }}">
<div class="mypage-card__icon">📱</div>
<div class="mypage-card__body">
<div class="mypage-card__title">연락처 변경</div>
<div class="mypage-card__desc">PASS 인증 휴대폰 번호를 변경합니다.</div>
<div class="mypage-card__meta">PASS 인증 필요</div>
</div>
<div class="mypage-card__arrow"></div>
</button>
<a class="mypage-card" href="javascript:void(0)" aria-label="비밀번호 변경 (준비중)" data-action="pw-change">
<div class="mypage-card__icon">🔒</div>
<div class="mypage-card__body">
<div class="mypage-card__title">비밀번호 변경</div>
<div class="mypage-card__desc">보안을 위해 주기적으로 변경을 권장해요</div>
<div class="mypage-card__meta">준비중</div>
</div>
<div class="mypage-card__arrow"></div>
</a>
<a class="mypage-card" href="javascript:void(0)" aria-label="2차 비밀번호 변경 (준비중)" data-action="pw2-change">
<div class="mypage-card__icon">🔐</div>
<div class="mypage-card__body">
<div class="mypage-card__title">2차비밀번호 변경</div>
<div class="mypage-card__desc">민감 기능 이용 추가로 확인하는 비밀번호</div>
<div class="mypage-card__meta">준비중</div>
</div>
<div class="mypage-card__arrow"></div>
</a>
<a class="mypage-card" href="javascript:void(0)" aria-label="출금계좌번호 {{ $hasWithdrawAccount ? '수정' : '등록' }}" data-action="withdraw-account">
<div class="mypage-card__icon">🏦</div>
<div class="mypage-card__body">
<div class="mypage-card__title">출금계좌번호</div>
<div class="mypage-card__desc">
{{ $hasWithdrawAccount ? '등록된 출금계좌 정보를 수정합니다.' : '출금계좌를 등록해 주세요.' }}
</div>
<div class="mypage-card__meta">
{{ $hasWithdrawAccount ? '수정' : '등록' }}
</div>
</div>
<div class="mypage-card__arrow"></div>
</a>
<button type="button" class="mypage-card mypage-card--btn" data-action="consent-edit">
<div class="mypage-card__icon">📩</div>
<div class="mypage-card__body">
<div class="mypage-card__title">수신 동의</div>
<div class="mypage-card__desc">마케팅 정보 수신 여부를 설정합니다.</div>
<div class="mypage-card__meta">
이메일: {{ ($agreeEmail === 'y' || $agreeEmail === '1') ? '동의' : '미동의' }}
· SMS: {{ ($agreeSms === 'y' || $agreeSms === '1') ? '동의' : '미동의' }}
</div>
</div>
<div class="mypage-card__arrow"></div>
</button>
<button type="button" class="mypage-card mypage-card--btn mypage-card--danger" data-action="withdraw-member">
<div class="mypage-card__icon">⚠️</div>
<div class="mypage-card__body">
<div class="mypage-card__title">회원탈퇴</div>
<div class="mypage-card__desc">계정을 삭제합니다. 진행 주의사항을 확인해 주세요.</div>
<div class="mypage-card__meta">주의 필요</div>
</div>
<div class="mypage-card__arrow"></div>
</button>
</div>
{{-- 안내/주의사항 --}}
<div class="mypage-note mt-3">
<div class="mypage-note__title">안내</div>
<ul class="mypage-note__list">
<li>개인정보 변경은 보안을 위해 <b>재인증 일정 시간</b> 동안만 가능합니다.</li>
<li>예상치 못한 오류가 발생하면, 새로고침 다시 시도해 주세요.</li>
<li>기능은 다음 단계에서 실제 처리(저장/검증/로그) 연결합니다.</li>
</ul>
</div>
</div>
<form id="mypageDanalStartForm" method="post" action="{{ route('web.mypage.info.danal.start') }}" style="display:none;">
@csrf
<input type="hidden" name="platform" id="mypagePlatform" value="web">
<input type="hidden" name="fields" id="mypagePassFields" value="">
</form>
@push('styles')
<style>
.mypage-reauth__one{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
}
#reauthCountdown{
font-size:14px;
letter-spacing:0.5px;
}
/* 더 강한 특이도(덮임 방지) + 흰 배경에서도 확실히 보이게 */
.mypage-info-page .mypage-hero .mypage-hero__me{
margin-top:12px;
padding:14px;
border-radius:14px;
background:#f7f8fb; /* ✅ 흰배경에서도 티 나게 */
border:1px solid #e5e7eb;
box-shadow: 0 10px 24px rgba(16,24,40,.08);
}
.mypage-info-page .mypage-hero .mypage-hero__me-row{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:10px 12px;
border-radius:12px;
background:#ffffff;
border:1px solid rgba(0,0,0,.04);
}
.mypage-info-page .mypage-hero .mypage-hero__me-row + .mypage-hero__me-row{
margin-top:8px;
}
.mypage-info-page .mypage-hero .mypage-hero__me-row:hover{
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(16,24,40,.06);
}
.mypage-info-page .mypage-hero .mypage-hero__me-row .k{
display:inline-flex;
align-items:center;
gap:8px;
font-size:12px;
font-weight:800;
color:#667085;
white-space:nowrap;
}
.mypage-info-page .mypage-hero .mypage-hero__me-row .k::before{
content:'';
width:8px;
height:8px;
border-radius:999px;
background:#2563eb; /* 포인트 컬러 */
box-shadow: 0 0 0 4px rgba(37,99,235,.12);
}
.mypage-info-page .mypage-hero .mypage-hero__me-row .v{
font-size:14px;
font-weight:900;
color:#101828;
letter-spacing:.2px;
text-align:right;
word-break:break-all;
}
/* 모바일 */
@media (max-width: 480px){
.mypage-info-page .mypage-hero .mypage-hero__me{
padding:12px;
border-radius:12px;
}
.mypage-info-page .mypage-hero .mypage-hero__me-row{
padding:10px 10px;
}
.mypage-info-page .mypage-hero .mypage-hero__me-row .v{
font-size:13px;
}
}
</style>
@endpush
@push('scripts')
<script>
/**
* 1) 재인증 타이머 (버튼 유무와 무관하게 항상 실행)
* - remainSec 기반 카운트다운
* - 0 되면 alert info 페이지로 이동
*/
(function () {
const box = document.querySelector('.mypage-reauth--countdown');
const out = document.getElementById('reauthCountdown');
if (!box || !out) return;
const redirectUrl = "{{ route('web.mypage.info.gate_reset') }}";
// ✅ 서버가 내려준 세션 until(문자열) 우선 사용
const untilStr = (box.getAttribute('data-until') || '').trim();
// fallback: 렌더링 시점 remain
const remainFallback = parseInt(box.getAttribute('data-remain') || '0', 10);
// until 파싱 (서버가 'YYYY-MM-DD HH:MM:SS'로 주면 JS Date가 못 읽는 경우가 있음)
// 그래서 'YYYY-MM-DDTHH:MM:SS'로 변환해서 파싱 시도
function parseUntilMs(s){
if (!s) return null;
// 2026-02-01 16:33:50 -> 2026-02-01T16:33:50
const isoLike = s.includes('T') ? s : s.replace(' ', 'T');
const ms = Date.parse(isoLike);
return Number.isFinite(ms) ? ms : null;
}
const untilMs = parseUntilMs(untilStr);
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');
}
let done = false;
let timer = null;
function expireUI(){
const chip = document.querySelector('.mypage-chip');
if (chip) {
chip.classList.remove('is-ok');
chip.classList.add('is-warn');
chip.textContent = '재인증 필요';
}
}
async function onExpiredOnce() {
await showMsg("인증 허용 시간이 만료되었습니다.\n\n보안을 위해 다시 재인증이 필요합니다.", { type: 'alert', title: '인증완료' });
window.location.href = "{{ route('web.mypage.info.gate_reset') }}";
}
function getRemainSec(){
// ✅ untilMs가 유효하면 "세션 until - 현재시간"으로 매번 계산
if (untilMs !== null) {
const diffMs = untilMs - Date.now();
return Math.max(0, Math.floor(diffMs / 1000));
}
// fallback: remainFallback에서 감소시키는 방식(최후의 수단)
return Math.max(0, Number.isFinite(remainFallback) ? remainFallback : 0);
}
// fallback용 remain 변수 (untilMs가 없을 때만 사용)
let remain = Math.max(0, Number.isFinite(remainFallback) ? remainFallback : 0);
function tick(){
const sec = (untilMs !== null) ? getRemainSec() : remain;
out.textContent = fmt(sec);
if (sec <= 0) {
if (timer) clearInterval(timer);
onExpiredOnce();
return;
}
// untilMs가 없을 때만 1초씩 감소
if (untilMs === null) {
remain -= 1;
}
}
tick();
timer = setInterval(tick, 1000);
})();
/**
* 2) 연락처 변경 버튼 passReady danal iframe 모달
* (기존 로직 유지, 타이머와 분리)
*/
(function(){
const btn = document.querySelector('[data-action="phone-change"]');
const startForm = document.getElementById('mypageDanalStartForm');
// 버튼이 없는 페이지에서도 타이머는 돌아야 하므로, 여기서만 return
if (!btn || !startForm) return;
function isMobileUA(){
const ua = navigator.userAgent || '';
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
}
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 = `
<div class="danal-modal-dim" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:200000;background:#000;opacity:.55"></div>
<div class="danal-modal-box" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:${w}px;height:${h}px;background:#fff;border-radius:12px;z-index:200001;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35);">
<div style="height:46px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;background:rgba(0,0,0,.04);border-bottom:1px solid rgba(0,0,0,.08);">
<div style="font-weight:900;font-size:13px;color:#111;">PASS 본인인증</div>
<button type="button" id="${popupName}_close"
aria-label="인증창 닫기"
style="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;">×</button>
</div>
<iframe id="${popupName}_iframe" name="${popupName}_iframe" style="width:100%;height:calc(100% - 46px);border:none"></iframe>
</div>
`;
document.body.appendChild(wrap);
function removeModal() {
const el = document.getElementById(popupName);
if (el) el.remove();
}
function askCancelAndGo() {
//시스템 confirm
const ok = window.confirm("인증을 중단하시겠습니까?\n\n닫으면 현재 변경 진행이 취소됩니다.");
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);
const escHandler = (e) => { if (e.key === 'Escape') askCancelAndGo(); };
window.addEventListener('keydown', escHandler);
window.closeIframe = function () {
window.removeEventListener('keydown', escHandler);
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 = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
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 function(){
const readyUrl = btn.getAttribute('data-ready-url');
if (!readyUrl) {
await showMsg('준비 URL이 없습니다(data-ready-url).', { type: 'alert', title: '오류' });
return;
}
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': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'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 () {
const $ = (sel) => document.querySelector(sel);
// 비밀번호 변경
$('[data-action="pw-change"]')?.addEventListener('click', async () => {
await showMsg('준비중입니다.', { type: 'alert', title: '비밀번호 변경' });
});
// 2차 비밀번호 변경
$('[data-action="pw2-change"]')?.addEventListener('click', async () => {
await showMsg('준비중입니다.', { type: 'alert', title: '2차비밀번호 변경' });
});
// 출금계좌 등록/수정
$('[data-action="withdraw-account"]')?.addEventListener('click', async () => {
await showMsg('준비중입니다.', { type: 'alert', title: '출금계좌번호' });
});
// 수신동의 수정
$('[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: '회원탈퇴' });
});
})();
</script>
@endpush
@endsection

View File

@ -5,5 +5,5 @@
<span class="auth-dot">·</span> <span class="auth-dot">·</span>
<a href="{{ route('web.auth.find_id') }}" class="auth-link">아이디 찾기</a> <a href="{{ route('web.auth.find_id') }}" class="auth-link">아이디 찾기</a>
<span class="auth-dot">·</span> <span class="auth-dot">·</span>
<a href="{{ route('web.auth.find_password') }}" class="auth-link">비밀번호 찾기</a> <a href="{{ route('web.auth.find_password.show') }}" class="auth-link">비밀번호 찾기</a>
</nav> </nav>

View File

@ -6,7 +6,9 @@ use App\Http\Controllers\Web\Auth\FindIdController;
use App\Http\Controllers\Web\Auth\FindPasswordController; use App\Http\Controllers\Web\Auth\FindPasswordController;
use App\Http\Controllers\Web\Auth\RegisterController; use App\Http\Controllers\Web\Auth\RegisterController;
use App\Http\Controllers\Web\Auth\LoginController; use App\Http\Controllers\Web\Auth\LoginController;
use App\Http\Controllers\Web\Mypage\InfoGateController;
use App\Http\Controllers\Web\Cs\NoticeController; use App\Http\Controllers\Web\Cs\NoticeController;
use App\Http\Controllers\Web\Auth\EmailVerificationController;
Route::view('/', 'web.home')->name('web.home'); Route::view('/', 'web.home')->name('web.home');
@ -34,7 +36,16 @@ Route::prefix('cs')->name('web.cs.')->group(function () {
Route::prefix('mypage')->name('web.mypage.') Route::prefix('mypage')->name('web.mypage.')
->middleware('legacy.auth') ->middleware('legacy.auth')
->group(function () { ->group(function () {
Route::view('info', 'web.mypage.info.index')->name('info.index'); Route::get('info', [InfoGateController::class, 'show'])->name('info.index');
Route::post('info', [InfoGateController::class, 'verify'])->name('info.verify');
Route::get('info_renew', [InfoGateController::class, 'info_renew'])->name('info.renew');
Route::post('info/pass/ready', [InfoGateController::class, 'passReady'])->name('info.pass.ready');
Route::post('info/danal/start', [InfoGateController::class, 'danalStart'])->name('info.danal.start');
Route::post('info/danal/result', [InfoGateController::class, 'danalResult'])->name('info.danal.result');
Route::get('info/gate-reset', [InfoGateController::class, 'gateReset'])->name('info.gate_reset');
Route::view('usage', 'web.mypage.usage.index')->name('usage.index'); Route::view('usage', 'web.mypage.usage.index')->name('usage.index');
Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index'); Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index');
Route::view('qna', 'web.mypage.qna.index')->name('qna.index'); Route::view('qna', 'web.mypage.qna.index')->name('qna.index');
@ -45,7 +56,7 @@ Route::prefix('mypage')->name('web.mypage.')
| Policy | Policy
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
//로그인 후 접근하면 안됨 //일반접근
Route::prefix('policy')->name('web.policy.')->group(function () { Route::prefix('policy')->name('web.policy.')->group(function () {
Route::view('/', 'web.policy.index')->name('index'); Route::view('/', 'web.policy.index')->name('index');
Route::view('privacy', 'web.policy.privacy.index')->name('privacy.index'); Route::view('privacy', 'web.policy.privacy.index')->name('privacy.index');
@ -96,18 +107,27 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
Route::post('find-id/reset', [FindIdController::class, 'reset'])->name('find_id.reset'); Route::post('find-id/reset', [FindIdController::class, 'reset'])->name('find_id.reset');
// 비밀번호 찾기 // 비밀번호 찾기
Route::get('find-password', [FindPasswordController::class, 'show'])->name('find_password'); Route::get('find-password', [FindPasswordController::class, 'show'])->name('find_password.show');
Route::post('find-password/send-code', [FindPasswordController::class, 'sendCode'])->name('find_password.send_code'); Route::post('find-password/send-mail', [FindPasswordController::class, 'sendMail'])->name('find_password.send_mail');
Route::post('find-password/verify', [FindPasswordController::class, 'verify'])->name('find_password.verify'); Route::get('find-password/verify', [FindPasswordController::class, 'verifyLink'])->name('find_password.verify')->middleware('signed');
Route::post('find-password/reset', [FindPasswordController::class, 'reset'])->name('find_password.reset'); Route::post('find-password/reset', [FindPasswordController::class, 'reset'])->name('find_password.reset');
Route::post('find-password/reset-session', [FindPasswordController::class, 'resetSession'])->name('find_password.reset_session');
//이메일인증
Route::get('email-required', [EmailVerificationController::class, 'requiredPage'])->name('email.required');
Route::post('email/send-verify', [EmailVerificationController::class, 'sendVerify'])->name('email.send_verify');
Route::get('email/verify', [EmailVerificationController::class, 'verify'])->name('email.verify')->middleware('signed');
}); });
Route::middleware('legacy.auth')->group(function () { Route::post('logout', [LoginController::class, 'logout'])->name('logout');
Route::post('logout', [LoginController::class, 'logout'])->name('logout');
});
}); });
Route::prefix('auth')->name('web.auth.')->group(function () {
Route::middleware('legacy.guest')->group(function () {
Route::get('email-required', [EmailVerificationController::class, 'requiredPage'])->name('email.required');
Route::post('email/send-verify', [EmailVerificationController::class, 'sendVerify'])->name('email.send_verify');
Route::get('email/verify', [EmailVerificationController::class, 'verify'])->name('email.verify')->middleware('signed'); // 만료/위변조 자동 차단
});
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -117,12 +137,6 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
Route::get('/login', fn() => redirect()->route('web.auth.login'))->name('web.login'); Route::get('/login', fn() => redirect()->route('web.auth.login'))->name('web.login');
Route::get('/register', fn() => redirect()->route('web.auth.register'))->name('web.signup'); Route::get('/register', fn() => redirect()->route('web.auth.register'))->name('web.signup');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Dev Lab (로컬에서만 + 파일 존재할 때만 라우트 등록) | Dev Lab (로컬에서만 + 파일 존재할 때만 라우트 등록)
@ -150,6 +164,8 @@ if (app()->environment(['local', 'development', 'testing'])
/* 개발용 페이지 세션 보기*/ /* 개발용 페이지 세션 보기*/
use Illuminate\Http\Request; use Illuminate\Http\Request;