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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_BASE_URL=
17 changes: 17 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 📌 PR 설명

<br>

## ✅ 완료한 기능 명세

- [x]

<br>

## 📸 스크린샷

<br>

## 💭 고민과 해결과정

<br>
8 changes: 8 additions & 0 deletions .github/workflows/frontend-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Set environment variables
run: |
if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "VITE_API_BASE_URL=${{ secrets.PROD_API_URL }}" >> .env.production
else
echo "VITE_API_BASE_URL=${{ secrets.DEV_API_URL }}" >> .env.production
fi

- name: Build
run: pnpm build

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@ dist-ssr
*.sln
*.sw?

# Environment
.env*
!.env.example

# Claude
CLAUDE.md
2 changes: 2 additions & 0 deletions src/app/router/app-router.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Route, Routes } from 'react-router-dom'

import AdminPage from '@/pages/admin/ui/admin-page'
import ChatPage from '@/pages/chat/ui/chat-page'
import HistoryPage from '@/pages/history/ui/history-page'
import HomePage from '@/pages/home/ui/home-page'
Expand All @@ -17,6 +18,7 @@ function AppRouter() {
<Route path="history" element={<HistoryPage />} />
<Route path="chat" element={<ChatPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 인증 관련 컴포넌트나 가드가 있는지 확인
rg -l -i 'protected|authguard|requireauth|private.*route' --type=ts --type=tsx
ast-grep --pattern 'function ProtectedRoute($$$) { $$$ }'

Repository: S0L-V/wefin-fe

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Check the router file to see how admin route is defined
cat -n src/app/router/app-router.tsx | head -40

# Search for authentication-related components/files
find . -type f \( -name "*.ts" -o -name "*.tsx" \) | xargs rg -l -i 'protected|authguard|requireauth' | head -20

# Look for ProtectedRoute specifically
rg -A 5 'ProtectedRoute|function.*Route.*\(' --type ts | head -40

Repository: S0L-V/wefin-fe

Length of output: 4353


🏁 Script executed:

#!/bin/bash
# Search for authentication-related implementations
rg -i 'auth|context|provider' --type ts src/ | head -30

# Check AdminPage implementation
cat -n src/pages/admin/ui/admin-page.tsx 2>/dev/null || cat -n src/pages/admin/ui/admin-page.ts 2>/dev/null

# Check AppLayout
cat -n src/widgets/app-layout/ui/app-layout.tsx 2>/dev/null || cat -n src/widgets/app-layout/ui/app-layout.ts 2>/dev/null

# Search for any permission checks or auth guards
rg -i 'permission|role|isadmin|admin.*check' --type ts src/ | head -20

Repository: S0L-V/wefin-fe

Length of output: 2735


🏁 Script executed:

#!/bin/bash
# Search for context hooks and auth usage
rg 'useContext|useAuth|createContext' --type ts src/

# Check admin feature implementations
echo "=== Checking CollectMarketButton ==="
find src -name "collect-market-button*" -type f

echo "=== Checking NewsCollectButtons ==="
find src -name "news-collect-buttons*" -type f

# Check for any auth context creation
rg 'createContext|AuthContext|AuthProvider' --type ts src/

# Search for useUser or similar hooks that might track auth state
rg 'useUser|getCurrentUser|isAuthenticated' --type ts src/ | head -20

Repository: S0L-V/wefin-fe

Length of output: 237


🏁 Script executed:

#!/bin/bash
# Check the admin feature implementations
echo "=== CollectMarketButton ==="
cat -n src/features/admin-market/ui/collect-market-button.tsx

echo "=== NewsCollectButtons ==="
cat -n src/features/admin-news/ui/news-collect-buttons.tsx

# Check if there are any route guards or middleware
rg 'middleware|beforeEnter|canActivate|guard' --type ts src/

# Search for any environment-based checks or admin-only logic
rg -i 'admin.*check|isadmin|role.*check' --type ts src/

Repository: S0L-V/wefin-fe

Length of output: 2690


🏁 Script executed:

#!/bin/bash
# Check the hook implementations for any auth attempts
echo "=== useCollectMarket hook ==="
find src -name "use-collect-market*" -type f -exec cat {} \;

echo -e "\n=== useCollectNews hook ==="
find src -name "use-collect-news*" -type f -exec cat {} \;

# Check if routes have any middleware or protection mechanisms
rg 'react-router|createBrowserRouter|RouteGuard' --type ts src/app/

# Final check: see if there's any authentication state management
rg 'useState.*auth|useAuth|login.*state' --type ts src/ | head -10

Repository: S0L-V/wefin-fe

Length of output: 860


관리자 페이지에 인증/인가 보호가 필요합니다.

/admin 라우트가 인증 없이 누구나 접근할 수 있습니다. 시장 데이터 수집, 뉴스 크롤링 같은 관리자 기능이 보호되지 않아 보안 위험이 있습니다.

ProtectedRoute 컴포넌트나 인증 가드를 추가하여 로그인한 관리자만 접근할 수 있도록 구현해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/router/app-router.tsx` at line 21, The /admin Route currently renders
AdminPage without auth; wrap it with your authentication/authorization guard so
only logged-in admins can access it. Replace the element prop to render
ProtectedRoute (or AuthGuard) around AdminPage, e.g. use <ProtectedRoute
requiredRole="admin"><AdminPage/></ProtectedRoute>, ensuring ProtectedRoute
checks isAuthenticated and user.role/isAdmin from your auth context and
redirects unauthenticated users to the login page (or shows 403 for
unauthorized). Ensure the guarded component name (ProtectedRoute/AuthGuard)
matches your existing implementation and that AdminPage remains the child
component.

<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
Expand Down
13 changes: 13 additions & 0 deletions src/features/admin-market/api/collect-market-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from 'zod'

import { apiResponseSchema } from '@/shared/api/api-response'
import { baseApi } from '@/shared/api/base-api'

const collectMarketResponseSchema = apiResponseSchema(z.string())

export type CollectMarketResponse = z.infer<typeof collectMarketResponseSchema>

export async function collectMarketData(): Promise<CollectMarketResponse> {
const response = await baseApi.post('/admin/market/collect')
return collectMarketResponseSchema.parse(response.data)
}
26 changes: 26 additions & 0 deletions src/features/admin-market/api/fetch-market-snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from 'zod'

import { apiResponseSchema } from '@/shared/api/api-response'
import { baseApi } from '@/shared/api/base-api'

const marketSnapshotSchema = z.object({
metricType: z.string(),
label: z.string(),
value: z.number(),
changeRate: z.number().nullable(),
changeValue: z.number().nullable(),
unit: z.string(),
changeDirection: z.string(),
createdAt: z.string(),
updatedAt: z.string().nullable()
})

const marketSnapshotsResponseSchema = apiResponseSchema(z.array(marketSnapshotSchema))

export type MarketSnapshot = z.infer<typeof marketSnapshotSchema>

export async function fetchMarketSnapshots(): Promise<MarketSnapshot[]> {
const response = await baseApi.get('/admin/market/snapshots')
const parsed = marketSnapshotsResponseSchema.parse(response.data)
return parsed.data
}
14 changes: 14 additions & 0 deletions src/features/admin-market/model/use-collect-market.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { collectMarketData } from '../api/collect-market-data'

export function useCollectMarket() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: collectMarketData,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['market', 'snapshots'] })
}
})
}
10 changes: 10 additions & 0 deletions src/features/admin-market/model/use-market-snapshots-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'

import { fetchMarketSnapshots } from '../api/fetch-market-snapshots'

export function useMarketSnapshotsQuery() {
return useQuery({
queryKey: ['market', 'snapshots'],
queryFn: fetchMarketSnapshots
})
}
22 changes: 22 additions & 0 deletions src/features/admin-market/ui/collect-market-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useCollectMarket } from '../model/use-collect-market'

function CollectMarketButton() {
const { mutate, isPending, isSuccess, isError, error } = useCollectMarket()

return (
<div className="space-y-2">
<button
type="button"
onClick={() => mutate()}
disabled={isPending}
className="rounded-lg bg-wefin-mint px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wefin-mint/90 disabled:opacity-50"
>
{isPending ? '수집 중...' : '시장 지표 수집'}
</button>
{isSuccess && <p className="text-sm text-green-600">수집 완료</p>}
{isError && <p className="text-sm text-red-600">수집 실패: {error.message}</p>}
</div>
)
}

export default CollectMarketButton
56 changes: 56 additions & 0 deletions src/features/admin-market/ui/market-snapshot-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMarketSnapshotsQuery } from '../model/use-market-snapshots-query'

function MarketSnapshotTable() {
const { data, isLoading, isError } = useMarketSnapshotsQuery()

if (isLoading) return <p className="text-sm text-wefin-subtle">로딩 중...</p>
if (isError) return <p className="text-sm text-red-600">조회 실패</p>
if (!data || data.length === 0) return <p className="text-sm text-wefin-subtle">데이터 없음</p>

return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wefin-line text-left text-wefin-subtle">
<th className="py-2">지표</th>
<th className="py-2 text-right">현재 값</th>
<th className="py-2 text-right">변동률</th>
<th className="py-2 text-right">변동 값</th>
<th className="py-2 text-center">방향</th>
</tr>
</thead>
<tbody>
{data.map((snapshot) => (
<tr key={snapshot.metricType} className="border-b border-wefin-line/50">
<td className="py-3 font-medium text-wefin-text">{snapshot.label}</td>
<td className="py-3 text-right">{snapshot.value.toLocaleString()}</td>
<td className="py-3 text-right">
{snapshot.changeRate != null ? `${snapshot.changeRate}%` : '-'}
</td>
<td className="py-3 text-right">
{snapshot.changeValue != null ? snapshot.changeValue.toLocaleString() : '-'}
</td>
<td className="py-3 text-center">
<span
className={
snapshot.changeDirection === 'UP'
? 'text-red-600'
: snapshot.changeDirection === 'DOWN'
? 'text-blue-600'
: 'text-wefin-subtle'
}
>
{snapshot.changeDirection === 'UP'
? '▲'
: snapshot.changeDirection === 'DOWN'
? '▼'
: '-'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)
}

export default MarketSnapshotTable
16 changes: 16 additions & 0 deletions src/features/admin-news/api/collect-news.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from 'zod'

import { apiResponseSchema } from '@/shared/api/api-response'
import { baseApi } from '@/shared/api/base-api'

const collectNewsResponseSchema = apiResponseSchema(z.string())

export async function collectNews() {
const response = await baseApi.post('/admin/news/collect')
return collectNewsResponseSchema.parse(response.data)
}

export async function crawlNews() {
const response = await baseApi.post('/admin/news/crawl')
return collectNewsResponseSchema.parse(response.data)
}
11 changes: 11 additions & 0 deletions src/features/admin-news/model/use-collect-news.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useMutation } from '@tanstack/react-query'

import { collectNews, crawlNews } from '../api/collect-news'

export function useCollectNews() {
return useMutation({ mutationFn: collectNews })
}

export function useCrawlNews() {
return useMutation({ mutationFn: crawlNews })
}
39 changes: 39 additions & 0 deletions src/features/admin-news/ui/news-collect-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useCollectNews, useCrawlNews } from '../model/use-collect-news'

function NewsCollectButtons() {
const collect = useCollectNews()
const crawl = useCrawlNews()

return (
<div className="space-y-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => collect.mutate()}
disabled={collect.isPending}
className="rounded-lg bg-wefin-mint px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wefin-mint/90 disabled:opacity-50"
>
{collect.isPending ? '수집 중...' : '뉴스 수집'}
</button>
<button
type="button"
onClick={() => crawl.mutate()}
disabled={crawl.isPending}
className="rounded-lg bg-wefin-mint px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wefin-mint/90 disabled:opacity-50"
>
{crawl.isPending ? '크롤링 중...' : '뉴스 크롤링'}
</button>
</div>
{collect.isSuccess && <p className="text-sm text-green-600">뉴스 수집 완료</p>}
{collect.isError && (
<p className="text-sm text-red-600">뉴스 수집 실패: {collect.error.message}</p>
)}
{crawl.isSuccess && <p className="text-sm text-green-600">뉴스 크롤링 완료</p>}
{crawl.isError && (
<p className="text-sm text-red-600">뉴스 크롤링 실패: {crawl.error.message}</p>
)}
Comment on lines +27 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

비동기 결과 메시지 영역에 aria-live를 추가해주세요.

현재 성공/실패 텍스트가 시각적으로만 갱신되어 보조기기 사용자에게는 변경이 즉시 전달되지 않을 수 있습니다.

수정 예시
-      {collect.isSuccess && <p className="text-sm text-green-600">뉴스 수집 완료</p>}
-      {collect.isError && (
-        <p className="text-sm text-red-600">뉴스 수집 실패: {collect.error.message}</p>
-      )}
-      {crawl.isSuccess && <p className="text-sm text-green-600">뉴스 크롤링 완료</p>}
-      {crawl.isError && (
-        <p className="text-sm text-red-600">뉴스 크롤링 실패: {crawl.error.message}</p>
-      )}
+      <div aria-live="polite" role="status">
+        {collect.isSuccess && <p className="text-sm text-green-600">뉴스 수집 완료</p>}
+        {collect.isError && (
+          <p className="text-sm text-red-600">뉴스 수집 실패: {collect.error.message}</p>
+        )}
+        {crawl.isSuccess && <p className="text-sm text-green-600">뉴스 크롤링 완료</p>}
+        {crawl.isError && (
+          <p className="text-sm text-red-600">뉴스 크롤링 실패: {crawl.error.message}</p>
+        )}
+      </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/admin-news/ui/news-collect-buttons.tsx` around lines 27 - 34,
The success/error messages rendered for collect and crawl (collect.isSuccess,
collect.isError, crawl.isSuccess, crawl.isError) need an ARIA live region so
screen readers announce updates; wrap the message area (or each conditional <p>)
in an element with an appropriate aria-live (e.g., aria-live="polite" for
non-critical success and aria-live="assertive" for errors) and ensure
role="status" or role="alert" is set where needed so assistive tech immediately
announces the change in news-collect-buttons.tsx.

</div>
)
}

