This commit is contained in:
2025-04-17 23:32:39 +09:00
commit 5b719b6766
105 changed files with 25653 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="login-container">
<div class="login-card">
<h2>볼링 클럽 매니저 로그인</h2>
<form @submit.prevent="handleLogin" v-if="!showRegisterForm">
<div class="form-group">
<label for="email">이메일</label>
<InputText id="email" v-model="email" class="w-full" :class="{'p-invalid': emailError}" />
<small v-if="emailError" class="p-error">{{ emailError }}</small>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<InputText id="password" v-model="password" type="password" class="w-full" :class="{'p-invalid': passwordError}" />
<small v-if="passwordError" class="p-error">{{ passwordError }}</small>
</div>
<div class="form-options">
<div class="remember-me">
<Checkbox v-model="rememberMe" :binary="true" inputId="rememberMe" />
<label for="rememberMe" class="ml-2">로그인 상태 유지</label>
</div>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<Button type="submit" label="로그인" class="w-full" :loading="loading" />
<div class="register-link">
<p>계정이 없으신가요? <a href="#" @click.prevent="showRegisterForm = true">회원가입</a></p>
</div>
</form>
<!-- 회원가입 -->
<form @submit.prevent="handleRegister" v-if="showRegisterForm">
<h3>회원가입</h3>
<div class="form-group">
<label for="registerName">이름</label>
<InputText id="registerName" v-model="registerName" class="w-full" :class="{'p-invalid': registerNameError}" />
<small v-if="registerNameError" class="p-error">{{ registerNameError }}</small>
</div>
<div class="form-group">
<label for="registerEmail">이메일</label>
<InputText id="registerEmail" v-model="registerEmail" class="w-full" :class="{'p-invalid': registerEmailError}" />
<small v-if="registerEmailError" class="p-error">{{ registerEmailError }}</small>
</div>
<div class="form-group">
<label for="registerPassword">비밀번호</label>
<Password id="registerPassword" v-model="registerPassword" :feedback="true" toggleMask
:class="{'p-invalid': registerPasswordError}"
:promptLabel="'비밀번호 강도'"
:weakLabel="'약함'"
:mediumLabel="'중간'"
:strongLabel="'강함'"
class="w-full" />
<small v-if="registerPasswordError" class="p-error">{{ registerPasswordError }}</small>
</div>
<div class="form-group">
<label for="registerPasswordConfirm">비밀번호 확인</label>
<Password id="registerPasswordConfirm" v-model="registerPasswordConfirm" toggleMask
:class="{'p-invalid': registerPasswordConfirmError}"
:feedback="false"
class="w-full" />
<small v-if="registerPasswordConfirmError" class="p-error">{{ registerPasswordConfirmError }}</small>
</div>
<div class="form-group">
<label for="registerPhone">전화번호 (선택)</label>
<InputText id="registerPhone" v-model="registerPhone" class="w-full" />
</div>
<Button type="submit" label="회원가입" class="w-full" :loading="loading" />
<div class="login-link">
<p>이미 계정이 있으신가요? <a href="#" @click.prevent="showRegisterForm = false">로그인</a></p>
</div>
</form>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import authService from '@/services/authService';
const user = inject('user');
const router = useRouter();
const toast = useToast();
const email = ref('');
const password = ref('');
const rememberMe = ref(false);
const loading = ref(false);
const emailError = ref('');
const passwordError = ref('');
// 회원가입 관련 상태
const showRegisterForm = ref(false);
const registerName = ref('');
const registerEmail = ref('');
const registerPassword = ref('');
const registerPasswordConfirm = ref('');
const registerPhone = ref('');
const registerNameError = ref('');
const registerEmailError = ref('');
const registerPasswordError = ref('');
const registerPasswordConfirmError = ref('');
// 비밀번호 강도 검사
const passwordHasMinLength = computed(() => registerPassword.value.length >= 8);
const passwordHasUppercase = computed(() => /[A-Z]/.test(registerPassword.value));
const passwordHasLowercase = computed(() => /[a-z]/.test(registerPassword.value));
const passwordHasNumber = computed(() => /[0-9]/.test(registerPassword.value));
const passwordHasSpecial = computed(() => /[!@#$%^&*(),.?":{}|<>]/.test(registerPassword.value));
const validateForm = () => {
let isValid = true;
// 이메일 유효성 검사
if (!email.value) {
emailError.value = '이메일을 입력해주세요.';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
emailError.value = '유효한 이메일 주소를 입력해주세요.';
isValid = false;
} else {
emailError.value = '';
}
// 비밀번호 유효성 검사
if (!password.value) {
passwordError.value = '비밀번호를 입력해주세요.';
isValid = false;
} else {
passwordError.value = '';
}
return isValid;
};
// 회원가입 폼 유효성 검사
const validateRegisterForm = () => {
let isValid = true;
// 이름 유효성 검사
if (!registerName.value) {
registerNameError.value = '이름을 입력해주세요.';
isValid = false;
} else {
registerNameError.value = '';
}
// 이메일 유효성 검사
if (!registerEmail.value) {
registerEmailError.value = '이메일을 입력해주세요.';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerEmail.value)) {
registerEmailError.value = '유효한 이메일 주소를 입력해주세요.';
isValid = false;
} else {
registerEmailError.value = '';
}
// 비밀번호 유효성 검사
if (!registerPassword.value) {
registerPasswordError.value = '비밀번호를 입력해주세요.';
isValid = false;
} else if (registerPassword.value.length < 8) {
registerPasswordError.value = '비밀번호는 최소 8자 이상이어야 합니다.';
isValid = false;
} else if (!passwordHasUppercase.value || !passwordHasLowercase.value ||
!passwordHasNumber.value || !passwordHasSpecial.value) {
registerPasswordError.value = '비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다.';
isValid = false;
} else {
registerPasswordError.value = '';
}
// 비밀번호 확인 유효성 검사
if (!registerPasswordConfirm.value) {
registerPasswordConfirmError.value = '비밀번호 확인을 입력해주세요.';
isValid = false;
} else if (registerPassword.value !== registerPasswordConfirm.value) {
registerPasswordConfirmError.value = '비밀번호가 일치하지 않습니다.';
isValid = false;
} else {
registerPasswordConfirmError.value = '';
}
return isValid;
};
// 로그인 처리 함수
const handleLogin = async () => {
if (!validateForm()) return;
loading.value = true;
try {
const response = await authService.login({
email: email.value,
password: password.value
});
// 토큰 저장
const { token, user: userData } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('userRole', userData.role);
localStorage.setItem('clubId', userData.clubId);
localStorage.setItem('userClubRole', userData.memberType || '');
// 사용자 정보 저장
user.value = userData;
// 로그인 성공 메시지
toast.add({ severity: 'success', summary: '성공', detail: '로그인에 성공했습니다.', life: 3000 });
// 사용자 정보 및 메뉴 아이템 로드 이벤트 발생
window.dispatchEvent(new CustomEvent('user-logged-in'));
// 사용자 역할에 따라 리디렉션
if (userData.role === 'admin' || userData.role === 'superadmin') {
router.push('/admin');
} else {
router.push('/club');
}
} catch (error) {
console.error('로그인 오류:', error);
// 오류 메시지 처리
const errorMessage = error.response?.data?.message || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
// 회원가입 처리 함수
const handleRegister = async () => {
if (!validateRegisterForm()) return;
loading.value = true;
try {
await authService.register({
name: registerName.value,
email: registerEmail.value,
password: registerPassword.value,
phone: registerPhone.value
});
// 회원가입 성공 메시지
toast.add({ severity: 'success', summary: '성공', detail: '회원가입에 성공했습니다. 로그인해주세요.', life: 3000 });
// 로그인 폼으로 전환
showRegisterForm.value = false;
// 입력한 이메일을 로그인 폼에 자동 입력
email.value = registerEmail.value;
password.value = '';
// 회원가입 폼 초기화
registerName.value = '';
registerEmail.value = '';
registerPassword.value = '';
registerPasswordConfirm.value = '';
registerPhone.value = '';
} catch (error) {
console.error('회원가입 오류:', error);
// 오류 메시지 처리
const errorMessage = error.response?.data?.message || '회원가입에 실패했습니다. 다시 시도해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: white;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.remember-me {
display: flex;
align-items: center;
}
.forgot-password {
color: #2196F3;
text-decoration: none;
font-size: 0.875rem;
}
.forgot-password:hover {
text-decoration: underline;
}
.register-link, .login-link {
text-align: center;
margin-top: 1.5rem;
}
.register-link a, .login-link a {
color: #2196F3;
text-decoration: none;
font-weight: 500;
}
.register-link a:hover, .login-link a:hover {
text-decoration: underline;
}
.ml-2 {
margin-left: 0.5rem;
}
.w-full {
width: 100%;
}
:deep(.p-password-input) {
width: 100%;
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="profile-container">
<div class="card">
<h2> 프로필</h2>
<div class="profile-content">
<div class="profile-header">
<div class="profile-avatar">
<Avatar :image="user.avatar || null" :label="getInitials(user.name)" size="xlarge" shape="circle" />
</div>
<div class="profile-info">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<p><Badge :value="user.role === 'admin' ? '관리자' : '일반 사용자'" :severity="user.role === 'admin' ? 'success' : 'info'" /></p>
</div>
</div>
<Divider />
<div class="profile-form">
<form @submit.prevent="updateProfile">
<div class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="form.name" :class="{'p-invalid': v$.name.$invalid && submitted}" />
<small v-if="v$.name.$invalid && submitted" class="p-error">{{ v$.name.$errors[0].$message }}</small>
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="form.email" :class="{'p-invalid': v$.email.$invalid && submitted}" />
<small v-if="v$.email.$invalid && submitted" class="p-error">{{ v$.email.$errors[0].$message }}</small>
</div>
<Divider />
<div class="field">
<label for="currentPassword">현재 비밀번호 (변경 시에만 입력)</label>
<Password id="currentPassword" v-model="form.currentPassword" :feedback="false" toggleMask />
</div>
<div class="field">
<label for="newPassword"> 비밀번호 (변경 시에만 입력)</label>
<Password id="newPassword" v-model="form.newPassword" :class="{'p-invalid': v$.newPassword.$invalid && passwordChangeAttempted}" toggleMask />
<small v-if="v$.newPassword.$invalid && passwordChangeAttempted" class="p-error">{{ v$.newPassword.$errors[0].$message }}</small>
</div>
<div class="field">
<label for="confirmPassword">비밀번호 확인</label>
<Password id="confirmPassword" v-model="form.confirmPassword" :class="{'p-invalid': v$.confirmPassword.$invalid && passwordChangeAttempted}" :feedback="false" toggleMask />
<small v-if="v$.confirmPassword.$invalid && passwordChangeAttempted" class="p-error">{{ v$.confirmPassword.$errors[0].$message }}</small>
</div>
</div>
<div class="button-container">
<Button type="submit" label="프로필 업데이트" :loading="loading" />
</div>
</form>
</div>
</div>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength, sameAs, helpers } from '@vuelidate/validators';
import axios from 'axios';
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
// 상태 변수
const loading = ref(false);
const submitted = ref(false);
const passwordChangeAttempted = ref(false);
// 사용자 정보
const user = reactive({
id: null,
name: '',
email: '',
role: '',
avatar: null
});
// 폼 데이터
const form = reactive({
name: '',
email: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
// 유효성 검사 규칙
const rules = computed(() => ({
name: { required: helpers.withMessage('이름을 입력해주세요.', required) },
email: {
required: helpers.withMessage('이메일을 입력해주세요.', required),
email: helpers.withMessage('유효한 이메일 주소를 입력해주세요.', email)
},
newPassword: {
minLength: helpers.withMessage('비밀번호는 최소 8자 이상이어야 합니다.', minLength(8))
},
confirmPassword: {
sameAsPassword: helpers.withMessage('비밀번호가 일치하지 않습니다.', sameAs(form.newPassword))
}
}));
const v$ = useVuelidate(rules, form);
// 페이지 로드 시 사용자 정보 가져오기
onMounted(async () => {
await fetchUserProfile();
});
// 사용자 프로필 가져오기
const fetchUserProfile = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`${API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
});
// 사용자 정보 설정
Object.assign(user, response.data);
// 폼 데이터 초기화
form.name = user.name;
form.email = user.email;
} catch (error) {
console.error('사용자 프로필을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 프로필을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 이니셜 가져오기 (아바타용)
const getInitials = (name) => {
if (!name) return '';
return name.split(' ')
.map(part => part.charAt(0))
.join('')
.toUpperCase();
};
// 프로필 업데이트
const updateProfile = async () => {
submitted.value = true;
const profileResult = await v$.value.$validate();
if (!profileResult) return;
loading.value = true;
try {
const token = localStorage.getItem('token');
const profileData = {
name: form.name,
email: form.email
};
// 비밀번호 변경이 요청된 경우
if (form.currentPassword && form.newPassword) {
passwordChangeAttempted.value = true;
if (form.newPassword !== form.confirmPassword) {
toast.add({ severity: 'error', summary: '오류', detail: '새 비밀번호가 일치하지 않습니다.', life: 3000 });
return;
}
profileData.currentPassword = form.currentPassword;
profileData.password = form.newPassword;
}
await axios.put(`${API_URL}/auth/profile`, profileData, {
headers: { Authorization: `Bearer ${token}` }
});
// 사용자 정보 업데이트
Object.assign(user, {
name: profileData.name,
email: profileData.email
});
// 비밀번호 필드 초기화
form.currentPassword = '';
form.newPassword = '';
form.confirmPassword = '';
passwordChangeAttempted.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '프로필이 성공적으로 업데이트되었습니다.', life: 3000 });
} catch (error) {
console.error('프로필 업데이트 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: error.response?.data?.message || '프로필 업데이트 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #ffffff;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
}
.profile-content {
margin-top: 2rem;
}
.profile-header {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.profile-info {
h3 {
margin: 0;
margin-bottom: 0.5rem;
}
p {
margin: 0;
margin-bottom: 0.5rem;
color: #666;
}
}
.profile-form {
margin-top: 2rem;
.field {
margin-bottom: 1.5rem;
}
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
</style>