ITN Dev 07-22
feat: 시안 좌측 메뉴(사이드바)와 상단바·로그아웃 추가
@ab9134aba20e57d3fe4367c745872682e975023d
.gitignore
--- .gitignore
+++ .gitignore
@@ -8,3 +8,5 @@
 .idea/
 *.iml
 .superpowers/
+run-local.ps1
+archive-smoke.ps1
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -1,9 +1,10 @@
 import { useCallback, useEffect, useState } from 'react'
-import { ApiError, getOrgs, type Org } from './api/client'
+import { ApiError, getOrgs, logout, type Org } from './api/client'
 import LoginPage from './components/LoginPage'
 import OrgDetail from './components/OrgDetail'
 import OrgList from './components/OrgList'
 import SeedUpload from './components/SeedUpload'
+import Sidebar from './components/Sidebar'
 
 // 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
 // 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다.
@@ -63,20 +64,52 @@
 
   const selected = orgs.find((org) => org.id === selectedId) ?? null
 
+  async function handleLogout() {
+    try {
+      await logout()
+    } finally {
+      // 서버 응답과 무관하게 화면은 로그인으로 돌린다. 세션이 이미 만료된 경우에도
+      // 사용자를 붙잡아 둘 이유가 없다.
+      setOrgs([])
+      setSelectedId(null)
+      setAuth('anonymous')
+    }
+  }
+
   return (
     <div className="flex h-screen">
-      <div className="flex w-80 flex-col border-r border-gray-200">
-        <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
-        <SeedUpload onUploaded={() => void reload()} />
-      </div>
+      <Sidebar />
 
-      <main className="flex-1 overflow-y-auto">
-        {selected ? (
-          <OrgDetail org={selected} onChanged={() => void reload()} />
-        ) : (
-          <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
-        )}
-      </main>
+      <div className="flex min-w-0 flex-1 flex-col">
+        <header className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-6">
+          <span className="text-sm font-medium">기관관리</span>
+          <div className="flex items-center gap-3">
+            <span className="text-sm text-gray-500">관리자</span>
+            <button
+              type="button"
+              onClick={() => void handleLogout()}
+              className="rounded-md border border-gray-300 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
+            >
+              로그아웃
+            </button>
+          </div>
+        </header>
+
+        <div className="flex min-h-0 flex-1">
+          <div className="flex w-80 flex-col border-r border-gray-200">
+            <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
+            <SeedUpload onUploaded={() => void reload()} />
+          </div>
+
+          <main className="flex-1 overflow-y-auto">
+            {selected ? (
+              <OrgDetail org={selected} onChanged={() => void reload()} />
+            ) : (
+              <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
+            )}
+          </main>
+        </div>
+      </div>
     </div>
   )
 }
frontend/src/api/client.test.ts
--- frontend/src/api/client.test.ts
+++ frontend/src/api/client.test.ts
@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { ApiError, getOrgs, provisionChannels, updateContact } from './client'
+import { ApiError, getOrgs, logout, provisionChannels, updateContact } from './client'
 
 afterEach(() => {
   vi.unstubAllGlobals()
@@ -35,6 +35,20 @@
     expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
   })
 
