init
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user