인증 나의정보 정보수정 이메일인증 비밀번호 변경 메일발송
This commit is contained in:
parent
5f950a4420
commit
5cb2bc299f
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,10 @@
|
||||
namespace App\Http\Controllers\Web\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\SmsService;
|
||||
use App\Repositories\Member\MemberAuthRepository;
|
||||
use App\Support\LegacyCrypto\CiSeedCrypto;
|
||||
use App\Models\Member\MemInfo;
|
||||
use App\Rules\RecaptchaV3Rule;
|
||||
use App\Services\SmsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@ -33,37 +34,41 @@ class FindIdController extends Controller
|
||||
{
|
||||
logger()->info('HIT sendCode', ['path' => request()->path(), 'host' => request()->getHost()]);
|
||||
|
||||
$v = Validator::make($request->all(), [
|
||||
$rules = [
|
||||
'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' => '휴대폰 번호를 입력해 주세요.',
|
||||
'g-recaptcha-response.required' => '올바른 접근이 아닙니다.',
|
||||
]);
|
||||
|
||||
if ($v->fails()) {
|
||||
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
|
||||
}
|
||||
|
||||
$phoneRaw = $request->input('phone');
|
||||
$phoneRaw = (string) $request->input('phone');
|
||||
$phone = $this->normalizeKoreanPhone($phoneRaw);
|
||||
|
||||
if (!$phone) {
|
||||
return response()->json(['ok' => false, 'message' => '휴대폰 번호 형식이 올바르지 않습니다.'], 422);
|
||||
}
|
||||
|
||||
// ✅ 0) DB에 가입된 휴대폰인지 먼저 확인
|
||||
/** @var CiSeedCrypto $seed */
|
||||
$seed = app(CiSeedCrypto::class);
|
||||
|
||||
// DB에 저장된 방식(동일)으로 암호화해서 비교
|
||||
$phoneEnc = $seed->encrypt($phone);
|
||||
|
||||
$exists = MemInfo::query()
|
||||
->whereNotNull('email')
|
||||
->where('email', '<>', '')
|
||||
->where('cell_phone', $phoneEnc)
|
||||
->exists();
|
||||
$repo = app(MemberAuthRepository::class);
|
||||
$exists = $repo->existsByEncryptedPhone($phoneEnc);
|
||||
|
||||
if (!$exists) {
|
||||
// ✅ 세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공
|
||||
//\세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'code' => 'PHONE_NOT_FOUND',
|
||||
@ -80,7 +85,7 @@ class FindIdController extends Controller
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
// 6자리 OTP 생성
|
||||
// 6자리 난수 생성
|
||||
$code = (string) random_int(100000, 999999);
|
||||
|
||||
// 세션 저장(보안: 실제로는 해시 저장 권장, 여기선 간단 구현)
|
||||
@ -134,7 +139,7 @@ class FindIdController extends Controller
|
||||
'message' => '인증번호를 발송했습니다.',
|
||||
'expires_in' => 180,
|
||||
'step' => 2,
|
||||
'dev_code' => $isLocal ? $code : null,
|
||||
//'dev_code' => $isLocal ? $code : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -189,15 +194,8 @@ class FindIdController extends Controller
|
||||
// 키를 넘기지 말고, CiSeedCrypto가 생성자 주입된 userKey로 처리하게 통일
|
||||
$phoneEnc = $crypto->encrypt($phone);
|
||||
|
||||
$emails = MemInfo::query()
|
||||
->whereNotNull('email')
|
||||
->where('email', '<>', '')
|
||||
->where('cell_phone', $phoneEnc) //DB에 저장된 암호문과 동일하게 매칭됨
|
||||
->orderByDesc('mem_no')
|
||||
->pluck('email')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$repo = app(MemberAuthRepository::class);
|
||||
$emails = $repo->getEmailsByEncryptedPhone($phoneEnc);
|
||||
|
||||
if (empty($emails)) {
|
||||
// 운영에서는 암호문 노출 절대 금지 (지금은 디버그용이면 로그로만)
|
||||
@ -231,8 +229,6 @@ class FindIdController extends Controller
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $input);
|
||||
if (!$digits) return null;
|
||||
|
||||
// 010XXXXXXXX (11), 01XXXXXXXXX (10) 정도만 허용 예시
|
||||
if (Str::startsWith($digits, '010') && strlen($digits) === 11) return $digits;
|
||||
if (preg_match('/^01[0-9]{8,9}$/', $digits)) return $digits; // 필요시 범위 조정
|
||||
return null;
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Auth;
|
||||
namespace App\Http\Controllers\Web\Auth;
|
||||
|
||||
use App\Services\MailService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\MemInfo;
|
||||
use App\Rules\RecaptchaV3Rule;
|
||||
use App\Services\FindPasswordService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FindPasswordController extends Controller
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$sess = $request->session()->get('find_pw', []);
|
||||
$sess = (array) $request->session()->get('find_pw', []);
|
||||
|
||||
$step = 1;
|
||||
if (!empty($sess['verified'])) $step = 3;
|
||||
@ -25,237 +21,119 @@ class FindPasswordController extends Controller
|
||||
return view('web.auth.find_password', [
|
||||
'initialStep' => $step,
|
||||
'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'],
|
||||
], [
|
||||
'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.email' => '이메일 형식이 올바르지 않습니다.',
|
||||
'name.required' => '성명을 입력해 주세요.',
|
||||
'g-recaptcha-response.required' => '올바른 접근이 아닙니다.',
|
||||
]);
|
||||
|
||||
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([
|
||||
'ok' => false,
|
||||
'code' => 'EMAIL_NOT_FOUND',
|
||||
'message' => '해당 이메일로 가입된 계정을 찾을 수 없습니다. 이메일을 다시 확인해 주세요.',
|
||||
'step' => 1,
|
||||
], 404);
|
||||
'message' => $v->errors()->first(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
app(MailService::class)->sendTemplate(
|
||||
$email,
|
||||
'[PIN FOR YOU] 비밀번호 재설정 인증번호',
|
||||
'mail.legacy.noti_email_auth_1', // CI 템플릿명에 맞춰 선택
|
||||
[
|
||||
'code' => $code,
|
||||
'expires_min' => 3,
|
||||
'email' => $email,
|
||||
$email = (string) $request->input('email');
|
||||
$name = (string) $request->input('name');
|
||||
|
||||
$res = $svc->sendResetMail($request, $email, $name, 30);
|
||||
|
||||
if (!($res['ok'] ?? false)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'code' => $res['code'] ?? null,
|
||||
'message' => $res['message'] ?? '요청 처리 중 오류가 발생했습니다.',
|
||||
'step' => $res['step'] ?? 1,
|
||||
], (int)($res['status'] ?? 400));
|
||||
}
|
||||
|
||||
if (!empty($res['session'])) {
|
||||
$request->session()->put('find_pw', $res['session']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => $res['message'] ?? '인증메일을 발송했습니다.',
|
||||
'expires_in' => $res['expires_in'] ?? 1800,
|
||||
'step' => $res['step'] ?? 2,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function verifyLink(Request $request, FindPasswordService $svc)
|
||||
{
|
||||
$memNo = (int) $request->query('mem_no', 0);
|
||||
|
||||
$res = $svc->verifyResetLink($request, $memNo);
|
||||
|
||||
if (!($res['ok'] ?? false)) {
|
||||
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'] ?? '인증이 완료되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function reset(Request $request, FindPasswordService $svc)
|
||||
{
|
||||
$v = Validator::make($request->all(), [
|
||||
'new_password' => [
|
||||
'required', 'string', 'min:8', 'max:20',
|
||||
'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/'
|
||||
],
|
||||
queue: true
|
||||
);
|
||||
} 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) 레이트리밋(이메일 기준)
|
||||
$key = 'findpw:send:' . $email;
|
||||
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 생성
|
||||
$code = (string) random_int(100000, 999999);
|
||||
|
||||
// 3) 세션 저장 (코드는 해시로)
|
||||
$request->session()->put('find_pw', [
|
||||
'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 {
|
||||
// TODO: 프로젝트에 맞게 구현
|
||||
// 예: Mail::to($email)->send(new PasswordOtpMail($code));
|
||||
} 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);
|
||||
}
|
||||
|
||||
$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)
|
||||
{
|
||||
$v = Validator::make($request->all(), [
|
||||
'code' => ['required', 'digits:6'],
|
||||
], [
|
||||
'code.required' => '인증번호를 입력해 주세요.',
|
||||
'code.digits' => '인증번호 6자리를 입력해 주세요.',
|
||||
]);
|
||||
|
||||
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_confirmation' => ['required', 'same:new_password'],
|
||||
], [
|
||||
'new_password.required' => '새 비밀번호를 입력해 주세요.',
|
||||
'new_password.min' => '비밀번호는 8자 이상으로 입력해 주세요.',
|
||||
'new_password.confirmed' => '비밀번호 확인이 일치하지 않습니다.',
|
||||
'new_password.min' => '비밀번호는 8자리 이상이어야 합니다.',
|
||||
'new_password.max' => '비밀번호는 20자리를 초과할 수 없습니다.',
|
||||
'new_password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.',
|
||||
'new_password_confirmation.same' => '비밀번호 확인이 일치하지 않습니다.',
|
||||
]);
|
||||
|
||||
if ($v->fails()) {
|
||||
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
|
||||
}
|
||||
|
||||
$sess = $request->session()->get('find_pw');
|
||||
if (!$sess || empty($sess['email']) || empty($sess['verified'])) {
|
||||
return response()->json(['ok' => false, 'message' => '인증이 필요합니다.'], 403);
|
||||
}
|
||||
$pw = (string) $request->input('new_password');
|
||||
|
||||
$email = (string) $sess['email'];
|
||||
|
||||
// (선택) 인증 후 너무 오래 지나면 재인증 요구
|
||||
$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();
|
||||
$res = $svc->resetPasswordFinal($request, $pw);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.',
|
||||
'redirect_url' => route('web.auth.login'),
|
||||
]);
|
||||
'ok' => (bool)($res['ok'] ?? false),
|
||||
'message' => $res['message'] ?? '',
|
||||
'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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ final class LoginController extends Controller
|
||||
|
||||
if (app()->environment('production')) {
|
||||
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')];
|
||||
var_dump('google');
|
||||
exit;
|
||||
}
|
||||
|
||||
$v = Validator::make($request->all(), $rules, [
|
||||
@ -60,11 +62,25 @@ final class LoginController extends Controller
|
||||
'return_url' => $returnUrl,
|
||||
]);
|
||||
|
||||
if (!$res['ok']) {
|
||||
// UI 처리 방식은 프로젝트 스타일에 맞춰
|
||||
// (일단 errors로 처리)
|
||||
if (!($res['ok'] ?? false)) {
|
||||
|
||||
// 이메일 미인증이면 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()
|
||||
->withErrors(['login' => $res['message']])
|
||||
->withErrors(['login' => $res['message'] ?? '로그인에 실패했습니다.'])
|
||||
->withInput(['mem_email' => $email]);
|
||||
}
|
||||
|
||||
@ -89,18 +105,18 @@ final class LoginController extends Controller
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->session()->forget('_sess');
|
||||
|
||||
// (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가
|
||||
$request->session()->forget('signup');
|
||||
$request->session()->forget('register');
|
||||
|
||||
// (선택) 디버그 세션 정리
|
||||
$request->session()->forget('debug');
|
||||
|
||||
// ✅ 세션 저장
|
||||
$request->session()->save();
|
||||
|
||||
// $request->session()->forget('_sess');
|
||||
//
|
||||
// // (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가
|
||||
// $request->session()->forget('signup');
|
||||
// $request->session()->forget('register');
|
||||
//
|
||||
// // (선택) 디버그 세션 정리
|
||||
// $request->session()->forget('debug');
|
||||
//
|
||||
// $request->session()->save();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
return redirect()->route('web.home')
|
||||
->with('ui_dialog', [
|
||||
'type' => 'alert',
|
||||
|
||||
335
app/Http/Controllers/Web/Mypage/InfoGateController.php
Normal file
335
app/Http/Controllers/Web/Mypage/InfoGateController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -2,14 +2,12 @@
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
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 string $subjectText,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
208
app/Repositories/Member/EmailAuthRepository.php
Normal file
208
app/Repositories/Member/EmailAuthRepository.php
Normal 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'] ?? ''),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
21
app/Repositories/Member/MemInfoRepository.php
Normal file
21
app/Repositories/Member/MemInfoRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,64 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories\Member;
|
||||
|
||||
use App\Support\LegacyCrypto\CiPassword;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class MemStRingRepository
|
||||
{
|
||||
/**
|
||||
* CI3 기본(1차+2차) 저장과 동일한 upsert
|
||||
*/
|
||||
public function upsertBoth(int $memNo, string $plainPassword, string $pin2, ?string $dtNow = null): void
|
||||
public function getLegacyPass1(int $memNo, int $set = 0): ?string
|
||||
{
|
||||
$dt = $dtNow ?: Carbon::now()->toDateTimeString();
|
||||
$col = 'str_' . (int)$set; // str_0 / str_1 / str_2
|
||||
|
||||
[$str0, $str1, $str2] = CiPassword::makeAll($plainPassword);
|
||||
$pass2 = CiPassword::makePass2($pin2);
|
||||
$row = DB::table('mem_st_ring')
|
||||
->select($col)
|
||||
->where('mem_no', $memNo)
|
||||
->first();
|
||||
|
||||
DB::statement(
|
||||
"INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg, passwd2, passwd2_reg)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
str_0 = ?, str_1 = ?, str_2 = ?, dt_reg = ?, passwd2 = ?, passwd2_reg = ?",
|
||||
[
|
||||
$memNo, $str0, $str1, $str2, $dt, $pass2, $dt,
|
||||
$str0, $str1, $str2, $dt, $pass2, $dt,
|
||||
]
|
||||
);
|
||||
if (!$row) return null;
|
||||
|
||||
$val = $row->{$col} ?? null;
|
||||
$val = is_string($val) ? trim($val) : null;
|
||||
|
||||
return $val !== '' ? $val : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CI3 modify_type == "1_passwd" 대응 (1차만)
|
||||
*/
|
||||
public function upsertPassword1(int $memNo, string $plainPassword, ?string $dtNow = null): void
|
||||
public function getLegacyPass2(int $memNo): ?string
|
||||
{
|
||||
$dt = $dtNow ?: Carbon::now()->toDateTimeString();
|
||||
[$str0, $str1, $str2] = CiPassword::makeAll($plainPassword);
|
||||
$val = DB::table('mem_st_ring')
|
||||
->where('mem_no', $memNo)
|
||||
->value('passwd2');
|
||||
|
||||
DB::statement(
|
||||
"INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg)
|
||||
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]
|
||||
);
|
||||
$val = is_string($val) ? trim($val) : null;
|
||||
return $val !== '' ? $val : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,16 +206,16 @@ class MemberAuthRepository
|
||||
|
||||
// 2) 이미 회원인지 체크
|
||||
$member = $this->findMemberByPhone($phone, $carrier);
|
||||
// if ($member && !empty($member->mem_no)) {
|
||||
// return array_merge($base, [
|
||||
// 'ok' => false,
|
||||
// 'reason' => 'already_member',
|
||||
// 'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?",
|
||||
// 'redirect' => route('web.auth.find_id'),
|
||||
// 'matched_mem_no' => (int) $member->mem_no,
|
||||
// 'matched_cell_corp' => $member->cell_corp ?? null, // ✅ 필요시
|
||||
// ]);
|
||||
// }
|
||||
if ($member && !empty($member->mem_no)) {
|
||||
return array_merge($base, [
|
||||
'ok' => false,
|
||||
'reason' => 'already_member',
|
||||
'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?",
|
||||
'redirect' => route('web.auth.find_id'),
|
||||
'matched_mem_no' => (int) $member->mem_no,
|
||||
'matched_cell_corp' => $member->cell_corp ?? null, // ✅ 필요시
|
||||
]);
|
||||
}
|
||||
|
||||
// 3) 기존 phone+ip 필터
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
74
app/Repositories/Mypage/MypageInfoRepository.php
Normal file
74
app/Repositories/Mypage/MypageInfoRepository.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
78
app/Services/EmailVerificationService.php
Normal file
78
app/Services/EmailVerificationService.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
246
app/Services/FindPasswordService.php
Normal file
246
app/Services/FindPasswordService.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -2,23 +2,51 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Mail\TemplateMail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class MailService
|
||||
{
|
||||
/**
|
||||
* CI macro.sendmail 같은 역할
|
||||
* @param string|array $to
|
||||
*/
|
||||
public function sendTemplate($to, string $subject, string $view, array $data = []): void
|
||||
public function sendTemplate($to, string $subject, string $view, array $data = [], bool $queue = false): void
|
||||
{
|
||||
$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) {
|
||||
Mail::send($view, $data, function ($m) use ($toEmail, $subject) {
|
||||
$m->to($toEmail)->subject($subject);
|
||||
});
|
||||
$mailable = new TemplateMail(
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -408,7 +408,7 @@ class MemInfoService
|
||||
$this->insertLoginLog($yearTable, $log);
|
||||
|
||||
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회 이상 실패시 인증을 다시받아야 합니다."];
|
||||
@ -419,7 +419,10 @@ class MemInfoService
|
||||
if (($levelInfo['level'] ?? 0) < 1 || empty($levelInfo['auth_state']['email'])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message'=>"<br>이메일 인증 완료후 이용가능합니다. \n이메일주소(".$mem->email.") 메일을 확인하세요\n",
|
||||
'reason' => 'email_unverified',
|
||||
'email' => (string)$mem->email,
|
||||
'mem_no' => (int)$mem->mem_no,
|
||||
'message' => '이메일 인증이 필요합니다.',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
134
app/Services/MypageInfoService.php
Normal file
134
app/Services/MypageInfoService.php
Normal 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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Routing\Exceptions\InvalidSignatureException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
// (A) Routing: 도메인별 라우트 분리
|
||||
@ -40,7 +41,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// - 도메인 제외, path만
|
||||
// - 네 라우트 정의 기준: POST register/danal/result
|
||||
$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, //게스트 접근가능페이지
|
||||
]);
|
||||
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
//서명 만료/위변조(temporarySignedRoute + signed middleware)
|
||||
$exceptions->render(function (InvalidSignatureException $e, $request) {
|
||||
return redirect('/')->with('alert', '잘못된 접근입니다.');
|
||||
});
|
||||
})
|
||||
|
||||
->create();
|
||||
|
||||
@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Seoul'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -35,69 +35,24 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
// config/mail.php
|
||||
'mailers' => [
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'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'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
'timeout' => (int) env('MAIL_TIMEOUT', 60), //timeout 적용
|
||||
'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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
@charset "UTF-8";
|
||||
@import "./web/mypage.css";
|
||||
|
||||
/* =========================================
|
||||
Voucher Mall Design System
|
||||
Theme: White base + Blue accent
|
||||
|
||||
485
resources/css/web/mypage.css
Normal file
485
resources/css/web/mypage.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
||||
28
resources/views/mail/auth/otp_code.blade.php
Normal file
28
resources/views/mail/auth/otp_code.blade.php
Normal 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
|
||||
23
resources/views/mail/auth/reset_password.blade.php
Normal file
23
resources/views/mail/auth/reset_password.blade.php
Normal 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
|
||||
35
resources/views/mail/auth/verify_email.blade.php
Normal file
35
resources/views/mail/auth/verify_email.blade.php
Normal 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
|
||||
65
resources/views/mail/layouts/base.blade.php
Normal file
65
resources/views/mail/layouts/base.blade.php
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
ㄴ문의분류선택 : "이벤트문의" <br>
|
||||
ㄴ문의제목 : 이벤트참여 <br>
|
||||
ㄴ문의내용 : <?=$_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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
148
resources/views/web/auth/email_required.blade.php
Normal file
148
resources/views/web/auth/email_required.blade.php
Normal 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
|
||||
60
resources/views/web/auth/email_verified.blade.php
Normal file
60
resources/views/web/auth/email_verified.blade.php
Normal 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
|
||||
@ -8,6 +8,13 @@
|
||||
@section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.')
|
||||
@section('card_aria', '아이디 찾기 폼')
|
||||
@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')
|
||||
<form class="auth-form" id="findIdForm" onsubmit="return false;">
|
||||
@ -37,7 +44,6 @@
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="fi_code">인증번호</label>
|
||||
<input class="auth-input" id="fi_code" type="text" placeholder="6자리 인증번호" inputmode="numeric">
|
||||
<div class="auth-help">※ 현재는 UI만 구성되어 있어 실제 발송/검증은 동작하지 않습니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-actions">
|
||||
@ -58,7 +64,7 @@
|
||||
|
||||
<div class="auth-actions">
|
||||
<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>
|
||||
</form>
|
||||
@ -153,6 +159,29 @@
|
||||
// -------- helpers ----------
|
||||
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 res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -268,7 +297,11 @@
|
||||
setMsg('확인 중입니다...', 'info');
|
||||
|
||||
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)
|
||||
setMsg(json.message || '인증번호를 발송했습니다.', 'success');
|
||||
@ -278,9 +311,9 @@
|
||||
|
||||
startTimer(json.expires_in || 180);
|
||||
|
||||
if(json.dev_code){
|
||||
setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info');
|
||||
}
|
||||
// if(json.dev_code){
|
||||
// setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info');
|
||||
// }
|
||||
|
||||
} catch (err) {
|
||||
// ✅ 여기서 404(PHONE_NOT_FOUND)도 UX로 처리
|
||||
@ -310,7 +343,11 @@
|
||||
|
||||
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 이동 + 렌더 (패널 표시 보장)
|
||||
|
||||
@ -5,19 +5,21 @@
|
||||
@section('canonical', url('/auth/find-password'))
|
||||
|
||||
@section('h1', '비밀번호 찾기')
|
||||
@section('desc', '가입된 이메일과 인증을 통해 새 비밀번호를 설정합니다.')
|
||||
@section('desc', '가입된 이메일과 성명 확인 후, 비밀번호 재설정 링크를 이메일로 보내드립니다.')
|
||||
@section('card_aria', '비밀번호 찾기 폼')
|
||||
@section('show_cs_links', true)
|
||||
|
||||
@section('auth_content')
|
||||
<div class="auth-steps" aria-label="진행 단계">
|
||||
<div class="auth-step is-active" data-step-ind="1">1. 계정 확인</div>
|
||||
<div class="auth-step" data-step-ind="2">2. 인증</div>
|
||||
<div class="auth-step" data-step-ind="3">3. 재설정</div>
|
||||
</div>
|
||||
{{-- ✅ 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')
|
||||
<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-field">
|
||||
<label class="auth-label" for="fp_email">아이디(이메일)</label>
|
||||
@ -27,37 +29,58 @@
|
||||
placeholder="example@domain.com"
|
||||
autocomplete="username"
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- STEP 2 --}}
|
||||
{{-- STEP 2: 메일 발송 안내 + 유효시간 + 재발송 --}}
|
||||
<div class="auth-panel" data-step="2">
|
||||
<div class="auth-field">
|
||||
<label class="auth-label" for="fp_code">인증번호</label>
|
||||
<input class="auth-input"
|
||||
id="fp_code"
|
||||
type="text"
|
||||
placeholder="6자리 인증번호"
|
||||
inputmode="numeric">
|
||||
<div class="auth-help">인증번호 유효시간 내에 입력해 주세요.</div>
|
||||
<div class="auth-help" style="line-height:1.7;">
|
||||
입력하신 정보가 확인되면 <b>비밀번호 재설정 링크</b>를 이메일로 보내드립니다.<br>
|
||||
메일이 오지 않으면 스팸함/격리함을 확인해 주세요.
|
||||
</div>
|
||||
|
||||
{{-- 타이머 + 재발송 UI --}}
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- STEP 3 --}}
|
||||
{{-- STEP 3: 새 비밀번호 입력 (링크 검증 완료 시 진입) --}}
|
||||
<div class="auth-panel" data-step="3">
|
||||
<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"
|
||||
id="fp_new"
|
||||
type="password"
|
||||
@ -65,7 +88,7 @@
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="auth-field">
|
||||
<div class="auth-field" style="margin-top:20px">
|
||||
<label class="auth-label" for="fp_new2">새 비밀번호 확인</label>
|
||||
<input class="auth-input"
|
||||
id="fp_new2"
|
||||
@ -76,11 +99,11 @@
|
||||
|
||||
<div class="auth-actions">
|
||||
<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 class="auth-help" style="margin-top:10px;">
|
||||
인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다.
|
||||
이메일 링크 인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@ -101,11 +124,28 @@
|
||||
let step = Number(@json($initialStep ?? 1));
|
||||
|
||||
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 $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 = () => {
|
||||
let el = root.querySelector('.auth-msg');
|
||||
if(!el){
|
||||
@ -132,7 +172,6 @@
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
// 전환 전 포커스 제거
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && root.contains(activeEl)) activeEl.blur();
|
||||
|
||||
@ -157,13 +196,37 @@
|
||||
|
||||
mkMsg();
|
||||
|
||||
// 전환 후 포커스 이동
|
||||
const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`);
|
||||
target?.focus?.();
|
||||
};
|
||||
|
||||
// -------- 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 res = await fetch(url, {
|
||||
@ -198,50 +261,57 @@
|
||||
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 ----------
|
||||
let timerId = null;
|
||||
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 wrap = ensureTimerUI();
|
||||
const t = wrap.querySelector('.fp-timer__text');
|
||||
const btn = wrap.querySelector('.fp-resend');
|
||||
|
||||
const t = root.querySelector('.fp-timer__text');
|
||||
const mm = String(Math.floor(remain/60)).padStart(2,'0');
|
||||
const ss = String(remain%60).padStart(2,'0');
|
||||
|
||||
t.textContent = remain > 0 ? `유효시간 ${mm}:${ss}` : '인증번호가 만료되었습니다. 재전송 해주세요.';
|
||||
btn.disabled = remain > 0;
|
||||
if (t) {
|
||||
t.textContent = remain > 0
|
||||
? `링크 유효시간 ${mm}:${ss}`
|
||||
: '링크가 만료되었습니다. 메일 재발송을 진행해 주세요.';
|
||||
}
|
||||
|
||||
if(remain <= 0){
|
||||
clearInterval(timerId);
|
||||
@ -252,67 +322,62 @@
|
||||
};
|
||||
|
||||
const startTimer = (sec) => {
|
||||
remain = Number(sec || 180);
|
||||
remain = Number(sec || 1800); // 30분 기본
|
||||
if(timerId) clearInterval(timerId);
|
||||
timerId = setInterval(tick, 1000);
|
||||
tick();
|
||||
};
|
||||
|
||||
// -------- actions ----------
|
||||
const sendCode = async () => {
|
||||
const sendResetMail = async (fromResend = false) => {
|
||||
const email = ($email?.value || '').trim();
|
||||
const name = ($name?.value || '').trim();
|
||||
|
||||
if(!email){
|
||||
setMsg('이메일을 입력해 주세요.', 'error');
|
||||
step = 1; render();
|
||||
return;
|
||||
}
|
||||
if(!name){
|
||||
setMsg('성명을 입력해 주세요.', 'error');
|
||||
step = 1; render();
|
||||
return;
|
||||
}
|
||||
|
||||
setMsg('확인 중입니다...', 'info');
|
||||
|
||||
try {
|
||||
const json = await postJson(@json(route('web.auth.find_password.send_code')), { email });
|
||||
const targetBtn = fromResend ? btnResend : btnSend;
|
||||
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;
|
||||
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) {
|
||||
setMsg(err.message || '오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
setBtnBusy(targetBtn, false, '메일 재발송');
|
||||
}
|
||||
};
|
||||
|
||||
@ -332,6 +397,7 @@
|
||||
setMsg('변경 처리 중입니다...', 'info');
|
||||
|
||||
try {
|
||||
// (비번 변경도 캡챠 걸 거면 여기에도 token 추가하면 됨)
|
||||
const json = await postJson(@json(route('web.auth.find_password.reset')), {
|
||||
new_password: pw1,
|
||||
new_password_confirmation: pw2
|
||||
@ -349,66 +415,30 @@
|
||||
|
||||
// -------- events ----------
|
||||
root.addEventListener('click', async (e) => {
|
||||
const resend = e.target.closest('.fp-resend');
|
||||
const next = e.target.closest('[data-next]');
|
||||
const send = e.target.closest('[data-send]');
|
||||
const resend = e.target.closest('[data-resend]');
|
||||
const prev = e.target.closest('[data-prev]');
|
||||
const reset = e.target.closest('[data-reset]');
|
||||
|
||||
try{
|
||||
if(resend){
|
||||
await sendCode();
|
||||
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');
|
||||
}
|
||||
if(send){ await sendResetMail(false); return; }
|
||||
if(resend){ await sendResetMail(true); return; }
|
||||
if(prev){ step = Math.max(1, step - 1); render(); return; }
|
||||
if(reset){ await resetPassword(); return; }
|
||||
});
|
||||
|
||||
// Enter 키 UX
|
||||
// Enter UX
|
||||
$email?.addEventListener('keydown', (e) => {
|
||||
if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
root.querySelector('[data-step="1"] [data-next]')?.click();
|
||||
}
|
||||
if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); }
|
||||
});
|
||||
|
||||
$code?.addEventListener('keydown', (e) => {
|
||||
if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
root.querySelector('[data-step="2"] [data-next]')?.click();
|
||||
}
|
||||
$name?.addEventListener('keydown', (e) => {
|
||||
if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); }
|
||||
});
|
||||
|
||||
$newPw2?.addEventListener('keydown', (e) => {
|
||||
if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
root.querySelector('[data-step="3"] [data-reset]')?.click();
|
||||
}
|
||||
if(e.key === 'Enter'){ e.preventDefault(); root.querySelector('[data-reset]')?.click(); }
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@endpush
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
<div class="auth-links-inline">
|
||||
<a class="auth-link" href="{{ route('web.auth.find_id') }}">아이디 찾기</a>
|
||||
<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>
|
||||
|
||||
@ -73,29 +73,79 @@
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
|
||||
const emailEl = document.getElementById('login_id');
|
||||
const pwEl = document.getElementById('login_pw');
|
||||
// ✅ 너 템플릿이 id를 뭘 쓰든 대응 (id 우선, 없으면 name으로 fallback)
|
||||
const emailEl =
|
||||
document.getElementById('login_id')
|
||||
|| form.querySelector('input[name="mem_email"]')
|
||||
|| form.querySelector('input[type="email"]');
|
||||
|
||||
const pwEl =
|
||||
document.getElementById('login_pw')
|
||||
|| form.querySelector('input[name="mem_pw"]')
|
||||
|| form.querySelector('input[type="password"]');
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
|
||||
// showMsg / clearMsg 가 공통으로 있으면 그대로 활용
|
||||
async function alertMsg(msg) {
|
||||
if (typeof showMsg === 'function') {
|
||||
await showMsg(msg, { type: 'alert', title: '입력오류' });
|
||||
} else {
|
||||
alert(msg);
|
||||
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 ensureReturnUrl(){
|
||||
let el = form.querySelector('input[name="return_url"]');
|
||||
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;
|
||||
}
|
||||
|
||||
async function getRecaptchaToken(action){
|
||||
// 1) 프로젝트 공통 함수가 있으면 우선
|
||||
if (typeof window.recaptchaV3Exec === 'function') {
|
||||
try {
|
||||
const t = await window.recaptchaV3Exec(action);
|
||||
return t || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isEmail(v){
|
||||
// 너무 빡세게 잡지 말고 기본만
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
||||
// 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) {
|
||||
@ -108,58 +158,65 @@
|
||||
|
||||
if (!email) {
|
||||
await alertMsg('아이디(이메일)를 입력해 주세요.');
|
||||
await showMsg("아이디(이메일)를 입력해 주세요.", { type: 'alert', title: '폼체크' });
|
||||
emailEl?.focus();
|
||||
return;
|
||||
}
|
||||
if (!isEmail(email)) {
|
||||
await alertMsg('아이디는 이메일 형식이어야 합니다.');
|
||||
await showMsg("아이디는 이메일 형식이어야 합니다.", { type: 'alert', title: '폼체크' });
|
||||
emailEl?.focus();
|
||||
return;
|
||||
}
|
||||
if (!pw) {
|
||||
await alertMsg('비밀번호를 입력해 주세요.');
|
||||
await showMsg("비밀번호를 입력해 주세요.", { type: 'alert', title: '폼체크' });
|
||||
pwEl?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// return_url 없으면 기본 세팅
|
||||
ensureReturnUrl();
|
||||
|
||||
// 버튼 잠금
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
// ✅ 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책)
|
||||
@if(app()->environment('production') && config('services.recaptcha.site_key'))
|
||||
const hidden = document.getElementById('g-recaptcha-response');
|
||||
try {
|
||||
// recaptcha-v3.js 에 recaptchaV3Exec(action) 있다고 가정
|
||||
const token = await window.recaptchaV3Exec('login');
|
||||
if (hidden) hidden.value = token || '';
|
||||
} catch (err) {
|
||||
if (hidden) hidden.value = '';
|
||||
}
|
||||
@endif
|
||||
const isProd = @json(app()->environment('production'));
|
||||
const hasKey = @json((bool) config('services.recaptcha.site_key'));
|
||||
|
||||
form.submit(); // 실제 전송
|
||||
} finally {
|
||||
// submit() 호출 후 페이지 이동하므로 보통 의미 없지만
|
||||
// 혹시 ajax로 바꾸면 필요함
|
||||
// if (btn) btn.disabled = false;
|
||||
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 () => {
|
||||
const msg = @json($errors->first('login'));
|
||||
if (msg) {
|
||||
if (typeof showMsg === 'function') {
|
||||
await showMsg(msg, { type: 'alert', title: '로그인 실패' });
|
||||
} else if (typeof showAlert === 'function') {
|
||||
await showAlert(msg, '로그인 실패');
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@endpush
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
@yield('head')
|
||||
</head>
|
||||
|
||||
<body class="@yield('body_class')">
|
||||
<body class="@yield('body_class') {{ !empty($mypageActive) ? 'is-mypage' : '' }}">
|
||||
|
||||
{{-- Header --}}
|
||||
@include('web.company.header')
|
||||
|
||||
132
resources/views/web/mypage/info/gate.blade.php
Normal file
132
resources/views/web/mypage/info/gate.blade.php
Normal 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
|
||||
|
||||
|
||||
557
resources/views/web/mypage/info/renew.blade.php
Normal file
557
resources/views/web/mypage/info/renew.blade.php
Normal 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
|
||||
@ -5,5 +5,5 @@
|
||||
<span class="auth-dot">·</span>
|
||||
<a href="{{ route('web.auth.find_id') }}" class="auth-link">아이디 찾기</a>
|
||||
<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>
|
||||
|
||||
@ -6,7 +6,9 @@ use App\Http\Controllers\Web\Auth\FindIdController;
|
||||
use App\Http\Controllers\Web\Auth\FindPasswordController;
|
||||
use App\Http\Controllers\Web\Auth\RegisterController;
|
||||
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\Auth\EmailVerificationController;
|
||||
|
||||
|
||||
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.')
|
||||
->middleware('legacy.auth')
|
||||
->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('exchange', 'web.mypage.exchange.index')->name('exchange.index');
|
||||
Route::view('qna', 'web.mypage.qna.index')->name('qna.index');
|
||||
@ -45,7 +56,7 @@ Route::prefix('mypage')->name('web.mypage.')
|
||||
| Policy
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
//로그인 후 접근하면 안됨
|
||||
//일반접근
|
||||
Route::prefix('policy')->name('web.policy.')->group(function () {
|
||||
Route::view('/', 'web.policy.index')->name('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::get('find-password', [FindPasswordController::class, 'show'])->name('find_password');
|
||||
Route::post('find-password/send-code', [FindPasswordController::class, 'sendCode'])->name('find_password.send_code');
|
||||
Route::post('find-password/verify', [FindPasswordController::class, 'verify'])->name('find_password.verify');
|
||||
Route::get('find-password', [FindPasswordController::class, 'show'])->name('find_password.show');
|
||||
Route::post('find-password/send-mail', [FindPasswordController::class, 'sendMail'])->name('find_password.send_mail');
|
||||
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-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::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('/register', fn() => redirect()->route('web.auth.register'))->name('web.signup');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Dev Lab (로컬에서만 + 파일 존재할 때만 라우트 등록)
|
||||
@ -150,6 +164,8 @@ if (app()->environment(['local', 'development', 'testing'])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* 개발용 페이지 세션 보기*/
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user