feat: [users] 사용자 등록 시 비밀번호 자동 생성 및 이메일 발송

- 사용자 등록 시 비밀번호 입력 필드 제거
- 임의 비밀번호 자동 생성 후 이메일 발송
- 사용자 수정 페이지에 비밀번호 초기화 버튼 추가
- 사용자 모달에 비밀번호 초기화 버튼 추가
- 사용자 모달 프로필 이미지 없을 때 이름 첫글자 표시 (한글 지원)
- UserPasswordMail 클래스 및 이메일 템플릿 추가
This commit is contained in:
2025-12-01 10:50:16 +09:00
parent 4a454db0dc
commit 85cbe23782
10 changed files with 355 additions and 44 deletions

View File

@@ -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);
}
}
/**
* 사용자 영구 삭제 (슈퍼관리자 전용)
*/

View File

@@ -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',

View 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 [];
}
}

View File

@@ -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));
}
/**
* 사용자 수정
*/

View File

@@ -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('비밀번호 초기화에 실패했습니다.');
}
}
};

View 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>&copy; {{ date('Y') }} SAM System. All rights reserved.</p>
</div>
</body>
</html>

View File

@@ -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>

View File

@@ -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

View File

@@ -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()"

View File

@@ -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');