export default NewsCollectButtons
26 changes: 26 additions & 0 deletions src/pages/admin/ui/admin-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import CollectMarketButton from '@/features/admin-market/ui/collect-market-button'
import MarketSnapshotTable from '@/features/admin-market/ui/market-snapshot-table'
import NewsCollectButtons from '@/features/admin-news/ui/news-collect-buttons'

function AdminPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-wefin-text">관리자</h1>

<section className="rounded-xl border border-wefin-line bg-white p-6">
<h2 className="mb-4 text-lg font-semibold text-wefin-text">뉴스</h2>
<NewsCollectButtons />
</section>

<section className="rounded-xl border border-wefin-line bg-white p-6">
<h2 className="mb-4 text-lg font-semibold text-wefin-text">시장 지표</h2>
<div className="space-y-4">
<CollectMarketButton />
<MarketSnapshotTable />
</div>
</section>
</div>
)
}

export default AdminPage
17 changes: 17 additions & 0 deletions src/shared/api/api-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z, type ZodType } from 'zod'

export function apiResponseSchema<T extends ZodType>(dataSchema: T) {
return z.object({
status: z.number(),
code: z.string().nullable(),
message: z.string().nullable(),
data: dataSchema
})
}

export type ApiResponse<T> = {
status: number
code: string | null
message: string | null
data: T
}
27 changes: 26 additions & 1 deletion src/shared/api/base-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import axios from 'axios'

