Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
<RouterLink to="/chat" class="app-header__link" active-class="app-header__link--active">챗봇</RouterLink>
</nav>
<div class="app-header__right">
<RouterLink to="/login" class="app-header__cta">로그인</RouterLink>
<template v-if="auth.isLoggedIn">
<button class="app-header__cta" @click="handleLogout">로그아웃</button>
</template>
<template v-else>
<RouterLink to="/login" class="app-header__cta">로그인</RouterLink>
</template>
</div>
</header>

Expand All @@ -24,7 +29,17 @@
</template>

<script setup>
import { useRouter } from 'vue-router'
import logoBlack from '@/assets/logo-black.png'
import { useAuthStore } from '@/store/authStore.js'

const router = useRouter()
const auth = useAuthStore()

async function handleLogout() {
await auth.logout()
router.push('/')
}
</script>

<style scoped>
Expand Down Expand Up @@ -95,4 +110,9 @@ import logoBlack from '@/assets/logo-black.png'
.app-header__cta:hover {
background: #374151;
}
button.app-header__cta {
border: none;
cursor: pointer;
font-family: inherit;
}
</style>
19 changes: 19 additions & 0 deletions frontend/src/api/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import http from './http.js'

export const sendVerificationEmail = (email) =>
http.post('/api/v1/auth/email/send', { email })

export const verifyEmail = (email, code) =>
http.post('/api/v1/auth/email/verify', { email, code })

export const signup = (email, password, nickname) =>
http.post('/api/v1/auth/signup', { email, password, nickname })

export const login = (email, password) =>
http.post('/api/v1/auth/login', { email, password })

export const logout = () =>
http.post('/api/v1/auth/logout')

export const refresh = (refreshToken) =>
http.post('/api/v1/auth/refresh', { refreshToken })
65 changes: 64 additions & 1 deletion frontend/src/api/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import axios from 'axios'
const viteEnv = import.meta.env || {}
const baseURL = viteEnv.VITE_API_BASE_URL || 'http://localhost:8080'

const ACCESS_KEY = 'salmanhae.accessToken'
const REFRESH_KEY = 'salmanhae.refreshToken'

const http = axios.create({
baseURL,
timeout: 10000,
Expand All @@ -15,8 +18,8 @@ const readAccessToken = () => {
try {
if (typeof window === 'undefined' || !window.localStorage) return ''
return (
window.localStorage.getItem(ACCESS_KEY) ||
window.localStorage.getItem('accessToken') ||
window.localStorage.getItem('salmanhae.accessToken') ||
''
)
} catch {
Expand All @@ -32,4 +35,64 @@ http.interceptors.request.use((config) => {
return config
})

let isRefreshing = false
let pendingQueue = []

const flushQueue = (error, token = null) => {
pendingQueue.forEach(({ resolve, reject }) => {
if (error) reject(error)
else resolve(token)
})
pendingQueue = []
}

http.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config

if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}

const storedRefresh = localStorage.getItem(REFRESH_KEY)
if (!storedRefresh) {
return Promise.reject(error)
}

if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject })
}).then((token) => {
original.headers.Authorization = `Bearer ${token}`
return http(original)
})
Comment on lines +63 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Queued retries are not marked _retry, allowing repeat refresh attempts.

The request that triggers the refresh sets original._retry = true (line 72), but requests resolved from pendingQueue retry via http(original) without ever setting _retry. If the freshly issued token is still rejected with 401, those retried requests re-enter the interceptor, pass the !original._retry gate, and kick off another refresh cycle. Mark queued retries as retried as well.

