보안설정 수정, 모바일 최적화 변경

This commit is contained in:
2025-06-11 03:33:44 +09:00
parent d085e037a1
commit 48f1bf1c52
10 changed files with 339 additions and 141 deletions
+45 -5
View File
@@ -25,7 +25,29 @@ const io = socketIo(server, {
} }
}); });
app.use(cors()); function getDomain(origin) {
try {
const url = new URL(origin);
return url.hostname; // 'lanebow.com', 'localhost'
} catch (e) {
return null;
}
}
const allowedDomains = ['lanebow.com', 'localhost'];
app.use(cors({
origin: function(origin, callback) {
if (!origin) return callback(null, true);
const domain = getDomain(origin);
if (allowedDomains.includes(domain)) {
return callback(null, true);
} else {
return callback(new Error('Not allowed by CORS'), false);
}
},
credentials: true
}));
app.use(bodyParser.json()); app.use(bodyParser.json());
// Socket.IO 연결 처리 // Socket.IO 연결 처리
@@ -82,17 +104,35 @@ const options = {
// MySQL 세션 저장소 생성 // MySQL 세션 저장소 생성
const sessionStore = new MySQLStore(options); const sessionStore = new MySQLStore(options);
app.use(session({ const baseSessionOptions = {
key: 'bowling_session', key: 'bowling_session',
secret: process.env.JWT_SECRET, secret: process.env.JWT_SECRET,
store: sessionStore, store: sessionStore,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000,
maxAge: 24 * 60 * 60 * 1000 // 1일 secure: false,
sameSite: 'lax'
} }
})); };
app.use(session(baseSessionOptions));
// 요청마다 secure/sameSite를 동적으로 할당
app.use((req, res, next) => {
const origin = req.headers.origin;
const domain = getDomain(origin);
if (req.session && req.session.cookie) {
if (domain === 'lanebow.com') {
req.session.cookie.secure = true;
req.session.cookie.sameSite = 'none';
} else {
req.session.cookie.secure = false;
req.session.cookie.sameSite = 'lax';
}
}
next();
});
// 데이터베이스 연결 및 모델 동기화 실행 // 데이터베이스 연결 및 모델 동기화 실행
sequelize.authenticate() sequelize.authenticate()
+7 -25
View File
@@ -54,6 +54,7 @@ exports.selectClub = async (req, res) => {
} }
req.session.clubId = clubId; req.session.clubId = clubId;
console.log(`Session clubId: ${clubId}`);
res.status(200).json({ message: '클럽을 선택했습니다.' }); res.status(200).json({ message: '클럽을 선택했습니다.' });
} catch (error) { } catch (error) {
console.error('클럽 선택 오류:', error); console.error('클럽 선택 오류:', error);
@@ -136,18 +137,18 @@ exports.getClubById = async (req, res) => {
// 새 클럽 생성 // 새 클럽 생성
exports.createClub = async (req, res) => { exports.createClub = async (req, res) => {
try { try {
const { name, location, description, ownerEmail, contact, features, sendInvite } = req.body; const { name, location, description, averageCalculationPeriod } = req.body;
// 필수 필드 검증 // 필수 필드 검증
if (!name || !ownerEmail) { if (!name) {
return res.status(400).json({ message: '클럽 이름과 모임장 이메일은 필수 항목입니다.' }); return res.status(400).json({ message: '클럽 이름은 필수 항목입니다.' });
} }
// 이메일로 사용자 조회 // 이메일로 사용자 조회
const owner = await User.findOne({ where: { email: ownerEmail } }); const owner = await User.findOne({ where: { id: req.user.id } });
if (!owner) { if (!owner) {
return res.status(404).json({ message: '해당 이메일로 등록된 사용자를 찾을 수 없습니다.' }); return res.status(404).json({ message: '해당 사용자를 찾을 수 없습니다.' });
} }
// 클럽 생성 // 클럽 생성
@@ -155,7 +156,7 @@ exports.createClub = async (req, res) => {
name, name,
location, location,
description, description,
contact, averageCalculationPeriod,
ownerId: owner.id, ownerId: owner.id,
memberCount: 1, // 모임장 포함 memberCount: 1, // 모임장 포함
status: 'active' status: 'active'
@@ -172,25 +173,6 @@ exports.createClub = async (req, res) => {
status: 'active' status: 'active'
}); });
// 선택된 기능 추가
if (features && features.length > 0) {
const featurePromises = features.map(featureId =>
ClubFeature.create({
clubId: newClub.id,
featureId,
enabled: true
})
);
await Promise.all(featurePromises);
}
// 초대 이메일 발송 로직 (sendInvite가 true인 경우)
if (sendInvite) {
// TODO: 초대 이메일 발송 로직 구현
console.log(`${ownerEmail}에게 초대 이메일 발송 예정`);
}
// 베이직 1개월 플랜 자동 추가 // 베이직 1개월 플랜 자동 추가
try { try {
// 베이직 플랜 조회 (ID가 1이라고 가정, 실제 환경에 맞게 조정 필요) // 베이직 플랜 조회 (ID가 1이라고 가정, 실제 환경에 맞게 조정 필요)
+108 -105
View File
@@ -1,97 +1,99 @@
<template> <template>
<Toast /> <div id="app">
<template v-if="isRouterReady"> <AppHeader @toggle-sidebar="sidebarVisible = !sidebarVisible" />
<AppPublicLayout v-if="isPublicLayout"> <Toast />
<router-view /> <template v-if="isRouterReady">
</AppPublicLayout> <AppPublicLayout v-if="isPublicLayout">
<div v-else class="app"> <router-view />
<AppHeader /> </AppPublicLayout>
<div class="app-content"> <div v-else class="app">
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div> <div class="app-content">
<aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }"> <div v-if="sidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div>
<div class="sidebar-content"> <aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': sidebarVisible, 'mobile-hidden': !sidebarVisible && isMobileView }">
<!-- 클럽 정보 --> <div class="sidebar-content">
<div class="club-info" v-if="selectedClub"> <!-- 클럽 정보 -->
<div class="club-header"> <div class="club-info" v-if="selectedClub">
<div class="club-title"> <div class="club-header">
<h3>{{ selectedClub.name }}</h3> <div class="club-title">
<router-link v-if="userRole === 'admin' || userClubRole === '모임장'" <h3>{{ selectedClub.name }}</h3>
:to="{ name: 'ClubSettings' }" <router-link v-if="userRole === 'admin' || userClubRole === '모임장'"
class="settings-icon" :to="{ name: 'ClubSettings' }"
title="클럽 설정"> class="settings-icon"
<i class="pi pi-cog"></i> title="클럽 설정">
</router-link> <i class="pi pi-cog"></i>
</div>
<p class="club-location"><i class="pi pi-map-marker"></i> {{ selectedClub.location }}</p>
</div>
<div class="club-stats">
<div class="stat-item">
<i class="pi pi-users"></i>
<span>{{ memberCount }} </span>
</div>
<div class="stat-item">
<i class="pi pi-user"></i>
<span>모임장: {{ ownerName }}</span>
</div>
</div>
</div>
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) -->
<div v-if="isAdmin || clubs.length > 1" class="club-selector">
<label for="club-select">클럽 선택:</label>
<select id="club-select" v-model="selectedClubId" @change="onClubChange">
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
</select>
</div>
<!-- 커스텀 사이드바 메뉴 -->
<div class="custom-sidebar-menu">
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
<div class="menu-group">
<div class="menu-group-header" @click="toggleMenuGroup(groupIndex)">
<i :class="menuGroup.icon"></i>
<span>{{ menuGroup.label }}</span>
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</div>
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
<router-link
v-for="(item, itemIndex) in menuGroup.items"
:key="itemIndex"
:to="item.to"
class="menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</router-link> </router-link>
</div> </div>
<p class="club-location"><i class="pi pi-map-marker"></i> {{ selectedClub.location }}</p>
</div> </div>
</template> <div class="club-stats">
<!-- 그룹이 없는 단일 메뉴 아이템 --> <div class="stat-item">
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex"> <i class="pi pi-users"></i>
<router-link <span>{{ memberCount }} </span>
:to="menuItem.to" </div>
class="menu-item single-menu-item" <div class="stat-item">
exact <i class="pi pi-user"></i>
active-class="router-link-active" <span>모임장: {{ ownerName }}</span>
@click="closeSidebarOnMobile" </div>
> </div>
<i :class="menuItem.icon"></i> </div>
<span>{{ menuItem.label }}</span> <!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) -->
</router-link> <div v-if="isAdmin || clubs.length > 1" class="club-selector">
</template> <label for="club-select">클럽 선택:</label>
<select id="club-select" v-model="selectedClubId" @change="onClubChange">
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
</select>
</div>
<!-- 커스텀 사이드바 메뉴 -->
<div class="custom-sidebar-menu" :class="{ mobile: isMobileView }" v-show="sidebarVisible">
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
<div class="menu-group">
<div class="menu-group-header" @click="toggleMenuGroup(groupIndex)">
<i :class="menuGroup.icon"></i>
<span>{{ menuGroup.label }}</span>
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</div>
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
<router-link
v-for="(item, itemIndex) in menuGroup.items"
:key="itemIndex"
:to="item.to"
class="menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</router-link>
</div>
</div>
</template>
<!-- 그룹이 없는 단일 메뉴 아이템 -->
<div v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
<router-link
:to="menuItem.to"
class="menu-item single-menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="menuItem.icon"></i>
<span>{{ menuItem.label }}</span>
</router-link>
</div>
</div>
</div> </div>
</div> </aside>
</aside> <main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<main class="main-content" :class="{ 'full-width': !isLoggedIn }"> <router-view />
<router-view /> </main>
</main> </div>
<footer>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
</footer>
</div> </div>
<footer> </template>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p> </div>
</footer>
</div>
</template>
</template> </template>
<script setup> <script setup>
@@ -120,7 +122,7 @@ const menuItems = ref([]);
const singleMenuItems = ref([]); const singleMenuItems = ref([]);
const expandedGroups = ref({}); const expandedGroups = ref({});
const isMobileView = ref(window.innerWidth < 768); const isMobileView = ref(window.innerWidth < 768);
const isSidebarVisible = ref(!isMobileView.value); const sidebarVisible = ref(window.innerWidth > 600);
const isLoggedIn = computed(() => !!user.value && !!localStorage.getItem('token')); const isLoggedIn = computed(() => !!user.value && !!localStorage.getItem('token'));
const clubs = ref([]); // 클럽 목록 const clubs = ref([]); // 클럽 목록
const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된 클럽 ID const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된 클럽 ID
@@ -135,20 +137,18 @@ const userClubRole = computed(() => localStorage.getItem('userClubRole') || '');
// 화면 크기 변경 감지 // 화면 크기 변경 감지
const handleResize = () => { const handleResize = () => {
isMobileView.value = window.innerWidth < 768; isMobileView.value = window.innerWidth < 768;
if (!isMobileView.value) { sidebarVisible.value = window.innerWidth > 600;
isSidebarVisible.value = true;
}
}; };
// 사이드바 토글 // 사이드바 토글
const toggleSidebar = () => { const toggleSidebar = () => {
isSidebarVisible.value = !isSidebarVisible.value; sidebarVisible.value = !sidebarVisible.value;
}; };
// 사이드바 닫기 // 사이드바 닫기
const closeSidebar = () => { const closeSidebar = () => {
if (isMobileView.value) { if (isMobileView.value) {
isSidebarVisible.value = false; sidebarVisible.value = false;
} }
}; };
@@ -312,17 +312,7 @@ const loadMenuItems = async () => {
}); });
menuItems.value = groupMenus; menuItems.value = groupMenus;
/* singleMenuItems.value = singleMenus;
// 구독관리 메뉴 하드코딩으로 추가
singleMenuItems.value = [
...singleMenus,
{
label: '구독관리',
icon: 'pi pi-credit-card',
to: '/club/subscription'
}
];
*/
} else { } else {
menuItems.value = []; menuItems.value = [];
singleMenuItems.value = []; singleMenuItems.value = [];
@@ -534,6 +524,17 @@ body {
.custom-sidebar-menu { .custom-sidebar-menu {
margin-top: 20px; margin-top: 20px;
} }
.custom-sidebar-menu.mobile {
position: fixed;
left: 0;
width: 200px;
height: 100%;
z-index: 1200;
background: #fff;
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
transition: left 0.2s;
}
.menu-group-header { .menu-group-header {
display: flex; display: flex;
@@ -602,9 +603,11 @@ body {
/* 메인 컨텐츠 스타일 */ /* 메인 컨텐츠 스타일 */
.main-content { .main-content {
width: 100%;
flex: 1; flex: 1;
padding: 20px; padding: 20px;
transition: margin-left 0.3s ease; transition: margin-left 0.3s ease;
box-sizing: border-box;
} }
.main-content.full-width { .main-content.full-width {
+30 -1
View File
@@ -1,7 +1,10 @@
<template> <template>
<header class="app-header"> <header class="app-header">
<button class="sidebar-toggle" @click="$emit('toggle-sidebar')">
<i class="pi pi-bars"></i>
</button>
<div class="logo"> <div class="logo">
<img src="@/assets/LaneBowLogo.png" alt="LaneBow Logo" class="logo-img" /> <img src="@/assets/LaneBowLogo.png" alt="LaneBow Logo" class="logo-img header-logo" />
<div class="app-title">볼링 클럽 매니저</div> <div class="app-title">볼링 클럽 매니저</div>
</div> </div>
@@ -524,4 +527,30 @@ onBeforeUnmount(() => {
margin: 0.5rem; margin: 0.5rem;
} }
} }
.header-logo {
height: 60px;
transition: height 0.2s;
}
.sidebar-toggle {
display: none;
background: none;
border: none;
font-size: 2rem;
margin-right: 10px;
cursor: pointer;
}
@media (max-width: 600px) {
.header-logo {
height: 36px;
}
.app-title {
font-size: .8rem;
margin-top: -10px;
}
.sidebar-toggle {
display: block;
}
}
</style> </style>
+1
View File
@@ -10,6 +10,7 @@ const apiClient = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true,
timeout: 10000 timeout: 10000
}); });
@@ -170,4 +170,30 @@ const formatDate = (date) => {
:deep(.p-datatable-wrapper) { :deep(.p-datatable-wrapper) {
overflow-x: auto; overflow-x: auto;
} }
@media (max-width: 600px) {
.activity-log-container, .header, .dashboard-card, .subscription-management-container, .user-management-container, .club-management {
padding: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
.dashboard-card {
padding: 0.7rem !important;
font-size: 0.95rem;
}
.header h2, .dashboard-card h3 {
font-size: 1.1rem !important;
}
.p-datatable-wrapper {
overflow-x: auto !important;
}
/* Dialog 팝업 중앙 정렬 개선 */
:deep(.p-dialog) {
align-items: flex-start !important;
justify-content: center !important;
padding-top: 2.5rem !important;
}
:deep(.p-dialog .p-dialog-content) {
margin-top: 0 !important;
}
}
</style> </style>
@@ -725,4 +725,35 @@ const saveMember = async () => {
color: #666; color: #666;
line-height: 1.2; line-height: 1.2;
} }
@media (max-width: 600px) {
.club-management, .header, .dashboard-card, .activity-log-container, .subscription-management-container, .user-management-container {
padding: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
.dashboard-card {
padding: 0.7rem !important;
font-size: 0.95rem;
}
.header h2, .dashboard-card h3 {
font-size: 1.1rem !important;
}
.features-grid, .plan-details {
grid-template-columns: 1fr !important;
flex-direction: column !important;
gap: 0.7rem !important;
}
.p-datatable-wrapper {
overflow-x: auto !important;
}
/* Dialog 팝업 중앙 정렬 개선 */
:deep(.p-dialog) {
align-items: flex-start !important;
justify-content: center !important;
padding-top: 2.5rem !important;
}
:deep(.p-dialog .p-dialog-content) {
margin-top: 0 !important;
}
}
</style> </style>
+26
View File
@@ -303,4 +303,30 @@ const navigateTo = (path) => {
:deep(.p-datatable .p-datatable-tbody > tr) { :deep(.p-datatable .p-datatable-tbody > tr) {
background-color: transparent; background-color: transparent;
} }
@media (max-width: 600px) {
.admin-dashboard, .header, .dashboard-card, .activity-log-container, .subscription-management-container, .user-management-container, .club-management {
padding: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
.dashboard-card {
padding: 0.7rem !important;
font-size: 0.95rem;
}
.header h2, .dashboard-card h3 {
font-size: 1.1rem !important;
}
.p-datatable-wrapper {
overflow-x: auto !important;
}
/* Dialog 팝업 중앙 정렬 개선 */
:deep(.p-dialog) {
align-items: flex-start !important;
justify-content: center !important;
padding-top: 2.5rem !important;
}
:deep(.p-dialog .p-dialog-content) {
margin-top: 0 !important;
}
}
</style> </style>
@@ -16,10 +16,9 @@
dataKey="id" dataKey="id"
@row-expand="onPlanExpand" @row-expand="onPlanExpand"
> >
<Column :expander="true" style="width: 3%"/> <Column :expander="true" style="width: 1%"/>
<Column field="id" header="ID" sortable style="width: 5%"></Column> <Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="플랜명" sortable style="width: 15%"></Column> <Column field="name" header="플랜명" sortable></Column>
<Column field="description" header="설명" style="width: 25%"></Column>
<Column field="maxMembers" header="최대 회원수" sortable style="width: 10%"> <Column field="maxMembers" header="최대 회원수" sortable style="width: 10%">
<template #body="slotProps"> <template #body="slotProps">
{{ slotProps.data.maxMembers }} {{ slotProps.data.maxMembers }}
@@ -30,7 +29,7 @@
{{ formatCurrency(slotProps.data.price) }} {{ formatCurrency(slotProps.data.price) }}
</template> </template>
</Column> </Column>
<Column field="status" header="상태" sortable style="width: 10%"> <Column field="status" header="상태" sortable style="width: 15%">
<template #body="slotProps"> <template #body="slotProps">
<Badge :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" /> <Badge :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template> </template>
@@ -45,6 +44,10 @@
</Column> </Column>
<template #expansion="slotProps"> <template #expansion="slotProps">
<div class="plan-details"> <div class="plan-details">
<div class="plan-description">
<h4>설명</h4>
<p>{{ slotProps.data.description }}</p>
</div>
<div class="plan-features"> <div class="plan-features">
<h4>포함된 기능</h4> <h4>포함된 기능</h4>
<div class="features-grid"> <div class="features-grid">
@@ -603,4 +606,35 @@ const formatDate = (date) => {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
} }
@media (max-width: 600px) {
.subscription-management-container, .header, .dashboard-card, .activity-log-container, .user-management-container, .club-management {
padding: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
.dashboard-card {
padding: 0.7rem !important;
font-size: 0.95rem;
}
.header h2, .dashboard-card h3 {
font-size: 1.1rem !important;
}
.features-grid, .plan-details {
grid-template-columns: 1fr !important;
flex-direction: column !important;
gap: 0.7rem !important;
}
.p-datatable-wrapper {
overflow-x: auto !important;
}
/* Dialog 팝업 중앙 정렬 개선 */
:deep(.p-dialog) {
align-items: flex-start !important;
justify-content: center !important;
padding-top: 2.5rem !important;
}
:deep(.p-dialog .p-dialog-content) {
margin-top: 0 !important;
}
}
</style> </style>
@@ -294,4 +294,30 @@ const formatDate = (date) => {
:deep(.p-password-input) { :deep(.p-password-input) {
width: 100%; width: 100%;
} }
@media (max-width: 600px) {
.user-management-container, .header, .dashboard-card, .activity-log-container, .subscription-management-container, .club-management {
padding: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
.dashboard-card {
padding: 0.7rem !important;
font-size: 0.95rem;
}
.header h2, .dashboard-card h3 {
font-size: 1.1rem !important;
}
.p-datatable-wrapper {
overflow-x: auto !important;
}
/* Dialog 팝업 중앙 정렬 개선 */
:deep(.p-dialog) {
align-items: flex-start !important;
justify-content: center !important;
padding-top: 2.5rem !important;
}
:deep(.p-dialog .p-dialog-content) {
margin-top: 0 !important;
}
}
</style> </style>