浏览代码

파비콘 업데이트, 사용자 계정 관련(로그인/로그아웃/아이디/비번찾기) 기능 완료

DESKTOP-T61HUSC\user 21 小时之前
父节点
当前提交
de48c833b8

+ 7 - 12
app/app.vue

@@ -1,7 +1,10 @@
 <template>
   <UApp>
     <AdminLoadingOverlay />
+    <!-- 메인 페이지 헤더 -->
     <component :is="DynamicHeader" v-if="!isAdminPage && isIndexPage" />
+    <!-- 서브 페이지 헤더 -->
+    <component :is="DynamicSubHeader" v-if="!isAdminPage && !isIndexPage" />
     <NuxtLayout>
       <NuxtPage />
     </NuxtLayout>
@@ -29,10 +32,7 @@
 
   // 파비콘 동적 설정
   const favicon = computed(() => {
-    if (route.path.includes("/lincoln")) {
-      return "/favicon-lincoln.ico";
-    }
-    return "/favicon-ford.ico";
+    return "/favicon.ico";
   });
 
   // 타이틀 동적 설정
@@ -51,17 +51,12 @@
     ],
   }));
 
-  // 파비콘 강제 업데이트 (브라우저 캐시 우회)
-  watch(favicon, (newFavicon) => {
-    const link = document.querySelector("link[rel='icon']");
-    if (link) {
-      link.href = newFavicon + "?v=" + Date.now();
-    }
-  });
-
   // 동적 Header 컴포넌트
   const DynamicHeader = defineAsyncComponent(() => import(`~/components/header.vue`));
 
+  // 동적 Header 컴포넌트
+  const DynamicSubHeader = defineAsyncComponent(() => import(`~/components/subHeader.vue`));
+
   // 동적 Footer 컴포넌트
   const DynamicFooter = defineAsyncComponent(() => import(`~/components/footer.vue`));
 </script>

+ 0 - 265
app/assets/scss/admin.scss