Proposed guard
       }).then((token) => {
+        original._retry = true
         original.headers.Authorization = `Bearer ${token}`
         return http(original)
       })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject })
}).then((token) => {
original.headers.Authorization = `Bearer ${token}`
return http(original)
})
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject })
}).then((token) => {
original._retry = true
original.headers.Authorization = `Bearer ${token}`
return http(original)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/http.js` around lines 63 - 69, Queued retries in the http
interceptor are not being marked as retried, so they can re-enter the 401
refresh flow and trigger another refresh cycle. Update the pendingQueue
resolution path in the http request interceptor so the retried request from
http(original) also sets original._retry = true before reissuing it, matching
the behavior used for the initial refresh-triggering request and preventing
repeat refresh attempts.

}

original._retry = true
isRefreshing = true

try {
const res = await axios.post(`${baseURL}/api/v1/auth/refresh`, {
refreshToken: storedRefresh,
})
const { accessToken, refreshToken } = res.data.data
localStorage.setItem(ACCESS_KEY, accessToken)
localStorage.setItem(REFRESH_KEY, refreshToken)
http.defaults.headers.common.Authorization = `Bearer ${accessToken}`
flushQueue(null, accessToken)
original.headers.Authorization = `Bearer ${accessToken}`
return http(original)
Comment on lines +76 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find direct consumers of store token state that could observe a stale value
rg -nP '\.(accessToken|refreshToken)\b' frontend/src -g '!**/http.js' -C2
rg -nP '\bisLoggedIn\b' frontend/src -C2

Repository: ssafy-salman/salmanhae

Length of output: 2939


🏁 Script executed:

#!/bin/bash
set -e
# Inspect the refresh flow and auth store to see whether tokens are synchronized.
sed -n '1,220p' frontend/src/api/http.js
printf '\n--- authStore ---\n'
sed -n '1,220p' frontend/src/store/authStore.js
printf '\n--- router ---\n'
sed -n '1,120p' frontend/src/router/index.js
printf '\n--- App.vue ---\n'
sed -n '1,120p' frontend/src/App.vue

Repository: ssafy-salman/salmanhae

Length of output: 8093


🏁 Script executed:

#!/bin/bash
set -e
# Look for any refresh-specific store updates elsewhere.
rg -n "refreshToken|_saveTokens|restoreSession|flushQueue|window.location.href|location.href" frontend/src -C3

Repository: ssafy-salman/salmanhae

Length of output: 5465


🏁 Script executed:

#!/bin/bash
set -e
# Check whether anything outside the store reads accessToken/refreshToken directly.
rg -n "useAuthStore\(\)|auth\.(accessToken|refreshToken)|store\.accessToken|store\.refreshToken" frontend/src -C2

Repository: ssafy-salman/salmanhae

Length of output: 1532


🏁 Script executed:

#!/bin/bash
set -e
rg -n "useAuthStore\(\)|auth\.(accessToken|refreshToken)|store\.accessToken|store\.refreshToken" frontend/src -C2

Repository: ssafy-salman/salmanhae

Length of output: 1532


🏁 Script executed:

#!/bin/bash
set -e
sed -n '1,160p' frontend/src/api/auth.js
printf '\n--- main.js ---\n'
sed -n '1,80p' frontend/src/main.js

Repository: ssafy-salman/salmanhae

Length of output: 1130


Sync the auth store during token refresh
frontend/src/api/http.js:76-85 updates localStorage and http.defaults, but useAuthStore().accessToken / refreshToken stay stale for the rest of the session. Call the store’s token save action here (for example _saveTokens(accessToken, refreshToken)) so isLoggedIn, the router guard, and the header stay in sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/http.js` around lines 76 - 85, The token refresh flow in
http’s axios interceptor updates localStorage and axios defaults but leaves the
auth store stale, so useAuthStore state and downstream auth checks stay out of
sync. Update the refresh handling in the response interceptor to also invoke the
auth store’s token persistence action (for example the store method that saves
both accessToken and refreshToken) right after parsing res.data.data, alongside
the existing localStorage and http.defaults updates. Keep the existing
flushQueue and original request retry logic in place, but make sure the store
update happens in the same refresh path so isLoggedIn, the router guard, and the
header all read the same tokens.

} catch (refreshError) {
flushQueue(refreshError)
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
)

export default http
9 changes: 7 additions & 2 deletions frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/main.css'
import { useAuthStore } from './store/authStore.js'

const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')

useAuthStore().restoreSession()

app.mount('#app')
12 changes: 11 additions & 1 deletion frontend/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import Community from '../views/Community.vue'
import Recommend from '../views/Recommend.vue'
import Chatbot from '../views/Chatbot.vue'
import AuthView from '../views/AuthView.vue'
import { useAuthStore } from '../store/authStore.js'

const routes = [
{ path: '/', name: 'MapExplorer', component: MapExplorer },
{ path: '/login', name: 'Auth', component: AuthView },
{ path: '/diagnosis', name: 'Diagnosis', component: Diagnosis },
{ path: '/recommend', name: 'Recommend', component: Recommend },
{ path: '/chat', name: 'Chatbot', component: Chatbot },
{ path: '/chat', name: 'Chatbot', component: Chatbot, meta: { requiresAuth: true } },
{ path: '/community', name: 'Community', component: Community }
]

Expand All @@ -21,4 +22,13 @@ const router = createRouter({
scrollBehavior: () => ({ top: 0 })
})

router.beforeEach((to, _, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next()
}
})

export default router
57 changes: 57 additions & 0 deletions frontend/src/store/authStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { login as apiLogin, logout as apiLogout, signup as apiSignup } from '../api/auth.js'

const ACCESS_KEY = 'salmanhae.accessToken'
const REFRESH_KEY = 'salmanhae.refreshToken'

export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
accessToken: '',
refreshToken: '',
}),

getters: {
isLoggedIn: (state) => !!state.accessToken,
},

actions: {
restoreSession() {
this.accessToken = localStorage.getItem(ACCESS_KEY) || ''
this.refreshToken = localStorage.getItem(REFRESH_KEY) || ''
},
Comment on lines +19 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

restoreSession rehydrates tokens but never restores user.

After a reload, accessToken is restored (so isLoggedIn is true) yet user stays null. Any header/profile UI that reads user will render an authenticated-but-empty state until a fresh fetch. Consider persisting/rehydrating user too, or fetching the profile when a token is restored.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/store/authStore.js` around lines 19 - 22, restoreSession
currently rehydrates only accessToken and refreshToken, leaving user null so
authenticated UI can render without profile data. Update authStore’s
restoreSession to also restore the user state when tokens are present, or
trigger the existing profile fetch flow from the auth store after restoring a
valid token. Keep the change centered around restoreSession and any related
user-loading action in authStore.


_saveTokens(accessToken, refreshToken) {
this.accessToken = accessToken
this.refreshToken = refreshToken
localStorage.setItem(ACCESS_KEY, accessToken)
localStorage.setItem(REFRESH_KEY, refreshToken)
},

_clearTokens() {
this.user = null
this.accessToken = ''
this.refreshToken = ''
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
},

async login(email, password) {
const res = await apiLogin(email, password)
const { accessToken, refreshToken } = res.data.data
this._saveTokens(accessToken, refreshToken)
},

async logout() {
try {
await apiLogout()
} finally {
this._clearTokens()
}
},

async signup(email, password, nickname) {
await apiSignup(email, password, nickname)
},
},
})