feat: 프로필 설정 페이지 추가

- 프로필 정보 수정 (이름, 전화번호)
- 비밀번호 변경 기능
- 헤더 드롭다운 메뉴 연결

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 23:12:59 +09:00
parent 273ac6caf6
commit d992a19735
7 changed files with 442 additions and 4 deletions

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Services\ProfileService;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function __construct(
private readonly ProfileService $profileService
) {}
/**
* 프로필 페이지
*/
public function index(): View
{
return view('profile.index');
}
/**
* 프로필 정보 수정
*/
public function update(UpdateProfileRequest $request): JsonResponse
{
$user = auth()->user();
$result = $this->profileService->updateProfile($user, $request->validated());
if ($result) {
return response()->json([
'success' => true,
'message' => '프로필이 수정되었습니다.',
]);
}
return response()->json([
'success' => false,
'message' => '프로필 수정에 실패했습니다.',
], 500);
}
/**
* 비밀번호 변경
*/
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
$user = auth()->user();
$result = $this->profileService->changePassword(
$user,
$request->current_password,
$request->password
);
return response()->json($result, $result['success'] ? 200 : 422);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ChangePasswordRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'current_password' => 'required|string',
'password' => 'required|string|min:8|confirmed',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'current_password' => '현재 비밀번호',
'password' => '새 비밀번호',
'password_confirmation' => '새 비밀번호 확인',
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'password.confirmed' => '새 비밀번호가 일치하지 않습니다.',
'password.min' => '비밀번호는 최소 8자 이상이어야 합니다.',
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProfileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'phone' => 'nullable|string|max:20',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => '이름',
'phone' => '연락처',
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class ProfileService
{
/**
* 프로필 정보 수정 (이름, 전화번호)
*/
public function updateProfile(User $user, array $data): bool
{
$user->name = $data['name'];
$user->phone = $data['phone'] ?? null;
$user->updated_by = $user->id;
return $user->save();
}
/**
* 비밀번호 변경
*/
public function changePassword(User $user, string $currentPassword, string $newPassword): array
{
// 현재 비밀번호 확인
if (! Hash::check($currentPassword, $user->password)) {
return [
'success' => false,
'message' => '현재 비밀번호가 일치하지 않습니다.',
];
}
// 새 비밀번호가 현재 비밀번호와 동일한지 확인
if (Hash::check($newPassword, $user->password)) {
return [
'success' => false,
'message' => '새 비밀번호는 현재 비밀번호와 다르게 설정해주세요.',
];
}
// 비밀번호 업데이트
$user->password = Hash::make($newPassword);
$user->updated_by = $user->id;
$user->save();
return [
'success' => true,
'message' => '비밀번호가 변경되었습니다.',
];
}
}

View File

@@ -86,12 +86,9 @@ class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover
</div>
<!-- Menu Items -->
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<a href="{{ route('profile.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
프로필 설정
</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
계정 설정
</a>
<div class="border-t border-gray-200 my-1"></div>

View File

@@ -0,0 +1,222 @@
@extends('layouts.app')
@section('title', '프로필 설정')
@section('content')
<div class="container mx-auto max-w-2xl">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">프로필 설정</h1>
</div>
<!-- 기본 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b flex items-center gap-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
기본 정보
</h2>
<form id="profileForm">
<div class="space-y-4">
<!-- 사용자 ID (읽기 전용) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
사용자 ID
</label>
<input type="text" value="{{ auth()->user()->user_id ?? '-' }}" readonly
class="w-full px-4 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
</div>
<!-- 이름 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
이름 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" required maxlength="100"
value="{{ auth()->user()->name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 이메일 (읽기 전용) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
이메일
</label>
<input type="email" value="{{ auth()->user()->email }}" readonly
class="w-full px-4 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
</div>
<!-- 연락처 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
연락처
</label>
<input type="text" name="phone" maxlength="20"
value="{{ auth()->user()->phone }}"
placeholder="010-1234-5678"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- 저장 버튼 -->
<div class="flex justify-end mt-6">
<button type="submit" id="profileSubmitBtn"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
저장
</button>
</div>
</form>
</div>
<!-- 비밀번호 변경 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b flex items-center gap-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
비밀번호 변경
</h2>
<form id="passwordForm">
<div class="space-y-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="current_password" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</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" required minlength="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"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- 비밀번호 변경 버튼 -->
<div class="flex justify-end mt-6">
<button type="submit" id="passwordSubmitBtn"
class="px-6 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition">
비밀번호 변경
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
// 프로필 정보 수정
document.getElementById('profileForm').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('profileSubmitBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '저장 중...';
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('{{ route("profile.update") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
} else {
alert('오류: ' + (data.message || '프로필 수정에 실패했습니다.'));
}
})
.catch(error => {
console.error(error);
if (error.errors) {
let errorMsg = '입력 오류:\n';
for (let field in error.errors) {
errorMsg += '- ' + error.errors[field].join('\n') + '\n';
}
alert(errorMsg);
} else {
alert('서버 오류가 발생했습니다.');
}
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalText;
});
});
// 비밀번호 변경
document.getElementById('passwordForm').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('passwordSubmitBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '변경 중...';
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('{{ route("profile.password") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
this.reset(); // 폼 초기화
} else {
alert('오류: ' + (data.message || '비밀번호 변경에 실패했습니다.'));
}
})
.catch(error => {
console.error(error);
if (error.errors) {
let errorMsg = '입력 오류:\n';
for (let field in error.errors) {
errorMsg += '- ' + error.errors[field].join('\n') + '\n';
}
alert(errorMsg);
} else {
alert('서버 오류가 발생했습니다.');
}
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalText;
});
});
</script>
@endpush

View File

@@ -11,6 +11,7 @@
use App\Http\Controllers\ProjectManagementController;
use App\Http\Controllers\RoleController;
use App\Http\Controllers\RolePermissionController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\TenantController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
@@ -40,6 +41,13 @@
// 테넌트 전환
Route::post('/tenant/switch', [TenantController::class, 'switch'])->name('tenant.switch');
// 프로필 설정
Route::prefix('profile')->name('profile.')->group(function () {
Route::get('/', [ProfileController::class, 'index'])->name('index');
Route::post('/update', [ProfileController::class, 'update'])->name('update');
Route::post('/password', [ProfileController::class, 'changePassword'])->name('password');
});
// 테넌트 관리 (Blade 화면만)
Route::prefix('tenants')->name('tenants.')->group(function () {
Route::get('/', [TenantController::class, 'index'])->name('index');