export const baseApi = axios.create({
baseURL: '/',
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# baseApi로 /mock 경로를 호출하는 지점 확인
rg -nP --type ts --type tsx "baseApi\.(get|post|put|patch|delete)\(\s*['\"]/mock/" -C2

# '/api' prefix 변경 영향 후보 확인 (필요 시 패턴 조정)
rg -nP --type ts --type tsx "baseApi\.(get|post|put|patch|delete)\(\s*['\"]/(?!admin|api)[^'\"]+" -C1

Repository: S0L-V/wefin-fe

Length of output: 113


🏁 Script executed:

#!/bin/bash
# First, check the base-api.ts file to confirm the change
echo "=== Checking src/shared/api/base-api.ts ==="
cat -n src/shared/api/base-api.ts | head -20

echo ""
echo "=== Finding baseApi usages with non-API paths ==="
# Search for baseApi method calls without using unsupported file types
rg "baseApi\.(get|post|put|patch|delete)" --max-count=50 -A2

echo ""
echo "=== Specifically checking fetch-login-dialog-data.ts ==="
if [ -f "src/features/auth-dialog/api/fetch-login-dialog-data.ts" ]; then
  cat -n src/features/auth-dialog/api/fetch-login-dialog-data.ts
fi

Repository: S0L-V/wefin-fe

Length of output: 2507


🏁 Script executed:

#!/bin/bash
echo "=== Checking for public/mock directory ==="
find . -type d -name "mock" -o -type d -name "public" | head -20

echo ""
echo "=== Looking for mock JSON files ==="
find . -name "*.json" -path "*mock*" 2>/dev/null | head -20

echo ""
echo "=== Complete baseApi method calls (exhaustive search) ==="
rg "baseApi\.(get|post|put|patch|delete|request)" -A1 | wc -l

echo ""
echo "=== All baseApi calls with their paths ==="
rg "baseApi\.(get|post|put|patch|delete)" -o --only-matching | sort | uniq -c

Repository: S0L-V/wefin-fe

Length of output: 589


기본 baseURL 변경으로 mock 데이터 로딩이 깨집니다.

Line 4 변경으로 baseApibaseURL/api로 설정되면서, src/features/auth-dialog/api/fetch-login-dialog-data.ts에서 baseApi.get('/mock/login-dialog.json')을 호출할 때 실제 요청이 /api/mock/login-dialog.json으로 라우팅됩니다. 하지만 정적 리소스는 /public/mock/login-dialog.json에 있어 요청이 실패합니다.

/admin/* 경로는 API 엔드포인트이므로 영향을 받지 않지만, /mock 리소스만 별도 처리가 필요합니다.

해결 방향 예시
 export const baseApi = axios.create({
   baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
   timeout: 5_000
 })
+
+export const staticApi = axios.create({
+  baseURL: '/',
+  timeout: 5_000
+})
// src/features/auth-dialog/api/fetch-login-dialog-data.ts
-import { baseApi } from '@/shared/api/base-api'
+import { staticApi } from '@/shared/api/base-api'
 
-const response = await baseApi.get('/mock/login-dialog.json')
+const response = await staticApi.get('/mock/login-dialog.json')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/api/base-api.ts` at line 4, The change to baseApi's baseURL causes
requests to '/mock/*' to be routed to '/api/mock/*' and break static resource
loading; fix by adding/exporting a separate axios instance (e.g., staticApi) in
base-api.ts that mirrors baseApi's configuration but uses no baseURL or
baseURL='/' for raw static requests, then update
src/features/auth-dialog/api/fetch-login-dialog-data.ts to import staticApi
instead of baseApi and call staticApi.get('/mock/login-dialog.json') so the
request resolves to the public static path.

timeout: 5_000
})

export class ApiError extends Error {
readonly status: number
readonly code: string

constructor(status: number, code: string, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}

baseApi.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.data) {
const { status, code, message } = error.response.data
if (code && message) {
return Promise.reject(new ApiError(status, code, message))
}
}
return Promise.reject(error)
}
)
8 changes: 8 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,13 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
Loading