feat: 시안 좌측 메뉴(사이드바)와 상단바·로그아웃 추가
@ab9134aba20e57d3fe4367c745872682e975023d
--- .gitignore
+++ .gitignore
... | ... | @@ -8,3 +8,5 @@ |
| 8 | 8 |
.idea/ |
| 9 | 9 |
*.iml |
| 10 | 10 |
.superpowers/ |
| 11 |
+run-local.ps1 |
|
| 12 |
+archive-smoke.ps1 |
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 |
import { useCallback, useEffect, useState } from 'react'
|
| 2 |
-import { ApiError, getOrgs, type Org } from './api/client'
|
|
| 2 |
+import { ApiError, getOrgs, logout, type Org } from './api/client'
|
|
| 3 | 3 |
import LoginPage from './components/LoginPage' |
| 4 | 4 |
import OrgDetail from './components/OrgDetail' |
| 5 | 5 |
import OrgList from './components/OrgList' |
| 6 | 6 |
import SeedUpload from './components/SeedUpload' |
| 7 |
+import Sidebar from './components/Sidebar' |
|
| 7 | 8 |
|
| 8 | 9 |
// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도 |
| 9 | 10 |
// 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다. |
... | ... | @@ -63,20 +64,52 @@ |
| 63 | 64 |
|
| 64 | 65 |
const selected = orgs.find((org) => org.id === selectedId) ?? null |
| 65 | 66 |
|
| 67 |
+ async function handleLogout() {
|
|
| 68 |
+ try {
|
|
| 69 |
+ await logout() |
|
| 70 |
+ } finally {
|
|
| 71 |
+ // 서버 응답과 무관하게 화면은 로그인으로 돌린다. 세션이 이미 만료된 경우에도 |
|
| 72 |
+ // 사용자를 붙잡아 둘 이유가 없다. |
|
| 73 |
+ setOrgs([]) |
|
| 74 |
+ setSelectedId(null) |
|
| 75 |
+ setAuth('anonymous')
|
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 66 | 79 |
return ( |
| 67 | 80 |
<div className="flex h-screen"> |
| 68 |
- <div className="flex w-80 flex-col border-r border-gray-200"> |
|
| 69 |
- <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
|
|
| 70 |
- <SeedUpload onUploaded={() => void reload()} />
|
|
| 71 |
- </div> |
|
| 81 |
+ <Sidebar /> |
|
| 72 | 82 |
|
| 73 |
- <main className="flex-1 overflow-y-auto"> |
|
| 74 |
- {selected ? (
|
|
| 75 |
- <OrgDetail org={selected} onChanged={() => void reload()} />
|
|
| 76 |
- ) : ( |
|
| 77 |
- <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p> |
|
| 78 |
- )} |
|
| 79 |
- </main> |
|
| 83 |
+ <div className="flex min-w-0 flex-1 flex-col"> |
|
| 84 |
+ <header className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-6"> |
|
| 85 |
+ <span className="text-sm font-medium">기관관리</span> |
|
| 86 |
+ <div className="flex items-center gap-3"> |
|
| 87 |
+ <span className="text-sm text-gray-500">관리자</span> |
|
| 88 |
+ <button |
|
| 89 |
+ type="button" |
|
| 90 |
+ onClick={() => void handleLogout()}
|
|
| 91 |
+ className="rounded-md border border-gray-300 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50" |
|
| 92 |
+ > |
|
| 93 |
+ 로그아웃 |
|
| 94 |
+ </button> |
|
| 95 |
+ </div> |
|
| 96 |
+ </header> |
|
| 97 |
+ |
|
| 98 |
+ <div className="flex min-h-0 flex-1"> |
|
| 99 |
+ <div className="flex w-80 flex-col border-r border-gray-200"> |
|
| 100 |
+ <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
|
|
| 101 |
+ <SeedUpload onUploaded={() => void reload()} />
|
|
| 102 |
+ </div> |
|
| 103 |
+ |
|
| 104 |
+ <main className="flex-1 overflow-y-auto"> |
|
| 105 |
+ {selected ? (
|
|
| 106 |
+ <OrgDetail org={selected} onChanged={() => void reload()} />
|
|
| 107 |
+ ) : ( |
|
| 108 |
+ <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p> |
|
| 109 |
+ )} |
|
| 110 |
+ </main> |
|
| 111 |
+ </div> |
|
| 112 |
+ </div> |
|
| 80 | 113 |
</div> |
| 81 | 114 |
) |
| 82 | 115 |
} |
--- frontend/src/api/client.test.ts
+++ frontend/src/api/client.test.ts
... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 |
import { afterEach, describe, expect, it, vi } from 'vitest'
|
| 2 |
-import { ApiError, getOrgs, provisionChannels, updateContact } from './client'
|
|
| 2 |
+import { ApiError, getOrgs, logout, provisionChannels, updateContact } from './client'
|
|
| 3 | 3 |
|
| 4 | 4 |
afterEach(() => {
|
| 5 | 5 |
vi.unstubAllGlobals() |
... | ... | @@ -35,6 +35,20 @@ |
| 35 | 35 |
expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
|
| 36 | 36 |
}) |
| 37 | 37 |
|
| 38 |
+ it('로그아웃은 CSRF 헤더를 붙여 POST한다', async () => {
|
|
| 39 |
+ document.cookie = 'XSRF-TOKEN=token-value' |
|
| 40 |
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 })
|
|
| 41 |
+ vi.stubGlobal('fetch', fetchMock)
|
|
| 42 |
+ |
|
| 43 |
+ await logout() |
|
| 44 |
+ |
|
| 45 |
+ const [url, init] = fetchMock.mock.calls[0] |
|
| 46 |
+ expect(url).toBe('/api/auth/logout')
|
|
| 47 |
+ expect(init.method).toBe('POST')
|
|
| 48 |
+ expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
|
|
| 49 |
+ expect(init.credentials).toBe('same-origin')
|
|
| 50 |
+ }) |
|
| 51 |
+ |
|
| 38 | 52 |
it('응답이 실패면 status를 담은 ApiError를 던진다', async () => {
|
| 39 | 53 |
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
| 40 | 54 |
ok: false, |
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -97,6 +97,18 @@ |
| 97 | 97 |
return request<SeedReport>('/api/seed', { method: 'POST', body: form })
|
| 98 | 98 |
} |
| 99 | 99 |
|
| 100 |
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ |
|
| 101 |
+export async function logout(): Promise<void> {
|
|
| 102 |
+ const response = await fetch('/api/auth/logout', {
|
|
| 103 |
+ method: 'POST', |
|
| 104 |
+ credentials: 'same-origin', |
|
| 105 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 106 |
+ }) |
|
| 107 |
+ if (!response.ok) {
|
|
| 108 |
+ throw new ApiError(response.status, `로그아웃 실패 (${response.status})`)
|
|
| 109 |
+ } |
|
| 110 |
+} |
|
| 111 |
+ |
|
| 100 | 112 |
export async function login(username: string, password: string): Promise<void> {
|
| 101 | 113 |
const body = new URLSearchParams({ username, password })
|
| 102 | 114 |
const response = await fetch('/api/auth/login', {
|
+++ frontend/src/components/Sidebar.test.tsx
... | ... | @@ -0,0 +1,45 @@ |
| 1 | +import { render, screen } from '@testing-library/react' | |
| 2 | +import { describe, expect, it } from 'vitest' | |
| 3 | +import Sidebar from './Sidebar' | |
| 4 | + | |
| 5 | +describe('Sidebar', () => { | |
| 6 | + it('시안의 메뉴 8개를 순서대로 보여준다', () => { | |
| 7 | + render(<Sidebar />) | |
| 8 | + | |
| 9 | + const labels = [ | |
| 10 | + 'Dashboard', | |
| 11 | + '기관관리', | |
| 12 | + '사업관리', | |
| 13 | + '권리확인', | |
| 14 | + '권리처리', | |
| 15 | + '자료실', | |
| 16 | + 'Mattermost', | |
| 17 | + '시스템관리', | |
| 18 | + ] | |
| 19 | + const items = screen.getAllByRole('listitem').map((li) => li.textContent) | |
| 20 | + expect(items).toEqual(labels) | |
| 21 | + }) | |
| 22 | + | |
| 23 | + it('기관관리가 현재 페이지로 표시된다', () => { | |
| 24 | + render(<Sidebar />) | |
| 25 | + | |
| 26 | + const active = screen.getByRole('link', { name: '기관관리' }) | |
| 27 | + expect(active).toHaveAttribute('aria-current', 'page') | |
| 28 | + }) | |
| 29 | + | |
| 30 | + it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => { | |
| 31 | + render(<Sidebar />) | |
| 32 | + | |
| 33 | + const dashboard = screen.getByText('Dashboard') | |
| 34 | + expect(dashboard).toHaveAttribute('aria-disabled', 'true') | |
| 35 | + expect(dashboard).toHaveAttribute('title', '준비 중') | |
| 36 | + expect(screen.queryByRole('link', { name: 'Dashboard' })).toBeNull() | |
| 37 | + }) | |
| 38 | + | |
| 39 | + it('브랜드 영역을 보여준다', () => { | |
| 40 | + render(<Sidebar />) | |
| 41 | + | |
| 42 | + expect(screen.getByText('ITN-HUB')).toBeTruthy() | |
| 43 | + expect(screen.getByText('사업관리시스템')).toBeTruthy() | |
| 44 | + }) | |
| 45 | +}) |
+++ frontend/src/components/Sidebar.tsx
... | ... | @@ -0,0 +1,147 @@ |
| 1 | +// 3단계 대시보드 시안의 좌측 메뉴. 아직 화면이 있는 것은 기관관리뿐이라 | |
| 2 | +// 나머지 항목은 비활성(준비 중)으로 두고 자리만 잡는다 — 시안과 같은 골격을 | |
| 3 | +// 먼저 세워 두면 다음 모듈(Mattermost 채팅 등)이 붙을 때 메뉴만 활성화하면 된다. | |
| 4 | + | |
| 5 | +interface MenuItem { | |
| 6 | + key: string | |
| 7 | + label: string | |
| 8 | + enabled: boolean | |
| 9 | + icon: JSX.Element | |
| 10 | +} | |
| 11 | + | |
| 12 | +const ICON_CLASS = 'h-4 w-4 shrink-0' | |
| 13 | + | |
| 14 | +const MENU: MenuItem[] = [ | |
| 15 | + { | |
| 16 | + key: 'dashboard', | |
| 17 | + label: 'Dashboard', | |
| 18 | + enabled: false, | |
| 19 | + icon: ( | |
| 20 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 21 | + <rect x="3" y="3" width="7" height="7" rx="1" /> | |
| 22 | + <rect x="14" y="3" width="7" height="7" rx="1" /> | |
| 23 | + <rect x="3" y="14" width="7" height="7" rx="1" /> | |
| 24 | + <rect x="14" y="14" width="7" height="7" rx="1" /> | |
| 25 | + </svg> | |
| 26 | + ), | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + key: 'orgs', | |
| 30 | + label: '기관관리', | |
| 31 | + enabled: true, | |
| 32 | + icon: ( | |
| 33 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 34 | + <path d="M3 21h18M5 21V5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v16M9 8h2M9 12h2M9 16h2M15 10h4v11" /> | |
| 35 | + </svg> | |
| 36 | + ), | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + key: 'projects', | |
| 40 | + label: '사업관리', | |
| 41 | + enabled: false, | |
| 42 | + icon: ( | |
| 43 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 44 | + <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" /> | |
| 45 | + </svg> | |
| 46 | + ), | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + key: 'rights-check', | |
| 50 | + label: '권리확인', | |
| 51 | + enabled: false, | |
| 52 | + icon: ( | |
| 53 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 54 | + <path d="M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3z" /> | |
| 55 | + <path d="M9 12l2 2 4-4" /> | |
| 56 | + </svg> | |
| 57 | + ), | |
| 58 | + }, | |
| 59 | + { | |
| 60 | + key: 'rights-process', | |
| 61 | + label: '권리처리', | |
| 62 | + enabled: false, | |
| 63 | + icon: ( | |
| 64 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 65 | + <path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" /> | |
| 66 | + <path d="M14 3v5h5M9 13h6M9 17h6" /> | |
| 67 | + </svg> | |
| 68 | + ), | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + key: 'files', | |
| 72 | + label: '자료실', | |
| 73 | + enabled: false, | |
| 74 | + icon: ( | |
| 75 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 76 | + <path d="M3 7h18v4H3zM5 11v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9M10 15h4" /> | |
| 77 | + </svg> | |
| 78 | + ), | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + key: 'mattermost', | |
| 82 | + label: 'Mattermost', | |
| 83 | + enabled: false, | |
| 84 | + icon: ( | |
| 85 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 86 | + <path d="M21 12a8 8 0 1 1-4-6.9L21 4l-1.1 4A8 8 0 0 1 21 12z" /> | |
| 87 | + </svg> | |
| 88 | + ), | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + key: 'system', | |
| 92 | + label: '시스템관리', | |
| 93 | + enabled: false, | |
| 94 | + icon: ( | |
| 95 | + <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| 96 | + <circle cx="12" cy="12" r="3" /> | |
| 97 | + <path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2.4l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.06-.4.1-.8.1-1.2z" /> | |
| 98 | + </svg> | |
| 99 | + ), | |
| 100 | + }, | |
| 101 | +] | |
| 102 | + | |
| 103 | +export default function Sidebar({ activeKey = 'orgs' }: { activeKey?: string }) { | |
| 104 | + return ( | |
| 105 | + <aside className="flex w-56 shrink-0 flex-col border-r border-gray-200 bg-white"> | |
| 106 | + <div className="px-5 pb-4 pt-5"> | |
| 107 | + <p className="text-lg font-bold tracking-tight">ITN-HUB</p> | |
| 108 | + <p className="text-xs text-gray-500">사업관리시스템</p> | |
| 109 | + </div> | |
| 110 | + | |
| 111 | + <nav aria-label="주 메뉴"> | |
| 112 | + <ul className="space-y-0.5 px-2"> | |
| 113 | + {MENU.map((item) => | |
| 114 | + item.enabled ? ( | |
| 115 | + <li key={item.key}> | |
| 116 | + <a | |
| 117 | + href="/" | |
| 118 | + onClick={(e) => e.preventDefault()} | |
| 119 | + aria-current={item.key === activeKey ? 'page' : undefined} | |
| 120 | + className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm ${ | |
| 121 | + item.key === activeKey | |
| 122 | + ? 'bg-gray-100 font-medium text-gray-900' | |
| 123 | + : 'text-gray-700 hover:bg-gray-50' | |
| 124 | + }`} | |
| 125 | + > | |
| 126 | + {item.icon} | |
| 127 | + {item.label} | |
| 128 | + </a> | |
| 129 | + </li> | |
| 130 | + ) : ( | |
| 131 | + <li key={item.key}> | |
| 132 | + <span | |
| 133 | + title="준비 중" | |
| 134 | + aria-disabled="true" | |
| 135 | + className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-400" | |
| 136 | + > | |
| 137 | + {item.icon} | |
| 138 | + {item.label} | |
| 139 | + </span> | |
| 140 | + </li> | |
| 141 | + ), | |
| 142 | + )} | |
| 143 | + </ul> | |
| 144 | + </nav> | |
| 145 | + </aside> | |
| 146 | + ) | |
| 147 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?