+  it('로그아웃은 CSRF 헤더를 붙여 POST한다', async () => {
+    document.cookie = 'XSRF-TOKEN=token-value'
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 })
+    vi.stubGlobal('fetch', fetchMock)
+
+    await logout()
+
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('/api/auth/logout')
+    expect(init.method).toBe('POST')
+    expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
+    expect(init.credentials).toBe('same-origin')
+  })
+
   it('응답이 실패면 status를 담은 ApiError를 던진다', async () => {
     vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
       ok: false,
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -97,6 +97,18 @@
   return request<SeedReport>('/api/seed', { method: 'POST', body: form })
 }
 
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */
+export async function logout(): Promise<void> {
+  const response = await fetch('/api/auth/logout', {
+    method: 'POST',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    throw new ApiError(response.status, `로그아웃 실패 (${response.status})`)
+  }
+}
+
 export async function login(username: string, password: string): Promise<void> {
   const body = new URLSearchParams({ username, password })
   const response = await fetch('/api/auth/login', {
 
frontend/src/components/Sidebar.test.tsx (added)
+++ frontend/src/components/Sidebar.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Sidebar from './Sidebar'
+
+describe('Sidebar', () => {
+  it('시안의 메뉴 8개를 순서대로 보여준다', () => {
+    render(<Sidebar />)
+
+    const labels = [
+      'Dashboard',
+      '기관관리',
+      '사업관리',
+      '권리확인',
+      '권리처리',
+      '자료실',
+      'Mattermost',
+      '시스템관리',
+    ]
+    const items = screen.getAllByRole('listitem').map((li) => li.textContent)
+    expect(items).toEqual(labels)
+  })
+
+  it('기관관리가 현재 페이지로 표시된다', () => {
+    render(<Sidebar />)
+
+    const active = screen.getByRole('link', { name: '기관관리' })
+    expect(active).toHaveAttribute('aria-current', 'page')
+  })
+
+  it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
+    render(<Sidebar />)
+
+    const dashboard = screen.getByText('Dashboard')
+    expect(dashboard).toHaveAttribute('aria-disabled', 'true')
+    expect(dashboard).toHaveAttribute('title', '준비 중')
+    expect(screen.queryByRole('link', { name: 'Dashboard' })).toBeNull()
+  })
+
+  it('브랜드 영역을 보여준다', () => {
+    render(<Sidebar />)
+
+    expect(screen.getByText('ITN-HUB')).toBeTruthy()
+    expect(screen.getByText('사업관리시스템')).toBeTruthy()
+  })
+})
 
frontend/src/components/Sidebar.tsx (added)
+++ frontend/src/components/Sidebar.tsx
@@ -0,0 +1,147 @@
+// 3단계 대시보드 시안의 좌측 메뉴. 아직 화면이 있는 것은 기관관리뿐이라
+// 나머지 항목은 비활성(준비 중)으로 두고 자리만 잡는다 — 시안과 같은 골격을
+// 먼저 세워 두면 다음 모듈(Mattermost 채팅 등)이 붙을 때 메뉴만 활성화하면 된다.
+
+interface MenuItem {
+  key: string
+  label: string
+  enabled: boolean
+  icon: JSX.Element
+}
+
+const ICON_CLASS = 'h-4 w-4 shrink-0'
+
+const MENU: MenuItem[] = [
+  {
+    key: 'dashboard',
+    label: 'Dashboard',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <rect x="3" y="3" width="7" height="7" rx="1" />
+        <rect x="14" y="3" width="7" height="7" rx="1" />
+        <rect x="3" y="14" width="7" height="7" rx="1" />
+        <rect x="14" y="14" width="7" height="7" rx="1" />
+      </svg>
+    ),
+  },
+  {
+    key: 'orgs',
+    label: '기관관리',
+    enabled: true,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M3 21h18M5 21V5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v16M9 8h2M9 12h2M9 16h2M15 10h4v11" />
+      </svg>
+    ),
+  },
+  {
+    key: 'projects',
+    label: '사업관리',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <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" />
+      </svg>
+    ),
+  },
+  {
+    key: 'rights-check',
+    label: '권리확인',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3z" />
+        <path d="M9 12l2 2 4-4" />
+      </svg>
+    ),
+  },
+  {
+    key: 'rights-process',
+    label: '권리처리',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
+        <path d="M14 3v5h5M9 13h6M9 17h6" />
+      </svg>
+    ),
+  },
+  {
+    key: 'files',
+    label: '자료실',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M3 7h18v4H3zM5 11v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9M10 15h4" />
+      </svg>
+    ),
+  },
+  {
+    key: 'mattermost',
+    label: 'Mattermost',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M21 12a8 8 0 1 1-4-6.9L21 4l-1.1 4A8 8 0 0 1 21 12z" />
+      </svg>
+    ),
+  },
+  {
+    key: 'system',
+    label: '시스템관리',
+    enabled: false,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <circle cx="12" cy="12" r="3" />
+        <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" />
+      </svg>
+    ),
+  },
+]
+
+export default function Sidebar({ activeKey = 'orgs' }: { activeKey?: string }) {
+  return (
+    <aside className="flex w-56 shrink-0 flex-col border-r border-gray-200 bg-white">
+      <div className="px-5 pb-4 pt-5">
+        <p className="text-lg font-bold tracking-tight">ITN-HUB</p>
+        <p className="text-xs text-gray-500">사업관리시스템</p>
+      </div>
+
+      <nav aria-label="주 메뉴">
+        <ul className="space-y-0.5 px-2">
+          {MENU.map((item) =>
+            item.enabled ? (
+              <li key={item.key}>
+                <a
+                  href="/"
+                  onClick={(e) => e.preventDefault()}
+                  aria-current={item.key === activeKey ? 'page' : undefined}
+                  className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm ${
+                    item.key === activeKey
+                      ? 'bg-gray-100 font-medium text-gray-900'
+                      : 'text-gray-700 hover:bg-gray-50'
+                  }`}
+                >
+                  {item.icon}
+                  {item.label}
+                </a>
+              </li>
+            ) : (
+              <li key={item.key}>
+                <span
+                  title="준비 중"
+                  aria-disabled="true"
+                  className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-400"
+                >
+                  {item.icon}
+                  {item.label}
+                </span>
+              </li>
+            ),
+          )}
+        </ul>
+      </nav>
+    </aside>
+  )
+}
Add a comment
List