feat: [users] 사용자 등록 시 비밀번호 자동 생성 및 이메일 발송
- 사용자 등록 시 비밀번호 입력 필드 제거 - 임의 비밀번호 자동 생성 후 이메일 발송 - 사용자 수정 페이지에 비밀번호 초기화 버튼 추가 - 사용자 모달에 비밀번호 초기화 버튼 추가 - 사용자 모달 프로필 이미지 없을 때 이름 첫글자 표시 (한글 지원) - UserPasswordMail 클래스 및 이메일 템플릿 추가
This commit is contained in:
@@ -228,6 +228,41 @@ public function modal(Request $request, int $id): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 초기화 (임의 비밀번호 생성 + 메일 발송)
|
||||
*/
|
||||
public function resetPassword(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
// 슈퍼관리자 보호: 일반관리자가 슈퍼관리자 비밀번호 초기화 불가
|
||||
if (! $this->userService->canAccessUser($id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사용자를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$result = $this->userService->resetPassword($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사용자를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '비밀번호가 초기화되었습니다. 새 비밀번호가 사용자 이메일로 발송되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '비밀번호 초기화에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 영구 삭제 (슈퍼관리자 전용)
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,7 @@ public function rules(): array
|
||||
'name' => 'required|string|max:100',
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
// password는 시스템이 자동 생성하므로 입력 받지 않음
|
||||
'role' => 'nullable|string|max:50',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_super_admin' => 'nullable|boolean',
|
||||
|
||||
58
app/Mail/UserPasswordMail.php
Normal file
58
app/Mail/UserPasswordMail.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserPasswordMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public string $password,
|
||||
public bool $isNewUser = true
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->isNewUser
|
||||
? '[SAM] 계정이 생성되었습니다'
|
||||
: '[SAM] 비밀번호가 초기화되었습니다';
|
||||
|
||||
return new Envelope(
|
||||
subject: $subject,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.user-password',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\UserPasswordMail;
|
||||
use App\Models\DepartmentUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class UserService
|
||||
{
|
||||
@@ -80,16 +82,15 @@ public function getUserById(int $id): ?User
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 생성
|
||||
* 사용자 생성 (관리자용: 임의 비밀번호 생성 + 메일 발송)
|
||||
*/
|
||||
public function createUser(array $data): User
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
// 비밀번호 해싱
|
||||
if (isset($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
}
|
||||
// 임의 비밀번호 생성 (8자리 영문+숫자)
|
||||
$plainPassword = $this->generateRandomPassword();
|
||||
$data['password'] = Hash::make($plainPassword);
|
||||
|
||||
// is_active 처리
|
||||
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
|
||||
@@ -116,9 +117,56 @@ public function createUser(array $data): User
|
||||
$this->syncDepartments($user, $tenantId, $departmentIds);
|
||||
}
|
||||
|
||||
// 비밀번호 안내 메일 발송
|
||||
$this->sendPasswordMail($user, $plainPassword, true);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 초기화 (관리자용: 임의 비밀번호 생성 + 메일 발송)
|
||||
*/
|
||||
public function resetPassword(int $id): bool
|
||||
{
|
||||
$user = $this->getUserById($id);
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 임의 비밀번호 생성
|
||||
$plainPassword = $this->generateRandomPassword();
|
||||
|
||||
// 비밀번호 업데이트
|
||||
$user->password = Hash::make($plainPassword);
|
||||
$user->updated_by = auth()->id();
|
||||
$user->save();
|
||||
|
||||
// 비밀번호 초기화 안내 메일 발송
|
||||
$this->sendPasswordMail($user, $plainPassword, false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임의 비밀번호 생성 (8자리 영문+숫자 조합)
|
||||
*/
|
||||
private function generateRandomPassword(int $length = 8): string
|
||||
{
|
||||
// 영문 대소문자 + 숫자 조합으로 가독성 좋은 비밀번호 생성
|
||||
// 혼동되는 문자 제외: 0, O, l, 1, I
|
||||
$chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
|
||||
return substr(str_shuffle(str_repeat($chars, 3)), 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 안내 메일 발송
|
||||
*/
|
||||
private function sendPasswordMail(User $user, string $password, bool $isNewUser): void
|
||||
{
|
||||
Mail::to($user->email)->send(new UserPasswordMail($user, $password, $isNewUser));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 수정
|
||||
*/
|
||||
|
||||
@@ -182,6 +182,39 @@ const UserModal = {
|
||||
alert('복원에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 비밀번호 초기화 실행
|
||||
async resetPassword() {
|
||||
if (!this.currentUserId) return;
|
||||
|
||||
const userName = document.getElementById('user-modal-name')?.textContent || '이 사용자';
|
||||
|
||||
if (!confirm(`"${userName}"의 비밀번호를 초기화하시겠습니까?\n\n임시 비밀번호가 생성되어 사용자 이메일로 발송됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${this.currentUserId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message || '비밀번호가 초기화되었습니다.');
|
||||
} else {
|
||||
alert(data.message || '비밀번호 초기화에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset password:', error);
|
||||
alert('비밀번호 초기화에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
102
resources/views/emails/user-password.blade.php
Normal file
102
resources/views/emails/user-password.blade.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $isNewUser ? '계정 생성 안내' : '비밀번호 초기화 안내' }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.content {
|
||||
background: #f9fafb;
|
||||
padding: 30px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.password-box {
|
||||
background: #fff;
|
||||
border: 2px dashed #3b82f6;
|
||||
padding: 15px 20px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.password {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
letter-spacing: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.warning {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 12px 16px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.info {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>SAM 시스템</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>안녕하세요, <strong>{{ $user->name }}</strong>님.</p>
|
||||
|
||||
@if($isNewUser)
|
||||
<p>SAM 시스템에 계정이 생성되었습니다.</p>
|
||||
@else
|
||||
<p>비밀번호가 초기화되었습니다.</p>
|
||||
@endif
|
||||
|
||||
<div class="password-box">
|
||||
<p style="margin: 0 0 10px 0; color: #6b7280;">임시 비밀번호</p>
|
||||
<div class="password">{{ $password }}</div>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>보안 안내</strong><br>
|
||||
로그인 후 반드시 비밀번호를 변경해 주세요.
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<p><strong>로그인 정보</strong></p>
|
||||
<ul>
|
||||
<li>이메일: {{ $user->email }}</li>
|
||||
<li>로그인 URL: <a href="{{ config('app.url') }}">{{ config('app.url') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>본 메일은 발신 전용입니다. 문의사항은 관리자에게 연락해 주세요.</p>
|
||||
<p>© {{ date('Y') }} SAM System. All rights reserved.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -66,26 +66,18 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 -->
|
||||
<!-- 비밀번호 안내 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">비밀번호</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" name="password" required minlength="8"
|
||||
placeholder="최소 8자 이상"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-xs text-gray-500 mt-1">최소 8자 이상 입력하세요.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호 확인 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" name="password_confirmation" required minlength="8"
|
||||
placeholder="비밀번호 재입력"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 font-medium">임시 비밀번호가 자동 생성됩니다</p>
|
||||
<p class="text-sm text-blue-700 mt-1">사용자 생성 시 임시 비밀번호가 생성되어 입력한 이메일 주소로 발송됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,26 +70,22 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 -->
|
||||
<!-- 비밀번호 초기화 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">비밀번호 변경</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
새 비밀번호
|
||||
</label>
|
||||
<input type="password" name="password" minlength="8"
|
||||
placeholder="변경하지 않으려면 비워두세요"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-xs text-gray-500 mt-1">변경하지 않으려면 비워두세요.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호 확인
|
||||
</label>
|
||||
<input type="password" name="password_confirmation" minlength="8"
|
||||
placeholder="비밀번호 재입력"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">비밀번호</h2>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 font-medium">비밀번호 초기화</p>
|
||||
<p class="text-sm text-gray-500 mt-1">임시 비밀번호가 생성되어 사용자 이메일로 발송됩니다.</p>
|
||||
</div>
|
||||
<button type="button" id="resetPasswordBtn"
|
||||
class="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
비밀번호 초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,5 +219,41 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
// 비밀번호 초기화 버튼 처리
|
||||
document.getElementById('resetPasswordBtn').addEventListener('click', function() {
|
||||
if (!confirm('비밀번호를 초기화하시겠습니까?\n\n임시 비밀번호가 생성되어 사용자 이메일({{ $user->email }})로 발송됩니다.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 처리중...';
|
||||
|
||||
fetch('/api/admin/users/{{ $user->id }}/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert('오류: ' + (data.message || '비밀번호 초기화에 실패했습니다.'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
console.error(error);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> 비밀번호 초기화';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -42,7 +42,7 @@ class="w-20 h-20 rounded-full object-cover">
|
||||
@else
|
||||
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-blue-600">
|
||||
{{ strtoupper(substr($user->name, 0, 1)) }}
|
||||
{{ mb_strtoupper(mb_substr($user->name, 0, 1)) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@@ -199,6 +199,14 @@ class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-3
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
삭제
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="UserModal.resetPassword()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-orange-500 rounded-lg hover:bg-orange-600 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
비밀번호 초기화
|
||||
</button>
|
||||
@endif
|
||||
<button type="button"
|
||||
onclick="UserModal.goToEdit()"
|
||||
|
||||
@@ -93,6 +93,9 @@
|
||||
// 복원 (일반관리자 가능 - 슈퍼관리자 복원은 컨트롤러에서 차단)
|
||||
Route::post('/{id}/restore', [UserController::class, 'restore'])->name('restore');
|
||||
|
||||
// 비밀번호 초기화 (임의 비밀번호 생성 + 메일 발송)
|
||||
Route::post('/{id}/reset-password', [UserController::class, 'resetPassword'])->name('resetPassword');
|
||||
|
||||
// 슈퍼관리자 전용 액션 (영구삭제)
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
Route::delete('/{id}/force', [UserController::class, 'forceDestroy'])->name('forceDestroy');
|
||||
|
||||
Reference in New Issue
Block a user