@@ -761,271 +761,6 @@ html {
 |end
 =================================================*/
 
-
-header {
-  position: sticky;
-  top: 0px;
-  z-index: 1000;
-  background-color: #ffffff;
-  border-bottom: 1px solid #E9E9E9;
-
-  .header--wrap {
-    max-width: 1440px;
-    // padding:25px 0px;
-    background-color: #fff;
-    align-items: center;
-    margin: 0 auto;
-    display: flex;
-    justify-content: space-between;
-    position: relative;
-    z-index: 1001;
-
-    nav {
-      display: flex;
-      align-items: center;
-
-      >ul {
-        display: flex;
-        align-items: center;
-
-        >li {
-          width: 200px;
-          white-space: nowrap;
-          height: 90px;
-          display: flex;
-          position: relative;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-
-          >a {
-            width: 100%;
-            color: #222;
-            text-align: center;
-            // padding: 0 60px;
-            display: inline-block;
-            font-size: 16px;
-            font-style: normal;
-            font-weight: 700 !important;
-            text-transform: uppercase;
-            transition: color 0.3s;
-
-            &:hover {
-              color: #076FED;
-            }
-          }
-
-
-          &:hover {
-            .sub--menu--wrap {
-              max-height: 550px;
-            }
-          }
-
-          .sub--menu--wrap {
-            max-height: 0px;
-            overflow: hidden;
-            transition: max-height 0.3s;
-            background-color: #fff;
-            top: 90.5px;
-
-            &.sub1 {
-              position: fixed;
-              border-bottom-left-radius: 30px;
-              border-bottom-right-radius: 30px;
-              max-width: 1440px;
-              left: 50%;
-              transform: translateX(-50%);
-              width: 100%;
-
-              >div {
-                padding: 60px 0;
-                display: flex;
-                justify-content: center;
-                align-items: center;
-                gap: 70px;
-
-                &.active {
-                  max-height: 300px;
-                }
-
-                li {
-                  a {
-                    display: flex;
-                    gap: 25px;
-                    flex-direction: column;
-                    max-width: 180px;
-                    text-align: center;
-                    color: #000;
-                    font-size: 16px;
-                    font-weight: 500;
-                    text-transform: uppercase;
-
-                    img {
-                      transition: all 0.3s;
-                    }
-
-                    &:hover {
-                      color: #076fed;
-
-                      img {
-                        transform: scale(1.1);
-                      }
-                    }
-                  }
-                }
-              }
-            }
-
-            &.sub2 {
-              position: absolute;
-              border-bottom-left-radius: 20px;
-              border-bottom-right-radius: 20px;
-              width: 280px;
-              left: calc(50% - 140px);
-
-              >div {
-                padding: 20px 40px;
-                display: flex;
-                flex-direction: column;
-                //height: 550px;
-                line-height: 1;
-
-                li {
-                  a {
-                    cursor: pointer;
-                    color: #000;
-                    padding: 20px 0;
-                    font-size: 16px;
-                    font-weight: 500;
-                    display: flex;
-                    justify-content: space-between;
-                    align-items: center;
-                    transition: color 0.3s;
-
-                    &:hover {
-                      color: #076fed;
-                    }
-                  }
-                }
-              }
-            }
-
-            &.sub3 {
-              position: absolute;
-              border-bottom-left-radius: 20px;
-              border-bottom-right-radius: 20px;
-              width: 280px;
-              left: calc(50% - 140px);
-              //left: -32px;
-              transition: all 0.3s;
-
-              &:has(.active) {
-                width: 630px;
-              }
-
-              &.h--200 {
-                >div {
-                  height: 320px;
-                }
-              }
-
-              &.h--400 {
-                >div {
-                  height: 432px;
-                }
-              }
-
-              >div {
-                height: 375px;
-                // max-height: 550px;
-                background-color: #fff;
-                position: relative;
-
-                .sub--menu--left {
-                  width: 280px;
-                  padding: 20px 40px;
-                  line-height: 1;
-
-                  li {
-                    padding: 20px 0;
-                    cursor: pointer;
-                    color: #000;
-                    font-size: 16px;
-                    font-weight: 500;
-                    display: flex;
-                    justify-content: space-between;
-                    align-items: center;
-                    transition: color 0.3s;
-
-                    &:hover,
-                    &.active {
-                      color: #076fed;
-
-                      .ico {
-                        background-image: url(/img/ico--header--arrow--blue.svg);
-                      }
-                    }
-
-                    .ico {
-                      width: 18px;
-                      background-image: url(/img/ico--header--arrow.svg);
-                      height: 18px;
-                    }
-                  }
-                }
-
-                .sub--menu--right {
-                  position: absolute;
-                  left: 280px;
-                  border-left: 1px solid #E5E5E5;
-                  width: 350px;
-                  padding: 40px 35px;
-                  height: 100%;
-                  top: 0;
-
-                  >div {
-                    opacity: 0;
-                    transition: opacity 0.3s;
-                    display: none;
-                    pointer-events: none;
-                    flex-direction: column;
-                    gap: 40px;
-
-                    li {
-                      line-height: 1;
-
-                      a {
-                        display: inline-block;
-                        color: #333;
-                        font-size: 16px;
-                        font-weight: 400;
-                        width: 100%;
-                        transition: all 0.3s;
-
-                        &:hover {
-                          color: #076fed;
-                        }
-                      }
-                    }
-
-                    &.active {
-                      display: flex;
-                      pointer-events: all;
-                      opacity: 1;
-                    }
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-
-}
-
 // ============================================
 // Admin Panel Dark Theme Styles
 // ============================================

+ 376 - 5
app/assets/scss/style.scss

@@ -181,10 +181,13 @@ textarea {
     .float--btn--wrap{
         position: fixed;
         bottom: 32px;
-        padding: 0 25px;
+        padding: 0 20px;
         left: 0;
         width: 100%;
         z-index: 100;
+        display: flex;
+        flex-direction: column;
+        gap: 12px;
         a{
             display: flex;
             justify-content: center;
@@ -199,9 +202,14 @@ textarea {
             font-weight: 700;
             width: 100%;
             &.disabled{
-                color: #9aa0ac;
-                pointer-events: none;
-                background-color: #e5e7eb;
+                color: #9aa0ac!important;
+                pointer-events: none!important;
+                background-color: #e5e7eb!important;
+            }
+            &.border--btn{
+                background-color: #fff;
+                color: #2f6fe0;
+                border: 1.5px solid #2f6fe0;
             }
         }
     }
@@ -232,7 +240,123 @@ textarea {
             font-weight: 400;
         }
     }
+    .find--tab--wrap{
+        display: flex;
+        button{
+            width: 50%;
+            padding: 12px 0;
+            font-size: 15px;
+            color: #9aa0ac;
+            border-bottom: 1px solid #eceef1;
+            font-weight: 500;
+            &.active{
+                font-weight: 700;
+                border-radius: 2px;
+                border-bottom-color: #2f6fe0;
+                border-bottom-width: 2px;
+                color: #1a1d29;
+            }
+        }
+    }
     .login--wrap{
+        .desc--p{
+            color: #8a8f9a;
+            font-size: 13px;
+            font-weight: 400;
+        }
+        .label--p{
+            color: #1a1d29;
+            font-size: 14px;
+            font-weight: 700;
+        }
+        .change--pw--box{
+            padding: 16px;
+            border-radius: 12px;
+            border: 1px solid #f0e4be;
+            background-color: #fffbef;
+            display: flex;
+            flex-direction: column;
+            gap: 6px;
+            span{
+                color: #1a1d29;
+                font-size: 13px;
+                font-weight: 700;
+            }
+            li{
+                color: #8a8f9a;
+                font-size: 12px;
+                font-weight: 400;
+                position: relative;
+                padding-left: 12px;
+                &::before{
+                    position: absolute;
+                    border-radius: 50%;
+                    top: calc(50% - 2px);
+                    left: 0;
+                    content: '';
+                    width: 4px;
+                    height: 4px;
+                    background-color: #8a8f9a;
+                }
+            }
+        }
+        .find--social--box{
+            padding: 16px;
+            border-radius: 12px;
+            border: 1px solid #f0e4be;
+            background-color: #fffbef;
+            display: flex;
+            flex-direction: column;
+            gap: 6px;
+            span{
+                color: #1a1d29;
+                font-size: 13px;
+                font-weight: 700;
+            }   
+            p{
+                color: #8a8f9a;
+                font-size: 12px;
+                font-weight: 400;
+            }
+        }
+        .find--result--wrap{
+            display: flex;
+            flex-direction: column;
+            line-height: 1;
+            border-radius: 16px;
+            border: 1px solid #e8edf3;
+            background-color: #f7f9fc;
+            padding: 24px 20px;
+            position: relative;
+            .result--label{
+                margin-bottom: 8px;
+                color: #8a8f9a;
+                font-weight: 500;
+                font-size: 12px;
+            }
+            .result--bubble{
+                position: absolute;
+                right: 20px;
+                top: 20px;
+                border-radius: 12px;
+                background-color: #eaf1fe;
+                padding: 4px 10px;
+                color: #2f6fe0;
+                font-size: 11px;
+                font-weight: 600;
+            }
+            .result--txt{
+                color: #1a1d29;
+                font-size: 24px;
+                margin-bottom: 12px;
+                font-weight: 800;
+            }
+            .result--date{
+                color: #8a8f9a;
+                font-size: 12px;
+                font-weight: 400;
+            }
+        }
         .auto--login--wrap{
             display: flex;
             justify-content: space-between;
@@ -276,6 +400,8 @@ textarea {
         }
         .login--btn--wrap{
             display: flex;
+            flex-direction: column;
+            gap: 12px;
             justify-content: center;
             button{
                 width: 100%;
@@ -318,7 +444,7 @@ textarea {
                 gap: 12px;
                 li{
                     display: flex;
-                    width: 33.3333%;
+                    width: 100%;
                     align-items: center;
                     justify-content: center;
                     a{
@@ -658,6 +784,27 @@ textarea {
     background-color: #fff;
     z-index: 100;
     padding: 0 20px;
+    .sub--header--wrap{
+        display: flex;
+        height: 100%;
+        align-items: center;
+        justify-content: center;
+        position: relative;
+        padding: 0 24px;
+        .back--btn{
+            position: absolute;
+            left: 0;
+            width: 24px;
+            height: 24px;
+            background-image: url(/img/ico--back.svg);
+        }
+        h2{
+            color: #1a1d29;
+            font-size: 16px;
+            font-weight: 700;
+            line-height: 1;
+        }
+    }
     .header--wrap{
         display: flex;
         align-items: center;
@@ -725,9 +872,233 @@ textarea {
 
     .home--container{
         padding: 20px 20px 25px 20px;
+        display: flex;
+        flex-direction: column;
+        gap: 14px;
+        .my--part--wrap{
+            border-radius: 18px;
+            background: #2F6FE0;
+            box-shadow: 0 4px 16px 0 rgba(46, 110, 224, 0.26);
+            padding: 18px;
+            display: flex;
+            flex-direction: column;
+            gap: 14px;
+            line-height: 1;
+            > div{
+                display: flex;;
+                gap: 10px;
+                align-items: center;
+                justify-content: space-between;
+                h2{
+                    color: #fff;
+                    font-size: 13px;
+                    font-weight: 600;
+                }
+                a{
+                    font-size: 12px;
+                    font-weight: 500;
+                    color: rgba(255, 255, 255, 0.85);
+                }
+                .part--count{
+                    display: flex;
+                    flex-direction: column;
+                    width: 33.3333%;
+                    gap: 3px;
+                    color: #fff;
+                    position: relative;
+                    &:last-child{
+                        &::after{
+                            display: none;
+                        }
+                    }
+                    &::after{
+                        content: '';
+                        background: rgba(255, 255, 255, 0.28);
+                        position: absolute;
+                        width: 1px;
+                        top: calc(50% - 15px);
+                        right: 0;
+                        height: 30px;
+                    }
+                    span{
+                        font-size: 21px;
+                        font-weight: 800;
+                        letter-spacing: -0.105px;
+                    }
+                    p{
+                        color: rgba(255, 255, 255, 0.80);
+                        font-size: 11px;
+                        font-weight: 500;
+                    }
+                }
+            }
+        }
+        .home--card--wrap{
+            padding: 18px;
+            background-color: #fff;
+            border-radius: 16px;
+            border: 1px solid #eceef1;
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+            .thumb--wrap{
+                border-radius: 14px;
+                height: 140px;
+                overflow: hidden;
+                font-size: 13px;
+                background-color: #edf0f3;
+                img{
+                    width: 100%;
+                    object-fit: cover;
+                    height: 100%;
+                }
+                &.no--image{
+                    background-color: #FC801C;
+                    background-size: 96px 96px;
+                    background-image: url(/img/ico--logo.svg);
+                    background-repeat: no-repeat;
+                    background-position: center;
+                }
+            }
+            .card--label{
+                display: flex;
+                gap: 8px;
+                align-items: center;
+                .part--label{
+                    color: #fff;
+                    font-size: 11px;
+                    font-weight: 700;
+                    padding: 4px 9px;
+                    border-radius: 100px;
+                    background-color: #d9650a;
+                    line-height: 1;
+                    &.blue{
+                        background-color: #2F6FE0;
+                    }
+                }
+                .status--label{
+                    display: flex;
+                    align-items: center;
+                    gap: 4px;
+                    font-size: 11px;
+                    color: #1e9e48;
+                    font-weight: 600;
+                    .ico{
+                        width: 6px;
+                        height: 6px;
+                        border-radius: 50%;
+                        background-color: #1e9e48;
+                    }
+                    &.closed{
+                        color: #9aa0ac;
+                        .ico{
+                            background-color: #9aa0ac;
+                        }
+                    }
+                }
+            }
+            .card--name{
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                color: #1a1d29;
+                line-height: 1;
+                font-size: 15px;
+                font-weight: 700;
+                letter-spacing: -0.045px;
+            }
+            .card--desc{
+                line-height: 1;
+                color: #8a8f9a;
+                font-size: 12px;
+                font-weight: 400;
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+            }
+            .card--info{
+                display: flex;
+                gap: 10px;
+                align-items: center;
+                color: #8a8f9a;
+                font-size: 12px;
+                font-weight: 500;
+                flex-wrap: wrap;
+                .area{
+                    display: flex;
+                    gap: 4px;
+                    align-items: center;
+                    .ico{
+                        width: 14px;
+                        height: 14px;
+                        background-image: url(/img/ico--area.svg);
+                    }
+                }
+                .date{
+                    display: flex;
+                    gap: 4px;
+                    align-items: center;
+                    .ico{
+                        height: 14px;
+                        background-image: url(/img/ico--date.svg);
+                        width: 14px;
+                    }
+                }
+            }
+            .card--btn{
+                padding: 12px;
+                line-height: 1;
+                color: #fff;
+                height: 40px;
+                font-size: 15px;
+                font-weight: 700;
+                background-color: #d9650a;
+                border-radius: 12px;
+                text-align: center;
+                &.closed{
+                    background-color: #e5e7eb;
+                    color: #9aa0ac;
+                }
+            }
+        }
+        .user--btn--wrap{
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+            button{
+                text-align: center;
+                font-size: 16px;
+                font-weight: 700;
+                border-radius: 12px;
+                border: 1px solid #eceef1;
+                background-color: #fff;
+                color: #8a8f9a;
+                height: 52px;
+            }
+        }
     }
 }
 
+.bottom--nav--wrap{
+    padding-top: 16px;
+    display: flex;
+    flex-direction: column;
+    line-height: 1;
+    gap: 6px;
+    .link--list{
+        display: flex;
+        a{
+            color: #8a8f9a;
+            font-size: 12px;
+            font-weight: 500;
+        }
+    }
+    .copyright--txt{
+        color: #b6bbc4;
+        font-size: 11px;
+        font-weight: 400;
+    }
+}
 footer{
     position: fixed;
     bottom: 0;

+ 10 - 0
app/components/bottomNav.vue

@@ -0,0 +1,10 @@
+<template>
+  <div class="bottom--nav--wrap">
+    <div class="link--list">
+      <NuxtLink to="/">이용약관ㆍ</NuxtLink>
+      <NuxtLink to="/">개인정보처리방침ㆍ</NuxtLink>
+      <NuxtLink to="/">고객센터</NuxtLink>
+    </div>
+    <div class="copyright--txt">© 2026 파이럿존(주)</div>
+  </div>
+</template>

+ 64 - 1
app/components/header.vue

@@ -2,10 +2,73 @@
   <header class="user--header">
     <div class="header--wrap">
       <NuxtLink to="/" class="header--logo"><i class="logo"></i><h1>파이럿존</h1></NuxtLink>
+      <button v-if="isLoggedIn" type="button" @click="handleLogout">로그아웃</button>
+      <NuxtLink v-else to="/login" class="header--login-link">로그인</NuxtLink>
     </div>
+
+    <AppAlertModal
+      v-model="confirmModal"
+      type="confirm"
+      icon-type="info"
+      title="로그아웃"
+      message="로그아웃 하시겠습니까?"
+      confirm-text="로그아웃"
+      cancel-text="취소"
+      @confirm="doLogout"
+    />
   </header>
 </template>
 
 <script setup>
-  const route = useRoute();
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import AppAlertModal from '~/components/AppAlertModal.vue'
+
+const router = useRouter()
+const { post } = useApi()
+
+const isLoggedIn = ref(false)
+const confirmModal = ref(false)
+
+const checkAuth = () => {
+  const token = localStorage.getItem('user_token') || sessionStorage.getItem('user_token')
+  const expires = localStorage.getItem('user_token_expires') || sessionStorage.getItem('user_token_expires')
+  isLoggedIn.value = !!(token && expires && new Date(expires.replace(' ', 'T')) > new Date())
+}
+
+const handleLogout = () => {
+  confirmModal.value = true
+}
+
+const clearAuth = () => {
+  ['user_token', 'user_token_expires', 'user', 'auto_login'].forEach((k) => {
+    localStorage.removeItem(k)
+    sessionStorage.removeItem(k)
+  })
+}
+
+const doLogout = async () => {
+  const token = localStorage.getItem('user_token') || sessionStorage.getItem('user_token')
+
+  // 백엔드 토큰 삭제 (실패해도 무시 — 클라이언트 정리는 무조건 진행)
+  if (token) {
+    try {
+      await post('/users/logout', null, {
+        headers: { Authorization: `Bearer ${token}` }
+      })
+    } catch (e) {
+      console.warn('[Logout] API failed but proceeding:', e)
+    }
+  }
+
+  clearAuth()
+  isLoggedIn.value = false
+  router.push('/login')
+}
+
+onMounted(() => {
+  checkAuth()
+  // 다른 탭에서 로그인/아웃 시 동기화
+  window.addEventListener('storage', checkAuth)
+})
 </script>

+ 26 - 0
app/components/subHeader.vue

@@ -0,0 +1,26 @@
+<template>
+  <header class="user--header">
+    <div class="sub--header--wrap">
+      <NuxtLink to="/" class="back--btn"></NuxtLink>
+      <h2 v-if="title">{{ title }}</h2>
+    </div>
+  </header>
+</template>
+
+<script setup>
+import { computed, watch } from 'vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+// 동적 override (API 응답 등으로 title을 setup 이후 바꿔야 할 때)
+const overrideTitle = useState('page_title', () => '')
+
+// 라우트 바뀌면 override 자동 초기화 (이전 페이지 값 안 남게)
+watch(() => route.path, () => {
+  overrideTitle.value = ''
+})
+
+// 표시할 title: override가 우선, 없으면 route.meta.title
+const title = computed(() => overrideTitle.value || route.meta?.title || '')
+</script>

+ 18 - 4
app/composables/useApi.js

@@ -76,13 +76,27 @@ export const useApi = () => {
         hasToken: typeof window !== 'undefined' && typeof localStorage !== 'undefined' ? !!localStorage.getItem('admin_token') : false
       })
 
-      // 401 에러만 토큰 삭제 및 로그아웃 처리
+      // 401 에러: 어드민/사용자 영역 분기 처리
       if (status === 401) {
         console.log('[useApi] 401 Unauthorized - 토큰 삭제 및 로그아웃')
         if (typeof window !== 'undefined') {
-          localStorage.removeItem('admin_token')
-          localStorage.removeItem('admin_user')
-          window.location.href = '/site-manager'
+          const currentPath = window.location.pathname
+          if (currentPath.startsWith('/site-manager')) {
+            // 어드민 영역
+            localStorage.removeItem('admin_token')
+            localStorage.removeItem('admin_user')
+            window.location.href = '/site-manager'
+          } else {
+            // 일반 사용자 영역
+            ;['user_token', 'user_token_expires', 'user', 'auto_login'].forEach((k) => {
+              localStorage.removeItem(k)
+              sessionStorage.removeItem(k)
+            })
+            // 이미 로그인 페이지면 그대로 두기 (로그인 시도 자체가 401일 수 있음)
+            if (!currentPath.startsWith('/login')) {
+              window.location.href = '/login'
+            }
+          }
         }
       } else {
         console.log('[useApi] 401이 아닌 에러 - 토큰 유지')

+ 67 - 1
app/pages/index.vue

@@ -8,7 +8,73 @@
         <button><span>참여중</span></button>
       </div>
       <div class="home--container">
-        <div class=""></div>
+        <div class="my--part--wrap">
+          <div class="">
+            <h2>내 참여 현황</h2>
+            <NuxtLink to="/">전체 ›</NuxtLink>
+          </div>
+          <div>
+            <div class="part--count">
+              <span>3</span>
+              <p>진행중</p>
+            </div>
+            <div class="part--count">
+              <span>1,250</span>
+              <p>포인트</p>
+            </div>
+            <div class="part--count">
+              <span>항해사</span>
+              <p>등급</p>
+            </div>
+          </div>
+        </div>
+        <div class="home--card--wrap">
+          <div class="thumb--wrap no--image">
+            <img src="" alt="챌린지/퀘스트 타이틀 이미지">
+          </div>
+          <div class="card--label">
+            <span class="part--label">챌린지</span>
+            <!-- <span class="part--bubble blue">퀘스트</span> -->
+            <span class="status--label"><i class="ico"></i>진행중</span>
+          </div>
+          <h3 class="card--name">여름 대물 챌린지</h3>
+          <p class="card--desc">전국에서 즐기는 여름 대물 낚시 챌린지 · 최대어로 순위 경쟁</p>
+          <div class="card--info">
+            <div class="area">
+              <i class="ico"></i> 동해
+            </div>
+            <div class="date">
+              <i class="ico"></i> 2026/5/20-2026/6/30
+            </div>
+          </div>
+          <NuxtLink to="/" class="card--btn">신청하기</NuxtLink>
+        </div>
+        <div class="home--card--wrap">
+          <div class="thumb--wrap no--image">
+            <img src="" alt="챌린지/퀘스트 타이틀 이미지">
+          </div>
+          <div class="card--label">
+            <span class="part--label blue">퀘스트</span>
+            <span class="status--label closed"><i class="ico"></i>종료</span>
+          </div>
+          <h3 class="card--name">여름 대물 챌린지2</h3>
+          <p class="card--desc">전국에서 즐기는 여름 대물 낚시 챌린지 · 최대어로 순위 경쟁</p>
+          <div class="card--info">
+            <div class="area">
+              <i class="ico"></i> 동해
+            </div>
+            <div class="date">
+              <i class="ico"></i> 2026/5/20-2026/6/30
+            </div>
+          </div>
+          <NuxtLink to="/" class="card--btn closed">마감</NuxtLink>
+        </div>
+        <div class="user--btn--wrap">
+          <button>
+            더보기
+          </button>
+        </div>
+        <BottomNav />
       </div>
     </div>
   </main>

+ 202 - 0
app/pages/login/find.vue

@@ -0,0 +1,202 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="title--wrap">
+        <h1>{{ activeTab === 'id' ? '아이디 찾기' : '비밀번호 재설정' }}</h1>
+      </div>
+      <div class="find--tab--wrap mt--14">
+        <button type="button" :class="{ active: activeTab === 'id' }" @click="setTab('id')">아이디 찾기</button>
+        <button type="button" :class="{ active: activeTab === 'pw' }" @click="setTab('pw')">비밀번호 찾기</button>
+      </div>
+
+      <!-- 아이디 찾기 -->
+      <div v-if="activeTab === 'id'" class="login--wrap mt--20">
+        <form @submit.prevent="handleFindId">
+          <p class="mb--15 desc--p">가입 시 등록한 이름과 핸드폰 번호로 아이디를 찾을 수 있습니다.</p>
+          <div class="input--wrap">
+            <label for="findId--name">이름</label>
+            <input id="findId--name" v-model="idForm.name" type="text" placeholder="이름을 입력해 주세요" maxlength="50">
+          </div>
+          <div class="input--wrap mt--18">
+            <label for="findId--phone1">핸드폰 <span class="required">*</span></label>
+            <div class="input--inner--wrap gap--4">
+              <input id="findId--phone1" v-model="idForm.phoneFront" type="text" maxlength="3">
+              <span>-</span>
+              <input v-model="idForm.phoneMiddle" type="text" maxlength="4" inputmode="numeric">
+              <span>-</span>
+              <input v-model="idForm.phoneLast" type="text" maxlength="4" inputmode="numeric">
+            </div>
+          </div>
+        </form>
+      </div>
+
+      <!-- 비밀번호 재설정 (본인확인) -->
+      <div v-else class="login--wrap mt--20">
+        <form @submit.prevent="handleVerifyForReset">
+          <p class="mb--15 desc--p">아이디와 본인인증으로 비밀번호를 재설정할 수 있습니다.</p>
+          <div class="input--wrap">
+            <label for="findPw--id">아이디</label>
+            <input id="findPw--id" v-model="pwForm.username" type="text" placeholder="아이디를 입력해 주세요" maxlength="20">
+          </div>
+
+          <!-- 소셜 가입자 안내 -->
+          <div v-if="socialNotice" class="mt--20 find--social--box">
+            <span>💬 소셜 가입 회원 안내</span>
+            <p>{{ socialNotice }}</p>
+          </div>
+
+          <p class="label--p mt--28">본인인증 (이름ㆍ핸드폰)</p>
+          <div class="input--wrap mt--18">
+            <label for="findPw--name">이름</label>
+            <input id="findPw--name" v-model="pwForm.name" type="text" placeholder="이름을 입력해 주세요" maxlength="50">
+          </div>
+          <div class="input--wrap mt--18">
+            <label for="findPw--phone1">핸드폰 <span class="required">*</span></label>
+            <div class="input--inner--wrap gap--4">
+              <input id="findPw--phone1" v-model="pwForm.phoneFront" type="text" maxlength="3">
+              <span>-</span>
+              <input v-model="pwForm.phoneMiddle" type="text" maxlength="4" inputmode="numeric">
+              <span>-</span>
+              <input v-model="pwForm.phoneLast" type="text" maxlength="4" inputmode="numeric">
+            </div>
+          </div>
+        </form>
+      </div>
+    </div>
+
+    <div class="float--btn--wrap">
+      <a
+        href="#"
+        :class="{ disabled: submitting }"
+        @click.prevent="submit"
+      >{{ submitting ? '처리 중...' : '다음' }}</a>
+    </div>
+
+    <AppAlertModal
+      v-model="modal.show"
+      :icon-type="modal.iconType"
+      :title="modal.title"
+      :message="modal.message"
+    />
+  </main>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import AppAlertModal from '~/components/AppAlertModal.vue'
+
+const route = useRoute()
+const router = useRouter()
+const { post } = useApi()
+
+const activeTab = ref('id') // 'id' | 'pw'
+
+// 쿼리스트링 ?tab=pw 로 진입하면 비밀번호 탭 자동 활성
+onMounted(() => {
+  if (route.query.tab === 'pw') activeTab.value = 'pw'
+})
+const submitting = ref(false)
+const socialNotice = ref('')
+
+const idForm = reactive({
+  name: '',
+  phoneFront: '010',
+  phoneMiddle: '',
+  phoneLast: '',
+})
+
+const pwForm = reactive({
+  username: '',
+  name: '',
+  phoneFront: '010',
+  phoneMiddle: '',
+  phoneLast: '',
+})
+
+const modal = reactive({ show: false, iconType: 'error', title: '', message: '' })
+const showAlert = (title, message, iconType = 'error') => {
+  modal.title = title
+  modal.message = message
+  modal.iconType = iconType
+  modal.show = true
+}
+
+const setTab = (t) => {
+  activeTab.value = t
+  socialNotice.value = ''
+}
+
+const submit = () => {
+  if (activeTab.value === 'id') handleFindId()
+  else handleVerifyForReset()
+}
+
+// 아이디 찾기
+const handleFindId = async () => {
+  if (submitting.value) return
+  if (!idForm.name.trim()) return showAlert('입력 확인', '이름을 입력해 주세요.')
+  if (!idForm.phoneMiddle || !idForm.phoneLast) {
+    return showAlert('입력 확인', '핸드폰 번호를 입력해 주세요.')
+  }
+
+  submitting.value = true
+  try {
+    const phone = `${idForm.phoneFront}-${idForm.phoneMiddle}-${idForm.phoneLast}`
+    const { data, error } = await post('/users/find-id', {
+      name: idForm.name.trim(),
+      phone,
+    })
+    if (error || !data?.success) {
+      return showAlert('조회 실패', error?.message || data?.message || '일치하는 회원 정보가 없습니다.')
+    }
+    sessionStorage.setItem('find_id_result', JSON.stringify(data.data))
+    router.push('/login/findIdComplete')
+  } catch (e) {
+    console.error(e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    submitting.value = false
+  }
+}
+
+// 비밀번호 재설정 본인확인
+const handleVerifyForReset = async () => {
+  if (submitting.value) return
+  socialNotice.value = ''
+  if (!pwForm.username.trim()) return showAlert('입력 확인', '아이디를 입력해 주세요.')
+  if (!pwForm.name.trim()) return showAlert('입력 확인', '이름을 입력해 주세요.')
+  if (!pwForm.phoneMiddle || !pwForm.phoneLast) {
+    return showAlert('입력 확인', '핸드폰 번호를 입력해 주세요.')
+  }
+
+  submitting.value = true
+  try {
+    const phone = `${pwForm.phoneFront}-${pwForm.phoneMiddle}-${pwForm.phoneLast}`
+    const { data, error } = await post('/users/verify-for-reset', {
+      username: pwForm.username.trim(),
+      name: pwForm.name.trim(),
+      phone,
+    })
+
+    if (error || !data?.success) {
+      const msg = error?.message || data?.message || '일치하는 회원 정보가 없습니다.'
+      // 소셜 가입자 안내 (백엔드가 "소셜 가입 회원입니다. (kakao)" 류로 응답)
+      if (msg.includes('소셜')) {
+        socialNotice.value = msg + ' 비밀번호 없이 소셜 로그인을 이용해 주세요.'
+        return
+      }
+      return showAlert('인증 실패', msg)
+    }
+
+    sessionStorage.setItem('reset_token', data.data.reset_token)
+    sessionStorage.setItem('reset_username', pwForm.username.trim())
+    router.push('/login/findPw')
+  } catch (e) {
+    console.error(e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    submitting.value = false
+  }
+}
+</script>

+ 79 - 0
app/pages/login/findIdComplete.vue

@@ -0,0 +1,79 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="title--wrap">
+        <h1>아이디 찾기 완료</h1>
+        <p class="mt--9">회원님의 아이디는 아래와 같습니다.</p>
+      </div>
+
+      <div v-if="result" class="login--wrap mt--25">
+        <div class="find--result--wrap">
+          <span class="result--label">아이디</span>
+          <span class="result--bubble">{{ signupTypeLabel }}</span>
+          <span class="result--txt">{{ result.username_masked }}</span>
+          <span class="result--date">가입일: {{ formattedDate }}</span>
+        </div>
+
+        <div class="float--btn--wrap">
+          <!-- 소셜 가입자만 해당 소셜 로그인 버튼 노출 -->
+          <div v-if="result.signup_type !== 'local'" class="social--login--wrap">
+            <ul class="social--login--list">
+              <li v-if="result.signup_type === 'kakao'"><NuxtLink to="#" class="kakao">카카오톡</NuxtLink></li>
+              <li v-if="result.signup_type === 'naver'"><NuxtLink to="#" class="naver">네이버</NuxtLink></li>
+              <li v-if="result.signup_type === 'apple'"><NuxtLink to="#" class="apple">애플</NuxtLink></li>
+            </ul>
+          </div>
+          <NuxtLink to="/login">로그인하기</NuxtLink>
+          <NuxtLink
+            v-if="result.signup_type === 'local'"
+            :to="{ path: '/login/find', query: { tab: 'pw' } }"
+            class="border--btn"
+          >비밀번호 재설정하기</NuxtLink>
+        </div>
+      </div>
+
+      <div v-else class="login--wrap mt--25">
+        <p class="desc--p">조회 결과가 없습니다. 다시 시도해 주세요.</p>
+        <div class="float--btn--wrap">
+          <NuxtLink to="/login/find">다시 찾기</NuxtLink>
+        </div>
+      </div>
+    </div>
+  </main>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
+
+const result = ref(null)
+
+const signupTypeLabel = computed(() => {
+  const t = result.value?.signup_type
+  if (t === 'kakao') return '카카오'
+  if (t === 'naver') return '네이버'
+  if (t === 'apple') return '애플'
+  return '일반'
+})
+
+const formattedDate = computed(() => {
+  const c = result.value?.created_at
+  if (!c) return '-'
+  const d = new Date(String(c).replace(' ', 'T'))
+  if (isNaN(d.getTime())) return c
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}.${m}.${day}`
+})
+
+onMounted(() => {
+  const raw = sessionStorage.getItem('find_id_result')
+  if (raw) {
+    try { result.value = JSON.parse(raw) } catch { result.value = null }
+  }
+})
+
+onBeforeUnmount(() => {
+  sessionStorage.removeItem('find_id_result')
+})
+</script>

+ 156 - 0
app/pages/login/findPw.vue

@@ -0,0 +1,156 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="title--wrap">
+        <h1>새 비밀번호 설정</h1>
+        <p class="mt--9">새로 사용할 비밀번호를 입력해주세요.</p>
+      </div>
+      <div class="login--wrap mt--20">
+        <form @submit.prevent="handleReset">
+          <!-- 새 비밀번호 -->
+          <div class="input--wrap mt--18 pw--input--wrap">
+            <label for="reset--pw">새 비밀번호</label>
+            <input
+              id="reset--pw"
+              v-model="password"
+              :type="showPw ? 'text' : 'password'"
+              placeholder="비밀번호를 입력해 주세요"
+              autocomplete="new-password"
+              maxlength="30"
+            >
+            <button
+              type="button"
+              class="pw--toggle--btn"
+              :aria-label="showPw ? '비밀번호 숨기기' : '비밀번호 표시'"
+              @click="showPw = !showPw"
+            >
+              <img v-if="showPw" src="/img/ico--pw.svg" alt="비밀번호 표시" />
+              <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
+            </button>
+          </div>
+          <p class="input--info" :style="passwordInvalid ? 'color:#e5484d;' : ''">
+            영문(소문자)+숫자+특수문자 조합 8자 이상
+          </p>
+
+          <!-- 새 비밀번호 확인 -->
+          <div class="input--wrap mt--18 pw--input--wrap">
+            <label for="reset--pw2">새 비밀번호 확인</label>
+            <input
+              id="reset--pw2"
+              v-model="password2"
+              :type="showPw2 ? 'text' : 'password'"
+              placeholder="비밀번호를 다시 입력해 주세요"
+              autocomplete="new-password"
+              maxlength="30"
+            >
+            <button
+              type="button"
+              class="pw--toggle--btn"
+              :aria-label="showPw2 ? '비밀번호 숨기기' : '비밀번호 표시'"
+              @click="showPw2 = !showPw2"
+            >
+              <img v-if="showPw2" src="/img/ico--pw.svg" alt="비밀번호 표시" />
+              <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
+            </button>
+          </div>
+          <p v-if="password2 && password !== password2" class="input--info" style="color:#e5484d;">
+            비밀번호가 일치하지 않습니다.
+          </p>
+
+          <div class="change--pw--box mt--25">
+            <span>🔒 비밀번호 규칙</span>
+            <ul>
+              <li>8자 이상</li>
+              <li>영문 소문자 + 숫자 + 특수문자 조합</li>
+              <li>이전 비밀번호와 달라야 함</li>
+              <li>아이디 포함 불가</li>
+            </ul>
+          </div>
+
+          <div class="float--btn--wrap">
+            <a
+              href="#"
+              :class="{ disabled: !canSubmit || submitting }"
+              @click.prevent="handleReset"
+            >{{ submitting ? '변경 중...' : '비밀번호 변경' }}</a>
+          </div>
+        </form>
+      </div>
+    </div>
+
+    <AppAlertModal
+      v-model="modal.show"
+      :icon-type="modal.iconType"
+      :title="modal.title"
+      :message="modal.message"
+    />
+  </main>
+</template>
+
+<script setup>
+import { ref, computed, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import AppAlertModal from '~/components/AppAlertModal.vue'
+
+const router = useRouter()
+const { post } = useApi()
+
+const password = ref('')
+const password2 = ref('')
+const showPw = ref(false)
+const showPw2 = ref(false)
+const submitting = ref(false)
+const resetToken = ref('')
+
+const modal = reactive({ show: false, iconType: 'error', title: '', message: '' })
+const showAlert = (title, message, iconType = 'error') => {
+  modal.title = title
+  modal.message = message
+  modal.iconType = iconType
+  modal.show = true
+}
+
+const PASSWORD_RE = /^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>/?]).{8,}$/
+
+const passwordInvalid = computed(() =>
+  password.value !== '' && !PASSWORD_RE.test(password.value)
+)
+
+const canSubmit = computed(() =>
+  PASSWORD_RE.test(password.value) && password.value === password2.value
+)
+
+onMounted(() => {
+  resetToken.value = sessionStorage.getItem('reset_token') || ''
+  if (!resetToken.value) {
+    // 인증 토큰 없으면 본인확인부터 다시
+    showAlert('잘못된 접근', '본인 확인부터 진행해 주세요.')
+    setTimeout(() => router.replace('/login/find'), 1000)
+  }
+})
+
+const handleReset = async () => {
+  if (!canSubmit.value || submitting.value) return
+
+  submitting.value = true
+  try {
+    const { data, error } = await post('/users/reset-password', {
+      reset_token: resetToken.value,
+      new_password: password.value,
+    })
+
+    if (error || !data?.success) {
+      return showAlert('변경 실패', error?.message || data?.message || '비밀번호 변경에 실패했습니다.')
+    }
+
+    sessionStorage.removeItem('reset_token')
+    sessionStorage.removeItem('reset_username')
+    router.push('/login/findPwComplete')
+  } catch (e) {
+    console.error(e)
+    showAlert('오류', '서버 오류가 발생했습니다.')
+  } finally {
+    submitting.value = false
+  }
+}
+</script>

+ 27 - 0
app/pages/login/findPwComplete.vue

@@ -0,0 +1,27 @@
+<template>
+  <main class="user--main">
+    <div class="join--container">
+      <div class="join--step3 mt--54">
+        <div class="join--complete--wrap">
+          <i class="ico"></i>
+          <h2 class="mt--28">
+            비밀번호가 변경되었습니다.
+          </h2>
+          <p class="mt--14">보안을 위해 모든 기기에서 자동 로그아웃되었습니다. <br />새 비밀번호로 다시 로그인해주세요.</p>
+        </div>
+        <div class="join--benefit--wrap mt--32">
+          <h3>🔔 보안 알림</h3>
+          <span class="mt--9">등록된 핸드폰으로 변경 알림이 발송되었습니다.</span>
+        </div>
+      </div>
+    </div>
+
+    <div class="float--btn--wrap">
+      <NuxtLink to="/login">로그인하기</NuxtLink>
+    </div>
+  </main>
+</template>
+
+<script setup>
+// 단순 안내 페이지 — 별도 로직 없음
+</script>

+ 6 - 2
app/pages/login/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <main>
+  <main class="user--main">
     <div class="join--container">
       <div class="title--wrap">
         <h1>로그인</h1>
@@ -43,7 +43,7 @@
                 자동 로그인
               </label>
             </div>
-            <NuxtLink to="/find" class="find--pw--btn">아이디ㆍ비밀번호 찾기</NuxtLink>
+            <NuxtLink to="/login/find" class="find--pw--btn">아이디ㆍ비밀번호 찾기</NuxtLink>
           </div>
           <div class="login--btn--wrap mt--30">
             <button type="submit" :disabled="loggingIn">
@@ -166,4 +166,8 @@ onMounted(() => {
     }
   }
 })
+
+// definePageMeta({
+//   title: '챌린지 상세'
+// })
 </script>

+ 3 - 0
backend/app/Config/Routes.php

@@ -107,6 +107,9 @@ $routes->post('api/users/logout', 'Api\UsersController::logout');
 $routes->post('api/users/check-username', 'Api\UsersController::checkUsername');
 $routes->post('api/users/check-nickname', 'Api\UsersController::checkNickname');
 $routes->post('api/users/signup', 'Api\UsersController::signup');
+$routes->post('api/users/find-id', 'Api\UsersController::findId');
+$routes->post('api/users/verify-for-reset', 'Api\UsersController::verifyForReset');
+$routes->post('api/users/reset-password', 'Api\UsersController::resetPassword');
 
 // File Upload
 $routes->post('api/upload/file', 'Api\UploadController::uploadFile');

+ 193 - 0
backend/app/Controllers/Api/UsersController.php

@@ -11,6 +11,199 @@ class UsersController extends BaseApiController
 
     private const ALLOWED_AREAS = ['남해', '동해', '민물', '서해', '제주'];
 
+    /**
+     * 아이디 찾기 — 이름 + 핸드폰으로 조회 → 마스킹된 아이디 반환
+     * POST /api/users/find-id
+     */
+    public function findId()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            $name = trim((string) ($payload['name'] ?? ''));
+            $phoneDigits = preg_replace('/[^0-9]/', '', (string) ($payload['phone'] ?? ''));
+
+            if ($name === '') return $this->respondError('이름을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
+                return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $phoneFormatted = $this->formatPhone($phoneDigits);
+
+            $user = $this->getDB()->table($this->table)
+                ->where('name', $name)
+                ->where('phone', $phoneFormatted)
+                ->where('deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$user) {
+                return $this->respondError('일치하는 회원 정보가 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            return $this->respondSuccess([
+                'username_masked' => $this->maskUsername($user->username),
+                'signup_type'     => $user->signup_type,
+                'created_at'      => $user->created_at,
+            ], '아이디 조회 성공');
+        } catch (\Exception $e) {
+            log_message('error', 'findId error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 비밀번호 재설정 — 본인 확인 (아이디+이름+핸드폰)
+     * POST /api/users/verify-for-reset
+     * 일치하면 10분 짜리 임시 토큰 발급
+     */
+    public function verifyForReset()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            $username = trim((string) ($payload['username'] ?? ''));
+            $name = trim((string) ($payload['name'] ?? ''));
+            $phoneDigits = preg_replace('/[^0-9]/', '', (string) ($payload['phone'] ?? ''));
+
+            if ($username === '' || $name === '') {
+                return $this->respondError('정보를 모두 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
+                return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $phoneFormatted = $this->formatPhone($phoneDigits);
+            $db = $this->getDB();
+
+            $user = $db->table($this->table)
+                ->where('username', $username)
+                ->where('name', $name)
+                ->where('phone', $phoneFormatted)
+                ->where('deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$user) {
+                return $this->respondError('일치하는 회원 정보가 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 소셜 가입자는 비밀번호 재설정 불가
+            if ($user->signup_type !== 'local') {
+                return $this->respondError(
+                    '소셜 가입 회원입니다. (' . $user->signup_type . ')',
+                    ResponseInterface::HTTP_BAD_REQUEST
+                );
+            }
+
+            // 10분짜리 임시 토큰 발급
+            $token = bin2hex(random_bytes(32));
+            $expiresAt = date('Y-m-d H:i:s', strtotime('+10 minutes'));
+            $db->table('user_tokens')->insert([
+                'user_id'    => $user->id,
+                'token'      => $token,
+                'expires_at' => $expiresAt,
+                'created_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            return $this->respondSuccess([
+                'reset_token' => $token,
+                'expires_at'  => $expiresAt,
+            ], '본인 확인 완료');
+        } catch (\Exception $e) {
+            log_message('error', 'verifyForReset error: ' . $e->getMessage());
+            return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 비밀번호 재설정 실제 적용
+     * POST /api/users/reset-password
+     */
+    public function resetPassword()
+    {
+        try {
+            $payload = $this->request->getJSON(true);
+            $resetToken = trim((string) ($payload['reset_token'] ?? ''));
+            $newPassword = (string) ($payload['new_password'] ?? '');
+
+            if ($resetToken === '' || $newPassword === '') {
+                return $this->respondError('정보를 모두 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $newPassword)) {
+                return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+            $tokenRow = $db->table('user_tokens')
+                ->where('token', $resetToken)
+                ->where('expires_at >', date('Y-m-d H:i:s'))
+                ->get()
+                ->getRow();
+
+            if (!$tokenRow) {
+                return $this->respondError('유효하지 않거나 만료된 인증입니다.', ResponseInterface::HTTP_UNAUTHORIZED);
+            }
+
+            $user = $db->table($this->table)
+                ->where('id', (int) $tokenRow->user_id)
+                ->where('deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$user) {
+                return $this->respondError('회원 정보를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 이전 비밀번호와 동일하면 거부
+            if (password_verify($newPassword, $user->password)) {
+                return $this->respondError('이전과 다른 비밀번호를 입력해 주세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            // 아이디 포함 거부
+            if (stripos($newPassword, $user->username) !== false) {
+                return $this->respondError('비밀번호에 아이디를 포함할 수 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // 비밀번호 업데이트
+            $db->table($this->table)->where('id', $user->id)->update([
+                'password'   => password_hash($newPassword, PASSWORD_DEFAULT),
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            // 보안상 해당 사용자의 모든 토큰 삭제 (강제 로그아웃)
+            $db->table('user_tokens')->where('user_id', $user->id)->delete();
+
+            return $this->respondSuccess(null, '비밀번호가 변경되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'resetPassword error: ' . $e->getMessage());
+            return $this->respondError('변경 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이디 마스킹 — hong****ng 같은 형태
+     */
+    private function maskUsername(string $username): string
+    {
+        $len = strlen($username);
+        if ($len <= 4) {
+            return substr($username, 0, 1) . str_repeat('*', max(0, $len - 1));
+        }
+        if ($len <= 6) {
+            return substr($username, 0, 2) . str_repeat('*', $len - 4) . substr($username, -2);
+        }
+        return substr($username, 0, 4) . str_repeat('*', $len - 6) . substr($username, -2);
+    }
+
+    /**
+     * 핸드폰 숫자 → 010-XXXX-XXXX 형식
+     */
+    private function formatPhone(string $digits): string
+    {
+        if (strlen($digits) === 11) {
+            return substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7);
+        }
+        return substr($digits, 0, 3) . '-' . substr($digits, 3, 3) . '-' . substr($digits, 6);
+    }
+
     /**
      * 회원 로그인
      * POST /api/users/login

二进制
public/favicon.ico


+ 4 - 0
public/img/ico--area.svg

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.6667 5.83317C11.6667 9.33317 7 12.8332 7 12.8332C7 12.8332 2.33334 9.33317 2.33334 5.83317C2.33334 4.59549 2.825 3.40851 3.70017 2.53334C4.57534 1.65817 5.76233 1.1665 7 1.1665C8.23768 1.1665 9.42466 1.65817 10.2998 2.53334C11.175 3.40851 11.6667 4.59549 11.6667 5.83317Z" stroke="#9AA0AC" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 7.5835C7.9665 7.5835 8.75 6.79999 8.75 5.8335C8.75 4.867 7.9665 4.0835 7 4.0835C6.0335 4.0835 5.25 4.867 5.25 5.8335C5.25 6.79999 6.0335 7.5835 7 7.5835Z" stroke="#9AA0AC" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
public/img/ico--back.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15.5 5L8.5 12L15.5 19" stroke="#1A1D29" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
public/img/ico--date.svg

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.0833 2.3335H2.91667C2.27233 2.3335 1.75 2.85583 1.75 3.50016V11.6668C1.75 12.3112 2.27233 12.8335 2.91667 12.8335H11.0833C11.7277 12.8335 12.25 12.3112 12.25 11.6668V3.50016C12.25 2.85583 11.7277 2.3335 11.0833 2.3335Z" stroke="#9AA0AC" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.33333 1.1665V3.49984M4.66667 1.1665V3.49984M1.75 5.83317H12.25" stroke="#9AA0AC" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>