diff --git a/backend/src/auth/authMiddleware.ts b/backend/src/auth/authMiddleware.ts index 4193f54..0a2fc47 100644 --- a/backend/src/auth/authMiddleware.ts +++ b/backend/src/auth/authMiddleware.ts @@ -1,11 +1,13 @@ import type { Request, Response, NextFunction } from 'express' import { verifyToken, getTokenFromCookie } from './jwtProvider.js' import type { JwtPayload } from './jwtProvider.js' +import { getUserInfo } from './authService.js' declare global { namespace Express { interface Request { user?: JwtPayload + resolvedPermissions?: Record } } } @@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) { next() } } + +/** + * 리소스 + 오퍼레이션 기반 권한 검사 미들웨어. + * + * OPER_CD는 HTTP Method가 아닌 비즈니스 의미로 결정한다. + * 오퍼레이션 미지정 시 기본 'READ'. + * + * 사용 예: + * router.post('/notice/list', requirePermission('board:notice', 'READ'), handler) + * router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler) + * router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler) + * router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler) + */ +export function requirePermission(resource: string, operation: string = 'READ') { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.user) { + res.status(401).json({ error: '인증이 필요합니다.' }) + return + } + + try { + // req에 캐싱된 permissions 재사용 (요청당 1회만 DB 조회) + if (!req.resolvedPermissions) { + const userInfo = await getUserInfo(req.user.sub) + req.resolvedPermissions = userInfo.permissions + } + + const allowedOps = req.resolvedPermissions[resource] + if (allowedOps && allowedOps.includes(operation)) { + next() + return + } + + res.status(403).json({ error: '접근 권한이 없습니다.' }) + } catch (err) { + console.error('[auth] 권한 확인 오류:', err) + res.status(500).json({ error: '권한 확인 중 오류가 발생했습니다.' }) + } + } +} diff --git a/backend/src/auth/authService.ts b/backend/src/auth/authService.ts index 1be0467..0d607b7 100644 --- a/backend/src/auth/authService.ts +++ b/backend/src/auth/authService.ts @@ -2,6 +2,8 @@ import bcrypt from 'bcrypt' import { authPool } from '../db/authDb.js' import { signToken, setTokenCookie } from './jwtProvider.js' import type { Response } from 'express' +import { resolvePermissions, makePermKey, grantedSetToRecord } from '../roles/permResolver.js' +import { getPermTreeNodes } from '../roles/roleService.js' const MAX_FAIL_COUNT = 5 const SALT_ROUNDS = 10 @@ -24,7 +26,7 @@ interface AuthUserInfo { rank: string | null org: { sn: number; name: string; abbr: string } | null roles: string[] - permissions: string[] + permissions: Record } export async function login( @@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise { const row = userResult.rows[0] - // 역할 조회 + // 역할 조회 (ROLE_SN + ROLE_CD) const rolesResult = await authPool.query( - `SELECT r.ROLE_CD as role_cd + `SELECT r.ROLE_SN as role_sn, r.ROLE_CD as role_cd FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`, @@ -137,17 +139,63 @@ export async function getUserInfo(userId: string): Promise { ) const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd) + const roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn) - // 권한 조회 (역할 기반) - const permsResult = await authPool.query( - `SELECT DISTINCT p.RSRC_CD as rsrc_cd - FROM AUTH_PERM p - JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN - WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, - [userId] - ) + // 트리 기반 resolved permissions (리소스 × 오퍼레이션) + let permissions: Record + try { + const treeNodes = await getPermTreeNodes() - const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd) + if (treeNodes.length > 0) { + // AUTH_PERM_TREE가 존재 → 트리 기반 resolve + const explicitPermsResult = await authPool.query( + `SELECT ROLE_SN as role_sn, RSRC_CD as rsrc_cd, OPER_CD as oper_cd, GRANT_YN as grant_yn + FROM AUTH_PERM WHERE ROLE_SN = ANY($1)`, + [roleSns] + ) + + const explicitPermsPerRole = new Map>() + for (const sn of roleSns) { + explicitPermsPerRole.set(sn, new Map()) + } + for (const p of explicitPermsResult.rows) { + const roleMap = explicitPermsPerRole.get(p.role_sn) + if (roleMap) { + const key = makePermKey(p.rsrc_cd, p.oper_cd) + roleMap.set(key, p.grant_yn === 'Y') + } + } + + const granted = resolvePermissions(treeNodes, explicitPermsPerRole) + permissions = grantedSetToRecord(granted) + } else { + // AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback + const permsResult = await authPool.query( + `SELECT DISTINCT p.RSRC_CD as rsrc_cd + FROM AUTH_PERM p + JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN + WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, + [userId] + ) + permissions = {} + for (const p of permsResult.rows) { + permissions[p.rsrc_cd] = ['READ'] + } + } + } catch { + // AUTH_PERM_TREE 테이블 미존재 시 fallback + const permsResult = await authPool.query( + `SELECT DISTINCT p.RSRC_CD as rsrc_cd + FROM AUTH_PERM p + JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN + WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, + [userId] + ) + permissions = {} + for (const p of permsResult.rows) { + permissions[p.rsrc_cd] = ['READ'] + } + } return { id: row.user_id, diff --git a/backend/src/board/boardRouter.ts b/backend/src/board/boardRouter.ts new file mode 100644 index 0000000..e854c5c --- /dev/null +++ b/backend/src/board/boardRouter.ts @@ -0,0 +1,137 @@ +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' +import { AuthError } from '../auth/authService.js' +import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js' + +const router = Router() + +// 카테고리 → 리소스 매핑 +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +} + +// ============================================================ +// GET /api/board — 게시글 목록 +// ============================================================ +router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => { + try { + const { categoryCd, search, page, size } = req.query + const result = await listPosts({ + categoryCd: categoryCd as string | undefined, + search: search as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + size: size ? parseInt(size as string, 10) : undefined, + }) + res.json(result) + } catch (err) { + console.error('[board] 목록 조회 오류:', err) + res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// GET /api/board/:sn — 게시글 상세 +// ============================================================ +router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + const post = await getPost(sn) + res.json(post) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 상세 조회 오류:', err) + res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한) +// ============================================================ +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' + requirePermission(resource, 'CREATE')(req, res, next) +}, async (req, res) => { + try { + const { categoryCd, title, content, pinnedYn } = req.body + + if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + + const result = await createPost({ + categoryCd, + title, + content, + authorId: req.user!.sub, + pinnedYn, + }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 작성 오류:', err) + res.status(500).json({ error: '게시글 작성 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서) +// ============================================================ +router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + + const { title, content, pinnedYn } = req.body + await updatePost(sn, { title, content, pinnedYn }, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 수정 오류:', err) + res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증) +// ============================================================ +router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + + await deletePost(sn, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 삭제 오류:', err) + res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/board/boardService.ts b/backend/src/board/boardService.ts new file mode 100644 index 0000000..1b554a7 --- /dev/null +++ b/backend/src/board/boardService.ts @@ -0,0 +1,243 @@ +import { wingPool } from '../db/wingDb.js' +import { AuthError } from '../auth/authService.js' + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface PostListItem { + sn: number + categoryCd: string + title: string + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + regDtm: string +} + +interface PostDetail extends PostListItem { + content: string | null + mdfcnDtm: string | null +} + +interface ListPostsInput { + categoryCd?: string + search?: string + page?: number + size?: number +} + +interface ListPostsResult { + items: PostListItem[] + totalCount: number + page: number + size: number +} + +interface CreatePostInput { + categoryCd: string + title: string + content?: string + authorId: string + pinnedYn?: string +} + +interface UpdatePostInput { + title?: string + content?: string + pinnedYn?: string +} + +// ============================================================ +// CRUD 함수 +// ============================================================ + +const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL'] + +export async function listPosts(input: ListPostsInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1 + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 + const offset = (page - 1) * size + + let whereClause = `WHERE bp.USE_YN = 'Y'` + const params: (string | number)[] = [] + let paramIdx = 1 + + if (input.categoryCd) { + whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` + params.push(input.categoryCd) + } + + if (input.search) { + whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` + params.push(`%${input.search}%`) + paramIdx++ + } + + // 전체 건수 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 목록 (상단고정 우선, 등록일 내림차순) + const listParams = [...params, size, offset] + const listResult = await wingPool.query( + `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, + bp.AUTHOR_ID as author_id, u.USER_NM as author_name, + bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, + bp.REG_DTM as reg_dtm + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause} + ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams + ) + + const items: PostListItem[] = listResult.rows.map((r: Record) => ({ + sn: r.sn as number, + categoryCd: r.category_cd as string, + title: r.title as string, + authorId: r.author_id as string, + authorName: r.author_name as string, + viewCnt: r.view_cnt as number, + pinnedYn: r.pinned_yn as string, + regDtm: r.reg_dtm as string, + })) + + return { items, totalCount, page, size } +} + +export async function getPost(postSn: number): Promise { + // 조회수 증가 + 상세 조회 (단일 쿼리) + const result = await wingPool.query( + `UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1 + WHERE POST_SN = $1 AND USE_YN = 'Y' + RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title, + CONTENT as content, AUTHOR_ID as author_id, + VIEW_CNT as view_cnt, PINNED_YN as pinned_yn, + REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`, + [postSn] + ) + + if (result.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + + // 작성자명 조회 + const authorResult = await wingPool.query( + 'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1', + [row.author_id] + ) + + return { + sn: row.sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: authorResult.rows[0]?.name || '알 수 없음', + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + } +} + +export async function createPost(input: CreatePostInput): Promise<{ sn: number }> { + if (!VALID_CATEGORIES.includes(input.categoryCd)) { + throw new AuthError('유효하지 않은 카테고리입니다.', 400) + } + + if (!input.title || input.title.trim().length === 0) { + throw new AuthError('제목은 필수입니다.', 400) + } + + const result = await wingPool.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) + VALUES ($1, $2, $3, $4, $5) + RETURNING POST_SN as sn`, + [input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N'] + ) + + return { sn: result.rows[0].sn } +} + +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string +): Promise { + // 게시글 존재 + 작성자 확인 + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.title !== undefined) { + sets.push(`TITLE = $${idx++}`) + params.push(input.title.trim()) + } + if (input.content !== undefined) { + sets.push(`CONTENT = $${idx++}`) + params.push(input.content) + } + if (input.pinnedYn !== undefined) { + sets.push(`PINNED_YN = $${idx++}`) + params.push(input.pinnedYn) + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + sets.push('MDFCN_DTM = NOW()') + params.push(postSn) + + await wingPool.query( + `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, + params + ) +} + +export async function deletePost(postSn: number, requesterId: string): Promise { + // 게시글 존재 + 작성자 확인 + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) + } + + // 논리 삭제 + await wingPool.query( + `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, + [postSn] + ) +} diff --git a/backend/src/db/authDb.ts b/backend/src/db/authDb.ts index d82613c..68d06d9 100644 --- a/backend/src/db/authDb.ts +++ b/backend/src/db/authDb.ts @@ -1,33 +1,13 @@ -import pg from 'pg' +// ============================================================ +// 하위 호환: authPool → wingPool re-export +// DB 통합으로 wing_auth DB가 wing DB의 auth 스키마로 이전됨. +// 기존 코드에서 authPool을 import하는 곳에서 에러 없이 동작하도록 유지. +// 신규 코드는 wingDb.ts의 wingPool을 직접 import할 것. +// ============================================================ +import { wingPool, testWingDbConnection } from './wingDb.js' -const { Pool } = pg - -const authPool = new Pool({ - host: process.env.AUTH_DB_HOST || 'localhost', - port: Number(process.env.AUTH_DB_PORT) || 5432, - database: process.env.AUTH_DB_NAME || 'wing_auth', - user: process.env.AUTH_DB_USER || 'wing_auth', - password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026', - max: 10, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, -}) - -authPool.on('error', (err) => { - console.error('[authDb] 예기치 않은 연결 오류:', err.message) -}) +export const authPool = wingPool export async function testAuthDbConnection(): Promise { - try { - const client = await authPool.connect() - await client.query('SELECT 1') - client.release() - console.log('[authDb] wing_auth 데이터베이스 연결 성공') - return true - } catch (err) { - console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message) - return false - } + return testWingDbConnection() } - -export { authPool } diff --git a/backend/src/db/wingDb.ts b/backend/src/db/wingDb.ts index 83648a7..5e257d4 100644 --- a/backend/src/db/wingDb.ts +++ b/backend/src/db/wingDb.ts @@ -2,19 +2,30 @@ import pg from 'pg' const { Pool } = pg +// ============================================================ +// wing DB 통합 Pool (wing 스키마 + auth 스키마) +// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등) +// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등) +// - public 스키마: PostGIS 시스템 테이블만 유지 +// ============================================================ const wingPool = new Pool({ - host: process.env.WING_DB_HOST || 'localhost', - port: Number(process.env.WING_DB_PORT) || 5432, - database: process.env.WING_DB_NAME || 'wing', - user: process.env.WING_DB_USER || 'wing', - password: process.env.WING_DB_PASSWORD || 'Wing2026', - max: 10, + host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432, + database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing', + user: process.env.DB_USER || process.env.WING_DB_USER || 'wing', + password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026', + max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, }) +// 연결 시 search_path 자동 설정 (public 미사용) +wingPool.on('connect', (client) => { + client.query('SET search_path = wing, auth, public') +}) + wingPool.on('error', (err) => { - console.error('[wingDb] 예기치 않은 연결 오류:', err.message) + console.error('[db] 예기치 않은 연결 오류:', err.message) }) export async function testWingDbConnection(): Promise { @@ -22,10 +33,10 @@ export async function testWingDbConnection(): Promise { const client = await wingPool.connect() await client.query('SELECT 1') client.release() - console.log('[wingDb] wing 데이터베이스 연결 성공') + console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)') return true } catch (err) { - console.warn('[wingDb] wing 데이터베이스 연결 실패:', (err as Error).message) + console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message) return false } } diff --git a/backend/src/roles/permResolver.ts b/backend/src/roles/permResolver.ts new file mode 100644 index 0000000..52dadc7 --- /dev/null +++ b/backend/src/roles/permResolver.ts @@ -0,0 +1,197 @@ +/** + * 트리 구조 기반 권한 해석(Resolution) 유틸. + * + * 2차원 모델: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫) + * + * 규칙: + * 1. 부모 리소스의 READ가 N → 자식의 모든 오퍼레이션 강제 N + * 2. 해당 (RSRC_CD, OPER_CD) 명시적 레코드 있으면 → 그 값 사용 + * 3. 명시적 레코드 없으면 → 부모의 같은 OPER_CD 상속 + * 4. 최상위까지 없으면 → 기본 N (거부) + * + * 키 형식: "rsrcCode::operCd" (더블콜론으로 리소스와 오퍼레이션 구분) + */ + +export const OPERATIONS = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const +export type OperationCode = (typeof OPERATIONS)[number] + +export interface PermTreeNode { + code: string + parentCode: string | null + name: string + description: string | null + icon: string | null + level: number + sortOrder: number +} + +/** 리소스::오퍼레이션 키 생성 */ +export function makePermKey(rsrcCode: string, operCd: string): string { + return `${rsrcCode}::${operCd}` +} + +/** 키에서 리소스 코드와 오퍼레이션 코드 분리 */ +export function parsePermKey(key: string): { rsrcCode: string; operCd: string } { + const idx = key.indexOf('::') + return { + rsrcCode: key.substring(0, idx), + operCd: key.substring(idx + 2), + } +} + +/** + * 트리 노드 + 역할별 명시적 권한 → granted된 "rsrc::oper" Set 반환. + * 다중 역할: 역할별 resolve 후 OR (하나라도 Y면 Y). + */ +export function resolvePermissions( + treeNodes: PermTreeNode[], + explicitPermsPerRole: Map>, +): Set { + const granted = new Set() + + const nodeMap = new Map() + for (const node of treeNodes) { + nodeMap.set(node.code, node) + } + + for (const [, explicitPerms] of explicitPermsPerRole) { + const roleResolved = resolveForSingleRole(treeNodes, nodeMap, explicitPerms) + for (const key of roleResolved) { + granted.add(key) + } + } + + return granted +} + +/** + * 단일 역할에 대한 권한 해석. + */ +function resolveForSingleRole( + treeNodes: PermTreeNode[], + nodeMap: Map, + explicitPerms: Map, +): Set { + const effective = new Map() + + // 레벨 순(0→1→2→...)으로 처리하여 부모 → 자식 순서 보장 + const sorted = [...treeNodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder) + + for (const node of sorted) { + // READ 먼저 resolve (CUD는 READ 결과에 의존) + resolveNodeOper(node, 'READ', explicitPerms, effective) + + // CUD resolve + for (const oper of OPERATIONS) { + if (oper === 'READ') continue + resolveNodeOper(node, oper, explicitPerms, effective) + } + } + + const granted = new Set() + for (const [key, value] of effective) { + if (value) granted.add(key) + } + return granted +} + +/** + * 개별 노드 × 오퍼레이션의 effective 값 계산. + */ +function resolveNodeOper( + node: PermTreeNode, + operCd: string, + explicitPerms: Map, + effective: Map, +): void { + const key = makePermKey(node.code, operCd) + if (effective.has(key)) return + + const explicit = explicitPerms.get(key) + + if (node.parentCode === null) { + // 최상위: 명시적 값 또는 기본 거부 + effective.set(key, explicit ?? false) + return + } + + // 부모의 READ 확인 (접근 게이트) + const parentReadKey = makePermKey(node.parentCode, 'READ') + const parentReadEffective = effective.get(parentReadKey) + + if (parentReadEffective === false) { + // 부모 READ 차단 → 모든 오퍼레이션 강제 차단 + effective.set(key, false) + return + } + + // 명시적 값 있으면 사용 + if (explicit !== undefined) { + effective.set(key, explicit) + return + } + + // 부모의 같은 오퍼레이션 상속 + const parentOperKey = makePermKey(node.parentCode, operCd) + const parentOperEffective = effective.get(parentOperKey) + effective.set(key, parentOperEffective ?? false) +} + +/** + * resolved Set → Record 변환 (API 반환용). + */ +export function grantedSetToRecord(granted: Set): Record { + const result: Record = {} + for (const key of granted) { + const { rsrcCode, operCd } = parsePermKey(key) + if (!result[rsrcCode]) result[rsrcCode] = [] + result[rsrcCode].push(operCd) + } + return result +} + +/** + * 플랫 노드 배열 → 트리 구조 변환 (프론트엔드 UI용). + */ +export interface PermTreeResponse { + code: string + parentCode: string | null + name: string + description: string | null + icon: string | null + level: number + sortOrder: number + children: PermTreeResponse[] +} + +export function buildPermTree(nodes: PermTreeNode[]): PermTreeResponse[] { + const nodeMap = new Map() + const roots: PermTreeResponse[] = [] + + const sorted = [...nodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder) + + for (const node of sorted) { + const treeNode: PermTreeResponse = { + code: node.code, + parentCode: node.parentCode, + name: node.name, + description: node.description, + icon: node.icon, + level: node.level, + sortOrder: node.sortOrder, + children: [], + } + nodeMap.set(node.code, treeNode) + + if (node.parentCode === null) { + roots.push(treeNode) + } else { + const parent = nodeMap.get(node.parentCode) + if (parent) { + parent.children.push(treeNode) + } + } + } + + return roots +} diff --git a/backend/src/roles/roleRouter.ts b/backend/src/roles/roleRouter.ts index 0f59ef3..3706dad 100644 --- a/backend/src/roles/roleRouter.ts +++ b/backend/src/roles/roleRouter.ts @@ -1,13 +1,24 @@ import { Router } from 'express' import { requireAuth, requireRole } from '../auth/authMiddleware.js' import { AuthError } from '../auth/authService.js' -import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault } from './roleService.js' +import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault, getPermTree } from './roleService.js' const router = Router() router.use(requireAuth) router.use(requireRole('ADMIN')) +// GET /api/roles/perm-tree — 권한 트리 구조 조회 +router.get('/perm-tree', async (_req, res) => { + try { + const tree = await getPermTree() + res.json(tree) + } catch (err) { + console.error('[roles] 권한 트리 조회 오류:', err) + res.status(500).json({ error: '권한 트리 조회 중 오류가 발생했습니다.' }) + } +}) + // GET /api/roles router.get('/', async (_req, res) => { try { @@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => { }) // PUT /api/roles/:id/permissions +// 요청: { permissions: [{ resourceCode, operationCode, granted }] } router.put('/:id/permissions', async (req, res) => { try { const roleSn = Number(req.params.id) @@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => { return } + for (const p of permissions) { + if (!p.resourceCode || !p.operationCode || typeof p.granted !== 'boolean') { + res.status(400).json({ error: '각 권한에는 resourceCode, operationCode, granted가 필요합니다.' }) + return + } + } + await updatePermissions(roleSn, permissions) res.json({ success: true }) } catch (err) { diff --git a/backend/src/roles/roleService.ts b/backend/src/roles/roleService.ts index b3b0862..b78aeb1 100644 --- a/backend/src/roles/roleService.ts +++ b/backend/src/roles/roleService.ts @@ -1,13 +1,34 @@ import { authPool } from '../db/authDb.js' import { AuthError } from '../auth/authService.js' - -const PERM_RESOURCE_CODES = [ - 'prediction', 'hns', 'rescue', 'reports', 'aerial', - 'assets', 'scat', 'incidents', 'board', 'weather', 'admin', -] as const +import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js' const PROTECTED_ROLE_CODES = ['ADMIN'] +/** AUTH_PERM_TREE에서 level 0 리소스 코드를 동적 조회 */ +async function getTopLevelResourceCodes(): Promise { + const result = await authPool.query( + `SELECT RSRC_CD FROM AUTH_PERM_TREE WHERE RSRC_LEVEL = 0 AND USE_YN = 'Y' ORDER BY SORT_ORD` + ) + return result.rows.map((r: { rsrc_cd: string }) => r.rsrc_cd) +} + +/** AUTH_PERM_TREE 전체 노드 조회 */ +export async function getPermTreeNodes(): Promise { + const result = await authPool.query( + `SELECT RSRC_CD as code, PARENT_CD as "parentCode", RSRC_NM as name, + RSRC_DESC as description, ICON as icon, RSRC_LEVEL as level, SORT_ORD as "sortOrder" + FROM AUTH_PERM_TREE WHERE USE_YN = 'Y' + ORDER BY RSRC_LEVEL, SORT_ORD` + ) + return result.rows +} + +/** 트리 구조로 변환하여 반환 (프론트엔드 UI용) */ +export async function getPermTree(): Promise { + const nodes = await getPermTreeNodes() + return buildPermTree(nodes) +} + interface RoleWithPermissions { sn: number code: string @@ -17,6 +38,7 @@ interface RoleWithPermissions { permissions: Array<{ sn: number resourceCode: string + operationCode: string granted: boolean }> } @@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise for (const row of rolesResult.rows) { const permsResult = await authPool.query( - `SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted - FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD`, + `SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted + FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`, [row.sn] ) @@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise name: row.name, description: row.description, isDefault: row.is_default === 'Y', - permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({ + permissions: permsResult.rows.map((p: { + sn: number; resource_code: string; operation_code: string; granted: string + }) => ({ sn: p.sn, resourceCode: p.resource_code, + operationCode: p.operation_code, granted: p.granted === 'Y', })), }) @@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise ({ + permissions: permsResult.rows.map((p: { + sn: number; resource_code: string; operation_code: string; granted: string + }) => ({ sn: p.sn, resourceCode: p.resource_code, + operationCode: p.operation_code, granted: p.granted === 'Y', })), } @@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise { export async function updatePermissions( roleSn: number, - permissions: Array<{ resourceCode: string; granted: boolean }> + permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }> ): Promise { for (const perm of permissions) { const existing = await authPool.query( - 'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2', - [roleSn, perm.resourceCode] + 'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3', + [roleSn, perm.resourceCode, perm.operationCode] ) if (existing.rows.length > 0) { await authPool.query( - 'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3', - [perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode] + 'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3 AND OPER_CD = $4', + [perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode, perm.operationCode] ) } else { await authPool.query( - 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)', - [roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N'] + 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)', + [roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N'] ) } } diff --git a/backend/src/server.ts b/backend/src/server.ts index 3fbc79e..21bb1ba 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,7 +4,6 @@ import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import cookieParser from 'cookie-parser' -import { testAuthDbConnection } from './db/authDb.js' import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' @@ -14,6 +13,7 @@ import roleRouter from './roles/roleRouter.js' import settingsRouter from './settings/settingsRouter.js' import menuRouter from './menus/menuRouter.js' import auditRouter from './audit/auditRouter.js' +import boardRouter from './board/boardRouter.js' import hnsRouter from './hns/hnsRouter.js' import { sanitizeBody, @@ -49,6 +49,7 @@ app.use(helmet({ } }, crossOriginEmbedderPolicy: false, // API 서버이므로 비활성 + crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용 })) // 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지) @@ -136,6 +137,7 @@ app.use('/api/menus', menuRouter) app.use('/api/audit', auditRouter) // API 라우트 — 업무 +app.use('/api/board', boardRouter) app.use('/api/layers', layersRouter) app.use('/api/simulation', simulationLimiter, simulationRouter) app.use('/api/hns', hnsRouter) @@ -174,17 +176,13 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) - // wing DB (운영 데이터) 연결 확인 - await testWingDbConnection() - - // wing_auth DB (인증 데이터) 연결 확인 - const connected = await testAuthDbConnection() + // wing DB 연결 확인 (wing + auth 스키마 통합) + const connected = await testWingDbConnection() if (connected) { // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) try { - const { authPool } = await import('./db/authDb.js') - await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) - console.log('[migration] SETTING_VAL → TEXT 변환 완료') + const { wingPool } = await import('./db/wingDb.js') + await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) } catch { // 이미 TEXT이거나 권한 없으면 무시 } diff --git a/database/auth_init.sql b/database/auth_init.sql index d55729b..b99b723 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM ( PERM_SN SERIAL NOT NULL, ROLE_SN INTEGER NOT NULL, RSRC_CD VARCHAR(50) NOT NULL, + OPER_CD VARCHAR(20) NOT NULL, GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y', REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN), CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE, - CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD), - CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')) + CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD), + CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')), + CONSTRAINT CK_AUTH_PERM_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT')) ); COMMENT ON TABLE AUTH_PERM IS '역할별권한'; COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번'; COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번'; COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)'; +COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)'; COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)'; COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시'; @@ -239,6 +242,7 @@ CREATE UNIQUE INDEX UK_AUTH_USER_OAUTH ON AUTH_USER(OAUTH_PROVIDER, OAUTH_SUB) W CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL; CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN); CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD); +CREATE INDEX IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD); CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID); CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM); CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID); @@ -257,36 +261,65 @@ INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES -- ============================================================ --- 11. 초기 데이터: 역할별 권한 (탭 접근 매트릭스) +-- 11. 초기 데이터: 역할별 권한 (리소스 × 오퍼레이션 매트릭스) +-- OPER_CD: READ(조회), CREATE(생성), UPDATE(수정), DELETE(삭제) -- ============================================================ --- ADMIN (ROLE_SN=1): 모든 탭 접근 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'), -(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'), -(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'), -(1, 'weather', 'Y'), (1, 'admin', 'Y'); +-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'), +(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'), +(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', 'Y'), +(1, 'reports', 'READ', 'Y'), (1, 'reports', 'CREATE', 'Y'), (1, 'reports', 'UPDATE', 'Y'), (1, 'reports', 'DELETE', 'Y'), +(1, 'aerial', 'READ', 'Y'), (1, 'aerial', 'CREATE', 'Y'), (1, 'aerial', 'UPDATE', 'Y'), (1, 'aerial', 'DELETE', 'Y'), +(1, 'assets', 'READ', 'Y'), (1, 'assets', 'CREATE', 'Y'), (1, 'assets', 'UPDATE', 'Y'), (1, 'assets', 'DELETE', 'Y'), +(1, 'scat', 'READ', 'Y'), (1, 'scat', 'CREATE', 'Y'), (1, 'scat', 'UPDATE', 'Y'), (1, 'scat', 'DELETE', 'Y'), +(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'), +(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'), +(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'), +(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y'); --- MANAGER (ROLE_SN=2): admin 탭 제외 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'), -(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'), -(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'), -(2, 'weather', 'Y'), (2, 'admin', 'N'); +-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'), +(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'), +(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'), +(2, 'reports', 'READ', 'Y'), (2, 'reports', 'CREATE', 'Y'), (2, 'reports', 'UPDATE', 'Y'), (2, 'reports', 'DELETE', 'Y'), +(2, 'aerial', 'READ', 'Y'), (2, 'aerial', 'CREATE', 'Y'), (2, 'aerial', 'UPDATE', 'Y'), (2, 'aerial', 'DELETE', 'Y'), +(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'), +(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'), +(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'), +(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'), +(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'), +(2, 'admin', 'READ', 'N'); --- USER (ROLE_SN=3): assets, admin 탭 제외 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'), -(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'), -(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'), -(3, 'weather', 'Y'), (3, 'admin', 'N'); +-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), +(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), +(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), +(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), +(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), +(3, 'assets', 'READ', 'N'), +(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), +(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), +(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), +(3, 'weather', 'READ', 'Y'), +(3, 'admin', 'READ', 'N'); --- VIEWER (ROLE_SN=4): reports, assets, scat, admin 제외 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'), -(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'), -(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'), -(4, 'weather', 'Y'), (4, 'admin', 'N'); +-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(4, 'prediction', 'READ', 'Y'), +(4, 'hns', 'READ', 'Y'), +(4, 'rescue', 'READ', 'Y'), +(4, 'reports', 'READ', 'N'), +(4, 'aerial', 'READ', 'Y'), +(4, 'assets', 'READ', 'N'), +(4, 'scat', 'READ', 'N'), +(4, 'incidents', 'READ', 'Y'), +(4, 'board', 'READ', 'Y'), +(4, 'weather', 'READ', 'Y'), +(4, 'admin', 'READ', 'N'); -- ============================================================ diff --git a/database/migration/003_perm_tree.sql b/database/migration/003_perm_tree.sql new file mode 100644 index 0000000..2688735 --- /dev/null +++ b/database/migration/003_perm_tree.sql @@ -0,0 +1,108 @@ +-- ============================================================ +-- AUTH_PERM_TREE: 트리 구조 기반 리소스(메뉴) 권한 정의 +-- 부모-자식 관계로 N-depth 서브탭 권한 제어 지원 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS AUTH_PERM_TREE ( + RSRC_CD VARCHAR(50) NOT NULL, -- 콜론 구분 경로: 'prediction', 'aerial:media' + PARENT_CD VARCHAR(50), -- NULL이면 최상위 탭 + RSRC_NM VARCHAR(100) NOT NULL, -- 표시명 + RSRC_DESC VARCHAR(200), -- 설명 (NULL 허용) + ICON VARCHAR(20), -- 아이콘 (NULL 허용, 선택 옵션) + RSRC_LEVEL SMALLINT NOT NULL DEFAULT 0, -- depth (0=탭, 1=서브탭, 2+) + SORT_ORD SMALLINT NOT NULL DEFAULT 0, -- 형제 노드 간 정렬 + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + CONSTRAINT PK_PERM_TREE PRIMARY KEY (RSRC_CD), + CONSTRAINT FK_PERM_TREE_PARENT FOREIGN KEY (PARENT_CD) + REFERENCES AUTH_PERM_TREE(RSRC_CD) +); + +CREATE INDEX IF NOT EXISTS IDX_PERM_TREE_PARENT ON AUTH_PERM_TREE(PARENT_CD); + +-- ============================================================ +-- 초기 데이터 +-- ============================================================ + +-- Level 0: 메인 탭 (11개) +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_DESC, RSRC_LEVEL, SORT_ORD) VALUES + ('prediction', NULL, '유출유 확산예측', '확산 예측 실행 및 결과 조회', 0, 1), + ('hns', NULL, 'HNS·대기확산', '대기확산 분석 실행 및 조회', 0, 2), + ('rescue', NULL, '긴급구난', '구난 예측 실행 및 조회', 0, 3), + ('reports', NULL, '보고자료', '사고 보고서 작성 및 조회', 0, 4), + ('aerial', NULL, '항공탐색', '항공 탐색 데이터 조회', 0, 5), + ('assets', NULL, '방제자산 관리', '방제 장비 및 자산 관리', 0, 6), + ('scat', NULL, '해안평가', 'SCAT 조사 실행 및 조회', 0, 7), + ('board', NULL, '게시판', '자료실 및 공지사항 조회', 0, 8), + ('weather', NULL, '기상정보', '기상 및 해상 정보 조회', 0, 9), + ('incidents', NULL, '통합조회', '사고 상세 정보 조회', 0, 10), + ('admin', NULL, '관리', '사용자 및 권한 관리', 0, 11) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: prediction 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('prediction:analysis', 'prediction', '확산분석', 1, 1), + ('prediction:list', 'prediction', '분석 목록', 1, 2), + ('prediction:theory', 'prediction', '확산모델 이론', 1, 3), + ('prediction:boom-theory', 'prediction', '오일펜스 배치 이론', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: hns 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('hns:analysis', 'hns', '대기확산 분석', 1, 1), + ('hns:list', 'hns', '분석 목록', 1, 2), + ('hns:scenario', 'hns', '시나리오 관리', 1, 3), + ('hns:manual', 'hns', 'HNS 대응매뉴얼', 1, 4), + ('hns:theory', 'hns', '확산모델 이론', 1, 5), + ('hns:substance', 'hns', 'HNS 물질정보', 1, 6) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: rescue 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('rescue:rescue', 'rescue', '긴급구난예측', 1, 1), + ('rescue:list', 'rescue', '긴급구난 목록', 1, 2), + ('rescue:scenario', 'rescue', '시나리오 관리', 1, 3), + ('rescue:theory', 'rescue', '긴급구난모델 이론', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: reports 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('reports:report-list', 'reports', '보고서 목록', 1, 1), + ('reports:template', 'reports', '표준보고서 템플릿', 1, 2), + ('reports:generate', 'reports', '보고서 생성', 1, 3) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: aerial 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('aerial:media', 'aerial', '영상사진관리', 1, 1), + ('aerial:analysis', 'aerial', '유출유면적분석', 1, 2), + ('aerial:realtime', 'aerial', '실시간드론', 1, 3), + ('aerial:sensor', 'aerial', '오염/선박3D분석', 1, 4), + ('aerial:satellite', 'aerial', '위성요청', 1, 5), + ('aerial:cctv', 'aerial', 'CCTV 조회', 1, 6), + ('aerial:theory', 'aerial', '항공탐색 이론', 1, 7) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: assets 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('assets:management', 'assets', '자산 관리', 1, 1), + ('assets:upload', 'assets', '자산 현행화', 1, 2), + ('assets:theory', 'assets', '방제자원 이론', 1, 3), + ('assets:insurance', 'assets', '선박 보험정보', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: board 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('board:all', 'board', '전체', 1, 1), + ('board:notice', 'board', '공지사항', 1, 2), + ('board:data', 'board', '자료실', 1, 3), + ('board:qna', 'board', 'Q&A', 1, 4), + ('board:manual', 'board', '해경매뉴얼', 1, 5) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: admin 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('admin:users', 'admin', '사용자 관리', 1, 1), + ('admin:permissions', 'admin', '권한 관리', 1, 2), + ('admin:menus', 'admin', '메뉴 관리', 1, 3), + ('admin:settings', 'admin', '시스템 설정', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; diff --git a/database/migration/004_oper_cd.sql b/database/migration/004_oper_cd.sql new file mode 100644 index 0000000..a352a58 --- /dev/null +++ b/database/migration/004_oper_cd.sql @@ -0,0 +1,55 @@ +-- ============================================================ +-- 마이그레이션 004: AUTH_PERM에 OPER_CD 컬럼 추가 +-- 리소스 단일 권한 → 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델 +-- ============================================================ + +-- Step 1: OPER_CD 컬럼 추가 (기존 레코드는 'READ'로 설정) +ALTER TABLE AUTH_PERM ADD COLUMN IF NOT EXISTS OPER_CD VARCHAR(20) NOT NULL DEFAULT 'READ'; +COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)'; + +-- Step 2: UNIQUE 제약 변경 (ROLE_SN, RSRC_CD) → (ROLE_SN, RSRC_CD, OPER_CD) +-- INSERT 전에 변경해야 CUD 레코드 삽입 시 충돌 없음 +ALTER TABLE AUTH_PERM DROP CONSTRAINT IF EXISTS UK_AUTH_PERM; +ALTER TABLE AUTH_PERM ADD CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD); + +-- Step 3: 기존 GRANT_YN='Y' 레코드를 CREATE/UPDATE/DELETE로 확장 +-- (기존에 허용된 리소스는 RCUD 모두 허용하여 동작 보존) +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT ROLE_SN, RSRC_CD, 'CREATE', GRANT_YN +FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y' +ON CONFLICT DO NOTHING; + +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT ROLE_SN, RSRC_CD, 'UPDATE', GRANT_YN +FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y' +ON CONFLICT DO NOTHING; + +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT ROLE_SN, RSRC_CD, 'DELETE', GRANT_YN +FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y' +ON CONFLICT DO NOTHING; + +-- Step 3-1: VIEWER(조회 전용) 역할의 CUD 레코드 제거 +-- VIEWER는 READ만 허용, CUD 확장은 의미 없음 +DELETE FROM AUTH_PERM +WHERE ROLE_SN = (SELECT ROLE_SN FROM AUTH_ROLE WHERE ROLE_CD = 'VIEWER') + AND OPER_CD != 'READ'; + +-- Step 4: 기본값 제거 (신규 레코드는 반드시 OPER_CD 명시) +ALTER TABLE AUTH_PERM ALTER COLUMN OPER_CD DROP DEFAULT; + +-- Step 5: CHECK 제약 추가 (확장 가능: MANAGE, EXPORT 포함) +DO $$ BEGIN + ALTER TABLE AUTH_PERM ADD CONSTRAINT CK_AUTH_PERM_OPER + CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Step 6: 인덱스 +CREATE INDEX IF NOT EXISTS IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD); + +-- 검증 +SELECT ROLE_SN, OPER_CD, COUNT(*), STRING_AGG(GRANT_YN, '') as grants +FROM AUTH_PERM +GROUP BY ROLE_SN, OPER_CD +ORDER BY ROLE_SN, OPER_CD; diff --git a/database/migration/005_db_consolidation.sql b/database/migration/005_db_consolidation.sql new file mode 100644 index 0000000..61262a5 --- /dev/null +++ b/database/migration/005_db_consolidation.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- 마이그레이션 005: DB 통합 (wing + wing_auth → wing 단일 DB) +-- +-- 스키마 구조: +-- wing — 운영 데이터 (LAYER, BOARD_POST, HNS_SUBSTANCE 등) +-- auth — 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등) +-- public — PostGIS 시스템 테이블만 유지 (spatial_ref_sys) +-- +-- 실행 순서: +-- 1. 이 SQL을 wing DB에서 실행 (스키마 생성 + 테이블 이동) +-- 2. wing_auth DB 덤프 → auth 스키마로 복원 (별도 쉘) +-- 3. search_path 설정 +-- ============================================================ + +-- Step 1: 명시적 스키마 생성 +CREATE SCHEMA IF NOT EXISTS wing; +CREATE SCHEMA IF NOT EXISTS auth; + +-- Step 2: 기존 public 운영 테이블을 wing 스키마로 이동 +-- (PostGIS 시스템 테이블 spatial_ref_sys, topology는 public에 유지) +ALTER TABLE IF EXISTS public.layer SET SCHEMA wing; +ALTER TABLE IF EXISTS public.hns_substance SET SCHEMA wing; + +-- Step 3: 기본 search_path 설정 (DB 레벨) +-- wing 사용자가 스키마 접두사 없이 양쪽 테이블 접근 가능 +ALTER DATABASE wing SET search_path = wing, auth; + +-- Step 4: wing 사용자에게 auth 스키마 권한 부여 +-- (wing_auth 데이터 복원 후 적용) +GRANT USAGE ON SCHEMA auth TO wing; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO wing; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO wing; + +-- Step 5: wing 스키마 기본 권한 +GRANT ALL PRIVILEGES ON SCHEMA wing TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON TABLES TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON SEQUENCES TO wing; + +-- 검증 +SELECT schemaname, tablename +FROM pg_tables +WHERE schemaname IN ('wing', 'auth') +ORDER BY schemaname, tablename; diff --git a/database/migration/006_board.sql b/database/migration/006_board.sql new file mode 100644 index 0000000..efc6e08 --- /dev/null +++ b/database/migration/006_board.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 마이그레이션 006: 게시판 (BOARD_POST) +-- wing 스키마에 생성, auth.AUTH_USER FK 참조 +-- ============================================================ + +-- Step 1: 게시판 테이블 +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; +COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; +COMMENT ON COLUMN BOARD_POST.PINNED_YN IS '상단고정 여부'; +COMMENT ON COLUMN BOARD_POST.USE_YN IS '사용여부 (N=논리삭제)'; + +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); + +-- Step 2: 초기 데이터 (기존 프론트엔드 mockPosts 이전) +-- admin 사용자 ID 조회 +DO $$ +DECLARE + v_admin_id UUID; +BEGIN + SELECT USER_ID INTO v_admin_id FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1; + + IF v_admin_id IS NOT NULL THEN + INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, VIEW_CNT, PINNED_YN, REG_DTM) VALUES + ('NOTICE', '시스템 업데이트 안내', '시스템 업데이트 관련 안내사항입니다.', v_admin_id, 245, 'Y', '2025-02-15'::timestamptz), + ('NOTICE', '2025년 방제 교육 일정 안내', '2025년도 방제 교육 일정을 안내합니다.', v_admin_id, 189, 'Y', '2025-02-14'::timestamptz), + ('DATA', '방제 매뉴얼 업데이트 (2025년 개정판)', '2025년 개정판 방제 매뉴얼입니다.', v_admin_id, 423, 'N', '2025-02-10'::timestamptz), + ('QNA', 'HNS 대기확산 분석 결과 해석 문의', 'HNS 분석 결과 해석 방법을 문의합니다.', v_admin_id, 156, 'N', '2025-02-08'::timestamptz), + ('DATA', '2024년 유류오염사고 통계 자료', '2024년도 유류오염사고 통계 자료를 공유합니다.', v_admin_id, 312, 'N', '2025-02-05'::timestamptz), + ('QNA', '유출유 확산 예측 알고리즘 선택 기준', '확산 예측 시 알고리즘 선택 기준을 문의합니다.', v_admin_id, 267, 'N', '2025-02-03'::timestamptz), + ('DATA', '해양오염 방제 장비 운용 가이드', '방제 장비 운용 가이드 문서입니다.', v_admin_id, 534, 'N', '2025-01-28'::timestamptz), + ('QNA', 'SCAT 조사 방법 관련 질문', 'SCAT 현장 조사 방법에 대해 질문합니다.', v_admin_id, 198, 'N', '2025-01-25'::timestamptz), + ('DATA', 'HNS 물질 안전보건자료 (MSDS) 모음', 'HNS 물질별 MSDS 자료 모음입니다.', v_admin_id, 645, 'N', '2025-01-20'::timestamptz), + ('QNA', '항공촬영 드론 운용 시 주의사항', '드론 운용 시 주의할 점을 문의합니다.', v_admin_id, 221, 'N', '2025-01-15'::timestamptz) + ON CONFLICT DO NOTHING; + END IF; +END $$; + +-- 검증 +SELECT POST_SN, CATEGORY_CD, TITLE, VIEW_CNT, PINNED_YN FROM BOARD_POST ORDER BY POST_SN; diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index b77b5a9..561f4ca 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -10,21 +10,79 @@ ### 개요 JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다. +### 권한 모델: 리소스 × 오퍼레이션 (RBAC) + +**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫) + +``` +AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + +리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫) +├── prediction READ = 조회/열람 +│ ├── prediction:analysis CREATE = 생성 +│ ├── prediction:list UPDATE = 수정 +│ └── prediction:theory DELETE = 삭제 +├── board +│ ├── board:notice +│ └── board:data +└── admin + ├── admin:users + └── admin:permissions +``` + +#### 오퍼레이션 코드 + +| OPER_CD | 설명 | 비고 | +|---------|------|------| +| `READ` | 조회/열람 | 목록, 상세 조회 | +| `CREATE` | 생성 | 새 데이터 등록 | +| `UPDATE` | 수정 | 기존 데이터 변경 | +| `DELETE` | 삭제 | 데이터 삭제 | +| `MANAGE` | 관리 | 관리자 설정 (확장용) | +| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) | + +#### 상속 규칙 + +1. 부모 리소스의 **READ**가 N → 자식의 **모든 오퍼레이션** 강제 N (접근 자체 차단) +2. 해당 `(RSRC_CD, OPER_CD)` 명시적 레코드 있으면 → 그 값 사용 +3. 명시적 레코드 없으면 → 부모의 **같은 OPER_CD** 상속 +4. 최상위까지 없으면 → 기본 N (거부) + +``` +예시: board (READ:Y, CREATE:Y, UPDATE:Y, DELETE:N) +└── board:notice + ├── READ: 상속 Y (부모 READ Y) + ├── CREATE: 상속 Y (부모 CREATE Y) + ├── UPDATE: 명시적 N (override 가능) + └── DELETE: 상속 N (부모 DELETE N) +``` + +#### 키 구분자 +- 리소스 내부 경로: `:` (board:notice) +- 리소스-오퍼레이션 결합 (내부용): `::` (board:notice::READ) + ### 백엔드 -#### 미들웨어 적용 +#### 미들웨어 + ```typescript -// backend/src/auth/authMiddleware.ts -import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js' // 인증만 필요한 라우트 router.use(requireAuth) -// 특정 역할 필요 +// 역할 기반 (관리 API용) router.use(requireRole('ADMIN')) -router.use(requireRole('ADMIN', 'MANAGER')) + +// 리소스×오퍼레이션 기반 (일반 비즈니스 API용) +router.post('/notice/list', requirePermission('board:notice', 'READ'), handler) +router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler) +router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler) +router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler) ``` +`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다. + #### JWT 페이로드 (req.user) `requireAuth` 통과 후 `req.user`에 담기는 정보: ```typescript @@ -36,25 +94,21 @@ interface JwtPayload { } ``` -#### 라우터 패턴 +#### 라우터 패턴 (CRUD 구조) ```typescript // backend/src/[모듈]/[모듈]Router.ts import { Router } from 'express' -import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' const router = Router() router.use(requireAuth) -router.get('/', async (req, res) => { - try { - const userId = req.user!.sub - // 비즈니스 로직... - res.json(result) - } catch (err) { - console.error('[모듈] 오류:', err) - res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) - } -}) +// 리소스별 CRUD 엔드포인트 +router.post('/list', requirePermission('module:sub', 'READ'), listHandler) +router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler) +router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler) +router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler) +router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler) export default router ``` @@ -63,32 +117,36 @@ export default router #### authStore (Zustand) ```typescript -// frontend/src/store/authStore.ts -import { useAuthStore } from '../store/authStore' +import { useAuthStore } from '@common/store/authStore' -// 컴포넌트 내에서 사용 const { user, isAuthenticated, hasPermission, logout } = useAuthStore() // 사용자 정보 user?.id // UUID user?.name // 이름 user?.roles // ['ADMIN', 'USER'] +user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... } -// 권한 확인 (탭 ID 기준) -hasPermission('prediction') // true/false -hasPermission('admin') // true/false +// 권한 확인 (리소스 × 오퍼레이션) +hasPermission('prediction') // READ 확인 (기본값) +hasPermission('prediction', 'READ') // 명시적 READ 확인 +hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한 +hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한 + +// 하위 호환: operation 생략 시 'READ' 기본값 +hasPermission('admin') // === hasPermission('admin', 'READ') ``` #### API 클라이언트 ```typescript -// frontend/src/services/api.ts -import { api } from './api' +import { api } from '@common/services/api' // withCredentials: true 설정으로 JWT 쿠키 자동 포함 -const response = await api.get('/your-endpoint') -const response = await api.post('/your-endpoint', data) +const response = await api.post('/your-endpoint/list', params) +const response = await api.post('/your-endpoint/create', data) // 401 응답 시 자동 로그아웃 처리 (인터셉터) +// 403 응답 시 권한 부족 (requirePermission 미들웨어) ``` --- @@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data) ```typescript // frontend/src/App.tsx (자동 적용, 수정 불필요) +import { API_BASE_URL } from '@common/services/api' + useEffect(() => { if (!isAuthenticated) return const blob = new Blob( [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], { type: 'text/plain' } ) - navigator.sendBeacon('/api/audit/log', blob) + navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) }, [activeMainTab, isAuthenticated]) ``` @@ -117,12 +177,13 @@ useEffect(() => { 특정 작업에 대해 명시적으로 감사 로그를 기록하려면: ```typescript -// 프론트엔드에서 sendBeacon 사용 +import { API_BASE_URL } from '@common/services/api' + const blob = new Blob( [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })], { type: 'text/plain' } ) -navigator.sendBeacon('/api/audit/log', blob) +navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) ``` ### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG) @@ -275,19 +336,72 @@ const mutation = useMutation({ --- -## 6. 백엔드 모듈 추가 절차 +## 6. 백엔드 API CRUD 규칙 + +> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조 + +### HTTP Method 정책 (보안 가이드 준수) +- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. +- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환). +- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. + +### 오퍼레이션 기반 권한 미들웨어 +OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. +`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다. + +| URL 패턴 | OPER_CD | 미들웨어 | +|----------|---------|----------| +| `/resource/list` | READ | `requirePermission(resource, 'READ')` | +| `/resource/detail` | READ | `requirePermission(resource, 'READ')` | +| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` | +| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | +| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` | + +### 라우터 작성 예시 + +```typescript +// backend/src/board/noticeRouter.ts +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +const router = Router() +router.use(requireAuth) + +// 조회 +router.post('/list', requirePermission('board:notice', 'READ'), listHandler) +router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler) + +// 생성/수정/삭제 +router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler) +router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler) +router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler) + +export default router +``` + +### 관리 API (예외) +사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지: +```typescript +router.use(requireAuth) +router.use(requireRole('ADMIN')) +``` + +--- + +## 7. 백엔드 모듈 추가 절차 새 백엔드 모듈을 추가할 때: 1. `backend/src/[모듈명]/` 디렉토리 생성 2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리) -3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리) +3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission) 4. `backend/src/server.ts`에 라우터 등록: ```typescript import newRouter from './[모듈명]/[모듈명]Router.js' app.use('/api/[경로]', newRouter) ``` 5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가 +6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL) ### DB 접근 ```typescript @@ -306,20 +420,30 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1' ``` frontend/src/ -├── services/api.ts Axios 인스턴스 + 인터셉터 -├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API -├── store/authStore.ts 인증 상태 (Zustand) -├── store/menuStore.ts 메뉴 상태 (Zustand) -└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 +├── common/ +│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터 +│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API +│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand) +│ ├── store/menuStore.ts 메뉴 상태 (Zustand) +│ └── hooks/ useSubMenu, useFeatureTracking 등 +├── tabs/ 탭별 패키지 (11개) +└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 backend/src/ -├── auth/ 인증 (JWT, OAuth, 미들웨어) +├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission) ├── users/ 사용자 관리 -├── roles/ 역할/권한 관리 +├── roles/ 역할/권한 관리 (permResolver, roleService) ├── settings/ 시스템 설정 ├── menus/ 메뉴 설정 ├── audit/ 감사 로그 -├── db/ DB 연결 (authDb, database) +├── db/ DB 연결 (authDb, wingDb) ├── middleware/ 보안 미들웨어 └── server.ts Express 진입점 + 라우터 등록 + +database/ +├── auth_init.sql 인증 DB DDL + 초기 데이터 +├── init.sql 운영 DB DDL +└── migration/ 마이그레이션 스크립트 + ├── 003_perm_tree.sql 리소스 트리 (AUTH_PERM_TREE) + └── 004_oper_cd.sql 오퍼레이션 코드 (OPER_CD) 추가 ``` diff --git a/docs/CRUD-API-GUIDE.md b/docs/CRUD-API-GUIDE.md new file mode 100644 index 0000000..95c46e4 --- /dev/null +++ b/docs/CRUD-API-GUIDE.md @@ -0,0 +1,1433 @@ +# RBAC 기반 CRUD API 개발 가이드 + +새 CRUD API를 추가할 때 따라야 할 표준 가이드. +Phase 5 RBAC 체계(리소스 x 오퍼레이션 2차원 모델)를 기반으로 한다. + +**DB 구조**: wing DB 단일 DB, 스키마 분리 +- `wing` 스키마: 운영 데이터 (BOARD_POST, LAYER 등) +- `auth` 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE, AUTH_PERM 등) +- `public` 스키마: PostGIS 시스템 테이블만 유지 (사용 금지) + +--- + +## Part 1: 범용 가이드 + +### 1. 개요 + +이 문서는 WING-OPS의 **모든 탭 개발자**가 새 CRUD API를 만들 때 참조하는 표준이다. + +- 백엔드: Express Router + Service 2-Layer +- 권한: `requirePermission(resource, operation)` 미들웨어 +- DB: PostgreSQL (`wingPool` 단일 Pool, `search_path = wing, auth, public`) +- 프론트: Axios + `hasPermission()` 조건부 렌더링 + +각 섹션에 복사해서 바로 사용할 수 있는 실제 코드 스니펫을 포함한다. + +--- + +### 2. 아키텍처 + +#### 3-Layer 구조 + +``` +클라이언트 (React) + ↓ Axios (withCredentials: true, JWT 쿠키 자동 포함) +Router (Express) ← requireAuth → requirePermission + ↓ +Service ← 비즈니스 로직, DB 쿼리 + ↓ +DB (pg Pool) ← wingPool (search_path = wing, auth) +``` + +#### 디렉토리 구조 + +``` +backend/src/{domain}/ +├── {domain}Router.ts ← Express 라우터 (엔드포인트 + 미들웨어) +└── {domain}Service.ts ← 비즈니스 로직 (쿼리, 인터페이스) +``` + +#### DB Pool + +```typescript +// backend/src/db/wingDb.ts +import { wingPool } from '../db/wingDb.js' + +// wingPool은 연결 시 search_path = wing, auth, public 자동 설정 +// → 스키마 접두사 없이 wing.BOARD_POST, auth.AUTH_USER 모두 접근 가능 +``` + +> **주의**: `authPool`은 하위 호환용 re-export이다. 신규 코드는 반드시 `wingPool`을 직접 import할 것. + +```typescript +// backend/src/db/authDb.ts (하위 호환 — 신규 코드에서 사용 금지) +import { wingPool } from './wingDb.js' +export const authPool = wingPool // 같은 Pool +``` + +--- + +### 3. 권한 모델 빠른 요약 + +#### 2차원 모델: 리소스 트리 x 오퍼레이션 + +``` +AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + +리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫) +├── board READ = 조회/열람 +│ ├── board:notice CREATE = 생성 +│ ├── board:data UPDATE = 수정 +│ └── board:qna DELETE = 삭제 +├── prediction +│ ├── prediction:analysis +│ └── prediction:list +└── admin + ├── admin:users + └── admin:permissions +``` + +#### 리소스 코드 + +`AUTH_PERM_TREE` 테이블에 등록된 코드. 콜론(`:`)으로 계층 구분. + +| 형식 | 예시 | 설명 | +|------|------|------| +| `{탭}` | `board` | 메인 탭 (level 0) | +| `{탭}:{서브}` | `board:notice` | 서브 리소스 (level 1) | + +#### 오퍼레이션 + +| OPER_CD | 설명 | 용도 | +|---------|------|------| +| `READ` | 조회/열람 | 목록, 상세 조회 | +| `CREATE` | 생성 | 새 데이터 등록 | +| `UPDATE` | 수정 | 기존 데이터 변경 | +| `DELETE` | 삭제 | 데이터 삭제 | + +#### 백엔드: requirePermission + +```typescript +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +// requirePermission(리소스코드, 오퍼레이션코드) +// 오퍼레이션 생략 시 기본값 'READ' +router.post('/list', requirePermission('board:notice', 'READ'), handler) +router.post('/create', requirePermission('board:notice', 'CREATE'), handler) +``` + +`requirePermission`은 **요청당 1회**만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다. + +#### 프론트엔드: hasPermission + +```typescript +import { useAuthStore } from '@common/store/authStore' + +const { hasPermission } = useAuthStore() + +hasPermission('board:notice') // READ 확인 (기본값) +hasPermission('board:notice', 'CREATE') // 생성 권한 확인 +hasPermission('board:notice', 'UPDATE') // 수정 권한 확인 +hasPermission('board:notice', 'DELETE') // 삭제 권한 확인 +``` + +#### 상속 규칙 + +``` +규칙 1: 부모 READ=N → 자식의 모든 오퍼레이션 강제 N +규칙 2: 명시적 레코드 있으면 → 그 값 사용 +규칙 3: 명시적 레코드 없으면 → 부모의 같은 오퍼레이션 상속 +규칙 4: 최상위까지 없으면 → 기본 N (거부) +``` + +--- + +### 4. DB 설계 규칙 + +#### 스키마 선택 + +| 데이터 성격 | 스키마 | 예시 | +|-------------|--------|------| +| 운영 데이터 | `wing` | BOARD_POST, LAYER, HNS_SUBSTANCE | +| 인증/인가 | `auth` | AUTH_USER, AUTH_ROLE, AUTH_PERM | + +> `search_path = wing, auth, public` 설정으로 스키마 접두사 없이 접근 가능. +> 단, 다른 스키마 테이블을 FK로 참조할 때는 `auth.AUTH_USER(USER_ID)` 처럼 명시한다. + +#### 네이밍 규칙 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| 테이블명 | UPPER_SNAKE_CASE | `BOARD_POST`, `HNS_SUBSTANCE` | +| 컬럼명 | UPPER_SNAKE_CASE | `POST_SN`, `CATEGORY_CD`, `REG_DTM` | +| PK | `{접두어}_SN` (SERIAL) 또는 `{접두어}_ID` (UUID) | `POST_SN`, `USER_ID` | +| FK 컬럼 | 참조 테이블의 PK 컬럼명 그대로 사용 | `AUTHOR_ID` (→ AUTH_USER.USER_ID) | +| 코드성 컬럼 | `{의미}_CD` | `CATEGORY_CD`, `OPER_CD` | +| 여부 컬럼 | `{의미}_YN` (CHAR(1), 'Y'/'N') | `USE_YN`, `PINNED_YN` | +| 일시 컬럼 | `{의미}_DTM` (TIMESTAMPTZ) | `REG_DTM`, `MDFCN_DTM` | + +#### 공통 컬럼 패턴 + +모든 운영 테이블에 포함하는 표준 컬럼: + +```sql +USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 논리삭제 (Y=활성, N=삭제) +REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 +MDFCN_DTM TIMESTAMPTZ, -- 수정일시 +``` + +#### DDL 작성 예시 + +```sql +-- database/migration/NNN_description.sql + +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + -- FK: 다른 스키마 참조 시 스키마 명시 + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + + -- CHECK: 코드성 컬럼에 허용값 명시 + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +-- COMMENT: 테이블/컬럼 설명 +COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; +COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; + +-- INDEX: 검색/필터 대상, FK 컬럼 +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +``` + +#### 마이그레이션 파일 규칙 + +- 경로: `database/migration/NNN_description.sql` +- 번호: 기존 파일 다음 번호 (001, 003, 004, 005, 006, ...) +- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전) +- 파일 끝에 검증 SELECT 포함 + +--- + +### 5. Service 레이어 패턴 + +#### 인터페이스 정의 + +Service 파일 상단에 반환 타입과 입력 타입을 정의한다. + +```typescript +// backend/src/{domain}/{domain}Service.ts + +import { wingPool } from '../db/wingDb.js' +import { AuthError } from '../auth/authService.js' + +// 목록/상세 조회 반환 타입 +interface PostItem { + postSn: number + categoryCd: string + title: string + content: string | null + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + useYn: string + regDtm: string + mdfcnDtm: string | null +} + +// 생성 입력 타입 +interface CreatePostInput { + categoryCd: string + title: string + content?: string + authorId: string + pinnedYn?: string +} + +// 수정 입력 타입 (모든 필드 optional — 부분 업데이트) +interface UpdatePostInput { + title?: string + content?: string + categoryCd?: string + pinnedYn?: string +} + +// 페이징 응답 타입 +interface PagedResult { + items: T[] + totalCount: number + page: number + size: number +} +``` + +#### wingPool 사용 + +```typescript +import { wingPool } from '../db/wingDb.js' + +// 단순 조회 +const result = await wingPool.query( + 'SELECT * FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = $2', + [postSn, 'Y'] +) + +// Parameterized Query — 반드시 $1, $2, ... 사용 (SQL Injection 방지) +// 문자열 결합으로 쿼리를 만들지 않는다 +``` + +#### 동적 WHERE 빌드 패턴 (필터, 검색) + +```typescript +export async function listPosts( + categoryCd?: string, + search?: string, + page: number = 1, + size: number = 20, +): Promise> { + // 동적 WHERE 조건 + const conditions: string[] = ["p.USE_YN = 'Y'"] + const params: (string | number)[] = [] + let paramIdx = 1 + + if (categoryCd) { + conditions.push(`p.CATEGORY_CD = $${paramIdx++}`) + params.push(categoryCd) + } + + if (search) { + conditions.push(`(p.TITLE ILIKE $${paramIdx} OR p.CONTENT ILIKE $${paramIdx})`) + params.push(`%${search}%`) + paramIdx++ + } + + const whereClause = conditions.join(' AND ') + + // totalCount 조회 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM BOARD_POST p WHERE ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 페이징 데이터 조회 + const offset = (page - 1) * size + const dataParams = [...params, size, offset] + + const dataResult = await wingPool.query( + `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, + p.TITLE as title, p.CONTENT as content, + p.AUTHOR_ID as author_id, u.USER_NM as author_name, + p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, + p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm + FROM BOARD_POST p + LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID + WHERE ${whereClause} + ORDER BY p.PINNED_YN DESC, p.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, + dataParams + ) + + const items: PostItem[] = dataResult.rows.map((row) => ({ + postSn: row.post_sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: row.author_name, + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + useYn: row.use_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + })) + + return { items, totalCount, page, size } +} +``` + +#### 상세 조회 + +```typescript +export async function getPost(postSn: number): Promise { + const result = await wingPool.query( + `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, + p.TITLE as title, p.CONTENT as content, + p.AUTHOR_ID as author_id, u.USER_NM as author_name, + p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, + p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm + FROM BOARD_POST p + LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID + WHERE p.POST_SN = $1 AND p.USE_YN = 'Y'`, + [postSn] + ) + + if (result.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + return { + postSn: row.post_sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: row.author_name, + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + useYn: row.use_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + } +} +``` + +#### 생성 + +```typescript +export async function createPost(input: CreatePostInput): Promise<{ postSn: number }> { + const result = await wingPool.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) + VALUES ($1, $2, $3, $4, $5) + RETURNING POST_SN as post_sn`, + [input.categoryCd, input.title, input.content || null, input.authorId, input.pinnedYn || 'N'] + ) + + return { postSn: result.rows[0].post_sn } +} +``` + +#### 동적 SET 빌드 패턴 (부분 업데이트) + +```typescript +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string, +): Promise { + // 소유자 검증 + const existing = await wingPool.query( + "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", + [postSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + // 동적 SET 빌드 + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.title !== undefined) { + sets.push(`TITLE = $${idx++}`) + params.push(input.title) + } + if (input.content !== undefined) { + sets.push(`CONTENT = $${idx++}`) + params.push(input.content) + } + if (input.categoryCd !== undefined) { + sets.push(`CATEGORY_CD = $${idx++}`) + params.push(input.categoryCd) + } + if (input.pinnedYn !== undefined) { + sets.push(`PINNED_YN = $${idx++}`) + params.push(input.pinnedYn) + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + // MDFCN_DTM 자동 갱신 + sets.push('MDFCN_DTM = NOW()') + params.push(postSn) + + await wingPool.query( + `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, + params + ) +} +``` + +#### 삭제 (논리삭제) + +```typescript +export async function deletePost(postSn: number, requesterId: string): Promise { + // 소유자 검증 + const existing = await wingPool.query( + "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", + [postSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) + } + + // 논리삭제: USE_YN = 'N' + await wingPool.query( + "UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1", + [postSn] + ) +} +``` + +#### 트랜잭션 패턴 + +여러 테이블을 동시에 변경해야 할 때: + +```typescript +export async function createPostWithAttachments( + input: CreatePostInput, + attachments: AttachmentInput[], +): Promise<{ postSn: number }> { + const client = await wingPool.connect() + + try { + await client.query('BEGIN') + + // 게시글 생성 + const postResult = await client.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID) + VALUES ($1, $2, $3, $4) + RETURNING POST_SN as post_sn`, + [input.categoryCd, input.title, input.content, input.authorId] + ) + const postSn = postResult.rows[0].post_sn + + // 첨부파일 생성 + for (const att of attachments) { + await client.query( + `INSERT INTO BOARD_ATTACH (POST_SN, FILE_NM, FILE_PATH, FILE_SIZE) + VALUES ($1, $2, $3, $4)`, + [postSn, att.fileName, att.filePath, att.fileSize] + ) + } + + await client.query('COMMIT') + return { postSn } + } catch (err) { + await client.query('ROLLBACK') + throw err + } finally { + client.release() + } +} +``` + +#### 에러 처리 + +```typescript +import { AuthError } from '../auth/authService.js' + +// AuthError: status 코드와 메시지를 포함하는 커스텀 에러 +// Router에서 instanceof 체크로 적절한 HTTP 응답을 반환 + +throw new AuthError('게시글을 찾을 수 없습니다.', 404) +throw new AuthError('권한이 없습니다.', 403) +throw new AuthError('필수 항목이 누락되었습니다.', 400) +throw new AuthError('이미 존재하는 데이터입니다.', 409) +``` + +`AuthError` 클래스 정의 (`backend/src/auth/authService.ts`): + +```typescript +export class AuthError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.status = status + this.name = 'AuthError' + } +} +``` + +--- + +### 6. Router 레이어 패턴 + +#### 미들웨어 체인 + +``` +requireAuth → requirePermission(resource, operation) → 핸들러 +``` + +- `requireAuth`: JWT 쿠키 검증, `req.user`에 페이로드 세팅 +- `requirePermission`: 리소스 x 오퍼레이션 권한 확인 + +#### CRUD 엔드포인트 표준 + +보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. +OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. + +| URL 패턴 | OPER_CD | 미들웨어 | +|----------|---------|----------| +| `POST /api/{domain}/list` | READ | `requirePermission(resource, 'READ')` | +| `POST /api/{domain}/detail` | READ | `requirePermission(resource, 'READ')` | +| `POST /api/{domain}/create` | CREATE | `requirePermission(resource, 'CREATE')` | +| `POST /api/{domain}/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | +| `POST /api/{domain}/delete` | DELETE | `requirePermission(resource, 'DELETE')` | + +#### 전체 Router 예시 + +```typescript +// backend/src/board/boardRouter.ts + +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' +import { AuthError } from '../auth/authService.js' +import { + listPosts, + getPost, + createPost, + updatePost, + deletePost, +} from './boardService.js' + +const router = Router() + +// 모든 엔드포인트에 인증 필수 +router.use(requireAuth) + +// 목록 조회 +router.post('/list', requirePermission('board:notice', 'READ'), async (req, res) => { + try { + const { categoryCd, search, page, size } = req.body + const result = await listPosts(categoryCd, search, page, size) + res.json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 목록 조회 오류:', err) + res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// 상세 조회 +router.post('/detail', requirePermission('board:notice', 'READ'), async (req, res) => { + try { + const { postSn } = req.body + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + const post = await getPost(postSn) + res.json(post) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 상세 조회 오류:', err) + res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' }) + } +}) + +// 생성 +router.post('/create', requirePermission('board:notice', 'CREATE'), async (req, res) => { + try { + const { categoryCd, title, content, pinnedYn } = req.body + + // 필수 필드 검증 + if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + + // req.user!.sub = 현재 로그인 사용자 UUID + const result = await createPost({ + categoryCd, + title, + content, + authorId: req.user!.sub, + pinnedYn, + }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 생성 오류:', err) + res.status(500).json({ error: '게시글 생성 중 오류가 발생했습니다.' }) + } +}) + +// 수정 +router.post('/update', requirePermission('board:notice', 'UPDATE'), async (req, res) => { + try { + const { postSn, title, content, categoryCd, pinnedYn } = req.body + + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + + await updatePost(postSn, { title, content, categoryCd, pinnedYn }, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 수정 오류:', err) + res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' }) + } +}) + +// 삭제 +router.post('/delete', requirePermission('board:notice', 'DELETE'), async (req, res) => { + try { + const { postSn } = req.body + + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + + await deletePost(postSn, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 삭제 오류:', err) + res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router +``` + +#### 입력 검증 패턴 + +핸들러 내부에서 필수 필드를 직접 체크한다. + +```typescript +// 필수 필드 검증 +if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return +} + +// 배열 타입 검증 +if (!Array.isArray(roleSns)) { + res.status(400).json({ error: '역할 목록이 필요합니다.' }) + return +} + +// 길이 검증 +if (!password || password.length < 4) { + res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다.' }) + return +} +``` + +#### 에러 응답 패턴 + +모든 핸들러에서 동일한 에러 처리 구조를 사용한다. + +```typescript +try { + // 비즈니스 로직 +} catch (err) { + // 1. AuthError → 해당 status + message + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + // 2. 예상치 못한 에러 → 500 + 일반 메시지 (내부 정보 노출 방지) + console.error('[domain] 작업 오류:', err) + res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) +} +``` + +#### server.ts 등록 + +```typescript +// backend/src/server.ts + +import boardRouter from './board/boardRouter.js' + +// API 라우트 — 업무 +app.use('/api/board', boardRouter) +``` + +#### req.user 구조 (JWT 페이로드) + +`requireAuth` 통과 후 `req.user`에 담기는 정보: + +```typescript +interface JwtPayload { + sub: string // 사용자 UUID (USER_ID) + acnt: string // 계정명 (USER_ACNT) + name: string // 사용자명 (USER_NM) + roles: string[] // 역할 코드 목록 ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] +} + +// 사용 예시 +const userId = req.user!.sub // 현재 사용자 UUID +const userName = req.user!.name // 현재 사용자 이름 +const isAdmin = req.user!.roles.includes('ADMIN') +``` + +--- + +### 7. 프론트엔드 연동 패턴 + +#### API 서비스 파일 + +탭별로 `services/` 디렉토리에 API 함수를 분리한다. + +```typescript +// frontend/src/tabs/board/services/boardApi.ts + +import { api } from '@common/services/api' + +// 타입 정의 +export interface PostItem { + postSn: number + categoryCd: string + title: string + content: string | null + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + useYn: string + regDtm: string + mdfcnDtm: string | null +} + +export interface PostListResult { + items: PostItem[] + totalCount: number + page: number + size: number +} + +// 목록 조회 +export async function fetchPosts(params: { + categoryCd?: string + search?: string + page?: number + size?: number +}): Promise { + const response = await api.post('/board/list', params) + return response.data +} + +// 상세 조회 +export async function fetchPost(postSn: number): Promise { + const response = await api.post('/board/detail', { postSn }) + return response.data +} + +// 생성 +export async function createPostApi(data: { + categoryCd: string + title: string + content?: string + pinnedYn?: string +}): Promise<{ postSn: number }> { + const response = await api.post<{ postSn: number }>('/board/create', data) + return response.data +} + +// 수정 +export async function updatePostApi( + postSn: number, + data: { title?: string; content?: string; categoryCd?: string; pinnedYn?: string }, +): Promise { + await api.post('/board/update', { postSn, ...data }) +} + +// 삭제 +export async function deletePostApi(postSn: number): Promise { + await api.post('/board/delete', { postSn }) +} +``` + +#### Axios 인스턴스 + +```typescript +// frontend/src/common/services/api.ts (이미 설정됨, 수정 불필요) + +import axios from 'axios' + +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', + withCredentials: true, // JWT 쿠키 자동 포함 + timeout: 30000, // 30초 타임아웃 +}) + +// 401 응답 시 자동 로그아웃 (인터셉터) +// 403 응답 시 권한 부족 (requirePermission 미들웨어) +``` + +#### 권한 기반 UI 분기 + +```tsx +// frontend/src/tabs/board/components/PostList.tsx + +import { useAuthStore } from '@common/store/authStore' + +const PostList = () => { + const { hasPermission } = useAuthStore() + + return ( +
+

게시판

+ + {/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */} + {hasPermission('board:notice', 'CREATE') && ( + + )} + + {/* 목록 렌더링 */} + {posts.map((post) => ( +
+ {post.title} + + {/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */} + {hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && ( + + )} + + {/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */} + {hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && ( + + )} +
+ ))} + + {/* 페이징 */} + +
+ ) +} +``` + +#### TanStack Query 연동 (권장) + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchPosts, createPostApi, deletePostApi } from '../services/boardApi' + +// 목록 조회 +const { data, isLoading } = useQuery({ + queryKey: ['posts', categoryCd, search, page], + queryFn: () => fetchPosts({ categoryCd, search, page, size: 20 }), +}) + +// 생성 +const queryClient = useQueryClient() +const createMutation = useMutation({ + mutationFn: createPostApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, +}) + +// 삭제 +const deleteMutation = useMutation({ + mutationFn: deletePostApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, +}) +``` + +--- + +### 8. 권한 상속 실전 시나리오 + +`AUTH_PERM_TREE`와 `AUTH_PERM`의 상속 규칙이 실제로 어떻게 동작하는지 4가지 시나리오로 설명한다. + +#### 시나리오 1: 부모 허용 → 자식 상속 + +``` +AUTH_PERM: + ADMIN 역할 — board READ=Y, CREATE=Y, UPDATE=Y, DELETE=Y + +결과: + board:notice READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y + board:notice CREATE → 명시적 레코드 없음 → 부모(board) CREATE=Y 상속 → Y + board:data READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y + +→ 부모에게 권한을 주면 모든 자식이 자동으로 같은 권한을 상속한다. +``` + +#### 시나리오 2: 명시적 거부 (Override) + +``` +AUTH_PERM: + MANAGER 역할 — board READ=Y, CREATE=Y + board:notice CREATE=N (명시적) + +결과: + board:notice READ → 부모 상속 Y + board:notice CREATE → 명시적 N → N (공지 작성 불가) + board:data CREATE → 부모 상속 Y (자료실은 작성 가능) + +→ 자식에 명시적 레코드가 있으면 부모 상속보다 우선한다. +``` + +#### 시나리오 3: 부모 접근 차단 → 자식 전체 차단 + +``` +AUTH_PERM: + VIEWER 역할 — board READ=N + +결과: + board:notice READ → 부모 READ=N → 강제 N (규칙 1) + board:notice CREATE → 부모 READ=N → 강제 N (규칙 1) + board:data READ → 부모 READ=N → 강제 N (규칙 1) + +→ 부모의 READ가 N이면 자식의 모든 오퍼레이션이 강제 차단된다. + 자식에 명시적 Y가 있어도 무시된다. +``` + +#### 시나리오 4: 서브리소스 개별 허용 + +``` +AUTH_PERM: + USER 역할 — board READ=Y, CREATE=N + board:qna CREATE=Y (명시적) + +결과: + board:notice CREATE → 부모 상속 N (공지 작성 불가) + board:data CREATE → 부모 상속 N (자료실 작성 불가) + board:qna CREATE → 명시적 Y → Y (Q&A는 작성 가능) + +→ 부모에서 CUD를 기본 차단하고, 특정 서브리소스만 허용하는 패턴. +``` + +#### 내부 키 형식 + +permResolver에서 리소스와 오퍼레이션을 결합할 때 더블콜론(`::`)을 사용한다. + +``` +리소스 내부 경로: board:notice (싱글콜론) +리소스-오퍼레이션 결합: board:notice::READ (더블콜론, 내부 전용) +``` + +```typescript +// backend/src/roles/permResolver.ts +export function makePermKey(rsrcCode: string, operCd: string): string { + return `${rsrcCode}::${operCd}` +} +``` + +--- + +### 9. 새 CRUD API 추가 체크리스트 + +새 도메인의 CRUD API를 추가할 때 아래 순서대로 진행한다. + +#### 백엔드 + +- [ ] `database/migration/NNN_{domain}.sql` 작성 (DDL + 초기 데이터) + - 테이블 생성 (IF NOT EXISTS) + - FK, CHECK 제약, 인덱스 + - COMMENT + - 검증 SELECT +- [ ] DB 마이그레이션 실행 (`psql`로 직접 실행) +- [ ] `backend/src/{domain}/{domain}Service.ts` 작성 + - 인터페이스 정의 (Item, CreateInput, UpdateInput) + - CRUD 함수 (list, get, create, update, delete) + - wingPool import, AuthError import + - 동적 WHERE/SET 빌드, 소유자 검증 +- [ ] `backend/src/{domain}/{domain}Router.ts` 작성 + - requireAuth + requirePermission 미들웨어 + - POST /list, /detail, /create, /update, /delete + - 입력 검증, AuthError 분기, 500 에러 처리 +- [ ] `backend/src/server.ts`에 라우터 등록 + ```typescript + import boardRouter from './board/boardRouter.js' + app.use('/api/board', boardRouter) + ``` +- [ ] 빌드 확인: `cd backend && npm run build` + +#### 권한 등록 (필요 시) + +- [ ] `AUTH_PERM_TREE`에 리소스 등록 (마이그레이션 SQL) + ```sql + INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) + VALUES ('board:notice', 'board', '공지사항', 1, 2) + ON CONFLICT (RSRC_CD) DO NOTHING; + ``` +- [ ] `AUTH_PERM`에 역할별 권한 초기값 추가 (마이그레이션 SQL) + ```sql + -- ADMIN: 모든 오퍼레이션 허용 + INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + SELECT r.ROLE_SN, 'board:notice', op.cd, 'Y' + FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd) + WHERE r.ROLE_CD = 'ADMIN' + ON CONFLICT DO NOTHING; + + -- VIEWER: READ만 허용 + INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + SELECT r.ROLE_SN, 'board:notice', 'READ', 'Y' + FROM AUTH_ROLE r + WHERE r.ROLE_CD = 'VIEWER' + ON CONFLICT DO NOTHING; + ``` + +#### 프론트엔드 + +- [ ] `frontend/src/tabs/{domain}/services/{domain}Api.ts` 작성 + - 타입 정의 (interface) + - CRUD API 함수 (api.post 사용) +- [ ] 프론트 컴포넌트에서 mock 데이터 → API 호출로 전환 +- [ ] `hasPermission()` 조건부 렌더링 적용 + - CREATE 권한 → 글쓰기 버튼 + - UPDATE 권한 → 수정 버튼 + - DELETE 권한 → 삭제 버튼 +- [ ] 빌드 확인: `cd frontend && npx tsc --noEmit` + +--- + +## Part 2: 게시판 실전 튜토리얼 + +게시판(Board) CRUD API를 처음부터 끝까지 구현한 실전 예제. +Part 1의 규칙을 실제로 어떻게 적용하는지 단계별로 설명한다. + +--- + +### Step 1: DB 테이블 설계 + +**파일**: `database/migration/006_board.sql` + +```sql +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +``` + +**설계 포인트**: +- `wing` 스키마에 생성 (search_path 덕분에 쿼리에서 스키마 접두사 불필요) +- `AUTHOR_ID`는 `auth.AUTH_USER(USER_ID)`를 cross-schema FK 참조 +- `USE_YN`으로 논리 삭제 (물리 삭제 대신 `'N'`으로 변경) +- `CATEGORY_CD` CHECK 제약으로 유효값 강제 + +#### 카테고리 ↔ 리소스 매핑 + +| CATEGORY_CD | AUTH_PERM_TREE 리소스 | 정책 | +|---|---|---| +| `NOTICE` | `board:notice` | ADMIN/MANAGER만 CUD | +| `DATA` | `board:data` | MANAGER 이상 CUD | +| `QNA` | `board:qna` | 인증 사용자 CUD (본인 글만 UD) | +| `MANUAL` | `board:manual` | ADMIN만 CUD | + +--- + +### Step 2: Service 구현 + +**파일**: `backend/src/board/boardService.ts` + +#### 인터페이스 정의 + +```typescript +interface PostListItem { + sn: number + categoryCd: string + title: string + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + regDtm: string +} + +interface ListPostsInput { + categoryCd?: string + search?: string + page?: number + size?: number +} + +interface ListPostsResult { + items: PostListItem[] + totalCount: number + page: number + size: number +} +``` + +#### 목록 조회 (페이징 + 필터 + 검색) + +```typescript +export async function listPosts(input: ListPostsInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1 + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 + const offset = (page - 1) * size + + let whereClause = `WHERE bp.USE_YN = 'Y'` + const params: (string | number)[] = [] + let paramIdx = 1 + + if (input.categoryCd) { + whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` + params.push(input.categoryCd) + } + + if (input.search) { + whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` + params.push(`%${input.search}%`) + paramIdx++ + } + + // 전체 건수 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 목록 (상단고정 우선 → 등록일 내림차순) + const listParams = [...params, size, offset] + const listResult = await wingPool.query( + `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, + bp.AUTHOR_ID as author_id, u.USER_NM as author_name, + bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause} + ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams + ) + // ... 결과 매핑 후 return +} +``` + +**핵심**: `JOIN AUTH_USER`로 cross-schema JOIN 수행 (작성자명 표시). 이것이 DB 통합의 핵심 이점. + +#### 소유자 검증 패턴 (수정/삭제) + +```typescript +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string // ← req.user.sub (JWT에서 추출) +): Promise { + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + // 본인 글만 수정 가능 + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + // ... 동적 SET 빌드 + UPDATE +} +``` + +#### 논리 삭제 + +```typescript +export async function deletePost(postSn: number, requesterId: string): Promise { + // 소유자 검증 (위와 동일) + await wingPool.query( + `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, + [postSn] + ) +} +``` + +--- + +### Step 3: Router 구현 + +**파일**: `backend/src/board/boardRouter.ts` + +#### 카테고리별 동적 리소스 결정 + +```typescript +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +} +``` + +#### 엔드포인트별 requirePermission 적용 + +```typescript +// 목록/상세: 부모 리소스 'board' READ +router.get('/', requireAuth, requirePermission('board', 'READ'), listHandler) +router.get('/:sn', requireAuth, requirePermission('board', 'READ'), getHandler) + +// 작성: 카테고리별 서브리소스 CREATE (핵심!) +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' + requirePermission(resource, 'CREATE')(req, res, next) +}, createHandler) + +// 수정/삭제: 부모 리소스 권한 + 서비스에서 소유자 검증 +router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), updateHandler) +router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), deleteHandler) +``` + +**카테고리별 작성 권한의 원리**: +- POST `/api/board` 요청 시 body에 `categoryCd`가 포함 +- 미들웨어에서 `CATEGORY_RESOURCE[categoryCd]`로 서브리소스 결정 +- `board:notice` CREATE 권한이 없는 사용자는 공지 작성 불가 +- `board:qna` CREATE 권한이 있으면 Q&A는 작성 가능 + +--- + +### Step 4: server.ts 등록 + +```typescript +import boardRouter from './board/boardRouter.js' + +// API 라우트 — 업무 +app.use('/api/board', boardRouter) +``` + +--- + +### Step 5: 프론트엔드 연동 + +#### API 서비스 + +**파일**: `frontend/src/tabs/board/services/boardApi.ts` + +```typescript +import { api } from '@common/services/api'; + +export interface BoardPostItem { + sn: number; + categoryCd: string; + title: string; + authorId: string; + authorName: string; + viewCnt: number; + pinnedYn: string; + regDtm: string; +} + +export interface BoardListResponse { + items: BoardPostItem[]; + totalCount: number; + page: number; + size: number; +} + +export async function fetchBoardPosts(params?: BoardListParams): Promise { + const response = await api.get('/board', { params }); + return response.data; +} + +export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> { + const response = await api.post<{ sn: number }>('/board', input); + return response.data; +} +``` + +#### 권한 기반 UI 분기 + +**파일**: `frontend/src/tabs/board/components/BoardListTable.tsx` + +```tsx +import { useAuthStore } from '@common/store/authStore'; + +const hasPermission = useAuthStore((s) => s.hasPermission); + +// 카테고리별 서브리소스 CREATE 권한 확인 +const canWrite = selectedCategory + ? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE') + : hasPermission('board', 'CREATE'); + +// 글쓰기 버튼 조건부 렌더링 +{canWrite && ( + +)} +``` + +--- + +### Step 6: 권한 시나리오 테스트 + +| 시나리오 | 역할 | 요청 | 결과 | +|---|---|---|---| +| ADMIN이 공지 작성 | ADMIN | POST `/api/board` `{categoryCd:"NOTICE"}` | 201 Created | +| USER가 공지 작성 | USER | POST `/api/board` `{categoryCd:"NOTICE"}` | 403 (board:notice CREATE 없음) | +| USER가 Q&A 작성 | USER | POST `/api/board` `{categoryCd:"QNA"}` | 201 (board:qna CREATE 있음) | +| VIEWER가 Q&A 작성 | VIEWER | POST `/api/board` `{categoryCd:"QNA"}` | 403 (board:qna CREATE 없음) | +| USER가 본인 글 수정 | USER | PUT `/api/board/11` (본인 글) | 200 | +| USER가 타인 글 수정 | USER | PUT `/api/board/1` (타인 글) | 403 (소유자 검증 실패) | +| ADMIN이 목록 조회 | ADMIN | GET `/api/board` | 200 (board READ 있음) | + +--- + +### 관련 파일 전체 목록 + +| 위치 | 파일 | 설명 | +|---|---|---| +| DB | `database/migration/006_board.sql` | DDL + 초기 데이터 | +| 백엔드 | `backend/src/board/boardService.ts` | CRUD 비즈니스 로직 | +| 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission | +| 백엔드 | `backend/src/server.ts` | boardRouter 등록 | +| 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 | +| 프론트 | `frontend/src/tabs/board/components/BoardListTable.tsx` | 목록 UI (API 연동) | diff --git a/frontend/src/common/constants/featureIds.ts b/frontend/src/common/constants/featureIds.ts new file mode 100644 index 0000000..9fee631 --- /dev/null +++ b/frontend/src/common/constants/featureIds.ts @@ -0,0 +1,72 @@ +/** + * FEATURE_ID 레지스트리 + * + * 서브탭 단위의 기능 식별자. AUTH_PERM.RSRC_CD 및 감사로그 ACTION_DTL과 동기화. + * 형식: '{메인탭}:{서브탭}' + */ +export const FEATURE_IDS = { + // prediction + 'prediction:analysis': '확산 분석', + 'prediction:list': '시뮬레이션 목록', + 'prediction:theory': '확산 이론', + 'prediction:boom-theory': '오일펜스 배치 이론', + + // hns + 'hns:analysis': 'HNS 분석', + 'hns:list': 'HNS 시뮬레이션 목록', + 'hns:scenario': 'HNS 시나리오', + 'hns:manual': 'HNS 매뉴얼', + 'hns:theory': 'HNS 이론', + 'hns:substance': 'HNS 물질정보', + + // rescue + 'rescue:rescue': '구난 메인', + 'rescue:list': '구난 목록', + 'rescue:scenario': '구난 시나리오', + 'rescue:theory': '구난 이론', + + // aerial + 'aerial:media': '영상 관리', + 'aerial:analysis': '유출 면적 분석', + 'aerial:realtime': '실시간 드론', + 'aerial:sensor': '센서 분석', + 'aerial:satellite': '위성 요청', + 'aerial:cctv': 'CCTV 모니터링', + 'aerial:theory': '항공탐색 이론', + + // reports + 'reports:report-list': '보고서 목록', + 'reports:template': '보고서 템플릿', + 'reports:generate': '보고서 생성', + + // board + 'board:all': '전체 게시판', + 'board:notice': '공지사항', + 'board:data': '자료실', + 'board:qna': '질의응답', + 'board:manual': '매뉴얼', + + // assets + 'assets:management': '자산 관리', + 'assets:upload': '자산 현행화', + 'assets:theory': '방제자원 이론', + 'assets:insurance': '선박 보험정보', + + // scat + 'scat:survey': 'SCAT 조사', + + // weather + 'weather:current': '현재 기상', + 'weather:forecast': '기상 예보', + + // incidents + 'incidents:list': '사고 목록', + + // admin + 'admin:users': '사용자 관리', + 'admin:permissions': '권한 매트릭스', + 'admin:menus': '메뉴 관리', + 'admin:settings': '시스템 설정', +} as const; + +export type FeatureId = keyof typeof FEATURE_IDS; diff --git a/frontend/src/common/hooks/useFeatureTracking.ts b/frontend/src/common/hooks/useFeatureTracking.ts new file mode 100644 index 0000000..2aaf674 --- /dev/null +++ b/frontend/src/common/hooks/useFeatureTracking.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useAuthStore } from '@common/store/authStore'; +import { API_BASE_URL } from '@common/services/api'; + +/** + * 서브탭 진입 시 감사 로그를 기록하는 훅. + * App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다. + * + * N-depth 지원: 콜론 구분 경로 (예: 'aerial:media', 'admin:users', 'a:b:c:d') + * + * @param featureId - 콜론 구분 리소스 경로 + */ +export function useFeatureTracking(featureId: string) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated || !featureId) return; + const blob = new Blob( + [JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })], + { type: 'text/plain' }, + ); + navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob); + }, [featureId, isAuthenticated]); +} diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 9dac4b4..dafecae 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react' import type { MainTab } from '../types/navigation' +import { useAuthStore } from '@common/store/authStore' +import { API_BASE_URL } from '@common/services/api' interface SubMenuItem { id: string @@ -91,6 +93,8 @@ function subscribe(listener: () => void) { export function useSubMenu(mainTab: MainTab) { const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab]) + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const hasPermission = useAuthStore((s) => s.hasPermission) useEffect(() => { const unsubscribe = subscribe(() => { @@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) { setSubTab(mainTab, subTab) } + // 권한 기반 서브메뉴 필터링 + const rawConfig = subMenuConfigs[mainTab] + const filteredConfig = rawConfig?.filter(item => + hasPermission(`${mainTab}:${item.id}`) + ) ?? null + + // 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로) + useEffect(() => { + if (!isAuthenticated || !activeSubTab) return + const resourcePath = `${mainTab}:${activeSubTab}` + const blob = new Blob( + [JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })], + { type: 'text/plain' }, + ) + navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) + }, [mainTab, activeSubTab, isAuthenticated]) + return { activeSubTab, setActiveSubTab, - subMenuConfig: subMenuConfigs[mainTab] + subMenuConfig: filteredConfig, } } diff --git a/frontend/src/common/services/api.ts b/frontend/src/common/services/api.ts index 904a372..6d99764 100755 --- a/frontend/src/common/services/api.ts +++ b/frontend/src/common/services/api.ts @@ -1,6 +1,6 @@ import axios from 'axios' -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' export const api = axios.create({ baseURL: API_BASE_URL, diff --git a/frontend/src/common/services/authApi.ts b/frontend/src/common/services/authApi.ts index 5f58a77..9fccd40 100644 --- a/frontend/src/common/services/authApi.ts +++ b/frontend/src/common/services/authApi.ts @@ -7,7 +7,7 @@ export interface AuthUser { rank: string | null org: { sn: number; name: string; abbr: string } | null roles: string[] - permissions: string[] + permissions: Record } interface LoginResponse { @@ -117,6 +117,7 @@ export interface RoleWithPermissions { permissions: Array<{ sn: number resourceCode: string + operationCode: string granted: boolean }> } @@ -126,9 +127,26 @@ export async function fetchRoles(): Promise { return response.data } +// 권한 트리 구조 API +export interface PermTreeNode { + code: string + parentCode: string | null + name: string + description: string | null + icon: string | null + level: number + sortOrder: number + children: PermTreeNode[] +} + +export async function fetchPermTree(): Promise { + const response = await api.get('/roles/perm-tree') + return response.data +} + export async function updatePermissionsApi( roleSn: number, - permissions: Array<{ resourceCode: string; granted: boolean }> + permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }> ): Promise { await api.put(`/roles/${roleSn}/permissions`, { permissions }) } diff --git a/frontend/src/common/store/authStore.ts b/frontend/src/common/store/authStore.ts index 299cdc6..f47a474 100644 --- a/frontend/src/common/store/authStore.ts +++ b/frontend/src/common/store/authStore.ts @@ -12,7 +12,7 @@ interface AuthState { googleLogin: (credential: string) => Promise logout: () => Promise checkSession: () => Promise - hasPermission: (resource: string) => boolean + hasPermission: (resource: string, operation?: string) => boolean clearError: () => void } @@ -70,10 +70,12 @@ export const useAuthStore = create((set, get) => ({ } }, - hasPermission: (resource: string) => { + hasPermission: (resource: string, operation?: string) => { const { user } = get() if (!user) return false - return user.permissions.includes(resource) + const ops = user.permissions[resource] + if (!ops) return false + return ops.includes(operation ?? 'READ') }, clearError: () => set({ error: null, pendingMessage: null }), diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 06b0f67..3633998 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -1,1293 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' -import data from '@emoji-mart/data' -import EmojiPicker from '@emoji-mart/react' -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, - type DragEndEvent, -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { - fetchUsers, - fetchRoles, - updatePermissionsApi, - updateUserApi, - updateRoleDefaultApi, - approveUserApi, - rejectUserApi, - assignRolesApi, - createRoleApi, - updateRoleApi, - deleteRoleApi, - fetchRegistrationSettings, - updateRegistrationSettingsApi, - fetchOAuthSettings, - updateOAuthSettingsApi, - fetchMenuConfig, - updateMenuConfigApi, - type UserListItem, - type RoleWithPermissions, - type RegistrationSettings, - type OAuthSettings, - type MenuConfigItem, -} from '@common/services/authApi' -import { useMenuStore } from '@common/store/menuStore' - -const DEFAULT_ROLE_COLORS: Record = { - ADMIN: 'var(--red)', - MANAGER: 'var(--orange)', - USER: 'var(--cyan)', - VIEWER: 'var(--t3)', -} - -const CUSTOM_ROLE_COLORS = [ - '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf', -] - -function getRoleColor(code: string, index: number): string { - return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length] -} - -const statusLabels: Record = { - PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, - ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, - LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, - INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, - REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, -} - - -// ─── 사용자 관리 패널 ───────────────────────────────────────── -function UsersPanel() { - const [searchTerm, setSearchTerm] = useState('') - const [statusFilter, setStatusFilter] = useState('') - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) - const [allRoles, setAllRoles] = useState([]) - const [roleEditUserId, setRoleEditUserId] = useState(null) - const [selectedRoleSns, setSelectedRoleSns] = useState([]) - const roleDropdownRef = useRef(null) - - const loadUsers = useCallback(async () => { - setLoading(true) - try { - const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) - setUsers(data) - } catch (err) { - console.error('사용자 목록 조회 실패:', err) - } finally { - setLoading(false) - } - }, [searchTerm, statusFilter]) - - useEffect(() => { - loadUsers() - }, [loadUsers]) - - useEffect(() => { - fetchRoles().then(setAllRoles).catch(console.error) - }, []) - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { - setRoleEditUserId(null) - } - } - if (roleEditUserId) { - document.addEventListener('mousedown', handleClickOutside) - } - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [roleEditUserId]) - - const handleUnlock = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'ACTIVE' }) - await loadUsers() - } catch (err) { - console.error('계정 잠금 해제 실패:', err) - } - } - - const handleApprove = async (userId: string) => { - try { - await approveUserApi(userId) - await loadUsers() - } catch (err) { - console.error('사용자 승인 실패:', err) - } - } - - const handleReject = async (userId: string) => { - try { - await rejectUserApi(userId) - await loadUsers() - } catch (err) { - console.error('사용자 거절 실패:', err) - } - } - - const handleDeactivate = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'INACTIVE' }) - await loadUsers() - } catch (err) { - console.error('사용자 비활성화 실패:', err) - } - } - - const handleActivate = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'ACTIVE' }) - await loadUsers() - } catch (err) { - console.error('사용자 활성화 실패:', err) - } - } - - const handleOpenRoleEdit = (user: UserListItem) => { - setRoleEditUserId(user.id) - setSelectedRoleSns(user.roleSns || []) - } - - const toggleRoleSelection = (roleSn: number) => { - setSelectedRoleSns(prev => - prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn] - ) - } - - const handleSaveRoles = async (userId: string) => { - try { - await assignRolesApi(userId, selectedRoleSns) - await loadUsers() - setRoleEditUserId(null) - } catch (err) { - console.error('역할 할당 실패:', err) - } - } - - const formatDate = (dateStr: string | null) => { - if (!dateStr) return '-' - return new Date(dateStr).toLocaleString('ko-KR', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }) - } - - const pendingCount = users.filter(u => u.status === 'PENDING').length - - return ( -
-
-
-
-

사용자 관리

-

총 {users.length}명

-
- {pendingCount > 0 && ( - - 승인대기 {pendingCount}명 - - )} -
-
- - setSearchTerm(e.target.value)} - className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> - -
-
- -
- {loading ? ( -
불러오는 중...
- ) : ( - - - - - - - - - - - - - - - {users.map((user) => { - const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE - return ( - - - - - - - - - - - ) - })} - -
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} -
-
handleOpenRoleEdit(user)} - title="클릭하여 역할 변경" - > - {user.roles.length > 0 ? user.roles.map((roleCode, idx) => { - const color = getRoleColor(roleCode, idx) - const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode - return ( - - {roleName} - - ) - }) : ( - 역할 없음 - )} - - - -
- {roleEditUserId === user.id && ( -
-
역할 선택
- {allRoles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} -
- - -
-
- )} -
-
- {user.oauthProvider ? ( - - - Google - - ) : ( - - - ID/PW - - )} - - - - {statusInfo.label} - - {formatDate(user.lastLogin)} -
- {user.status === 'PENDING' && ( - <> - - - - )} - {user.status === 'LOCKED' && ( - - )} - {user.status === 'ACTIVE' && ( - - )} - {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( - - )} -
-
- )} -
-
- ) -} - -// ─── 권한 관리 패널 ───────────────────────────────────────── -const PERM_RESOURCES = [ - { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, - { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, - { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, - { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, - { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, - { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, - { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, - { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, - { id: 'board', label: '게시판', desc: '게시판 접근' }, - { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, - { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, -] - -function PermissionsPanel() { - const [roles, setRoles] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [dirty, setDirty] = useState(false) - const [showCreateForm, setShowCreateForm] = useState(false) - const [newRoleCode, setNewRoleCode] = useState('') - const [newRoleName, setNewRoleName] = useState('') - const [newRoleDesc, setNewRoleDesc] = useState('') - const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState('') - const [editingRoleSn, setEditingRoleSn] = useState(null) - const [editRoleName, setEditRoleName] = useState('') - - useEffect(() => { - loadRoles() - }, []) - - const loadRoles = async () => { - setLoading(true) - try { - const data = await fetchRoles() - setRoles(data) - setDirty(false) - } catch (err) { - console.error('역할 목록 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const getPermGranted = (roleSn: number, resourceCode: string): boolean => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return false - const perm = role.permissions.find(p => p.resourceCode === resourceCode) - return perm?.granted ?? false - } - - const togglePerm = (roleSn: number, resourceCode: string) => { - setRoles(prev => prev.map(role => { - if (role.sn !== roleSn) return role - const perms = role.permissions.map(p => - p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p - ) - if (!perms.find(p => p.resourceCode === resourceCode)) { - perms.push({ sn: 0, resourceCode, granted: true }) - } - return { ...role, permissions: perms } - })) - setDirty(true) - } - - const toggleDefault = async (roleSn: number) => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return - const newValue = !role.isDefault - try { - await updateRoleDefaultApi(roleSn, newValue) - setRoles(prev => prev.map(r => - r.sn === roleSn ? { ...r, isDefault: newValue } : r - )) - } catch (err) { - console.error('기본 역할 변경 실패:', err) - } - } - - const handleSave = async () => { - setSaving(true) - try { - for (const role of roles) { - const permissions = PERM_RESOURCES.map(r => ({ - resourceCode: r.id, - granted: getPermGranted(role.sn, r.id), - })) - await updatePermissionsApi(role.sn, permissions) - } - setDirty(false) - } catch (err) { - console.error('권한 저장 실패:', err) - } finally { - setSaving(false) - } - } - - const handleCreateRole = async () => { - setCreating(true) - setCreateError('') - try { - await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined }) - await loadRoles() - setShowCreateForm(false) - setNewRoleCode('') - setNewRoleName('') - setNewRoleDesc('') - } catch (err) { - const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.' - setCreateError(message) - } finally { - setCreating(false) - } - } - - const handleDeleteRole = async (roleSn: number, roleName: string) => { - if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) { - return - } - try { - await deleteRoleApi(roleSn) - await loadRoles() - } catch (err) { - console.error('역할 삭제 실패:', err) - } - } - - const handleStartEditName = (role: RoleWithPermissions) => { - setEditingRoleSn(role.sn) - setEditRoleName(role.name) - } - - const handleSaveRoleName = async (roleSn: number) => { - if (!editRoleName.trim()) return - try { - await updateRoleApi(roleSn, { name: editRoleName.trim() }) - setRoles(prev => prev.map(r => - r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r - )) - setEditingRoleSn(null) - } catch (err) { - console.error('역할 이름 수정 실패:', err) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-
-

사용자 권한 관리

-

역할별 메뉴 접근 권한을 설정합니다

-
-
- - -
-
- -
- - - - - {roles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} - - - - {PERM_RESOURCES.map((perm) => ( - - - {roles.map(role => ( - - ))} - - ))} - -
기능 -
- {editingRoleSn === role.sn ? ( - setEditRoleName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveRoleName(role.sn) - if (e.key === 'Escape') setEditingRoleSn(null) - }} - onBlur={() => handleSaveRoleName(role.sn)} - autoFocus - className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean" - /> - ) : ( - handleStartEditName(role)} - title="클릭하여 이름 수정" - > - {role.name} - - )} - {role.code !== 'ADMIN' && ( - - )} -
-
{role.code}
- -
-
{perm.label}
-
{perm.desc}
-
- -
-
- - {/* 역할 생성 모달 */} - {showCreateForm && ( -
-
-
-

새 역할 추가

-
-
-
- - setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} - placeholder="CUSTOM_ROLE" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" - /> -

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

-
-
- - setNewRoleName(e.target.value)} - placeholder="사용자 정의 역할" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> -
-
- - setNewRoleDesc(e.target.value)} - placeholder="역할에 대한 설명" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> -
- {createError && ( -
- {createError} -
- )} -
-
- - -
-
-
- )} -
- ) -} - -// ─── 메뉴 항목 (Sortable) ──────────────────────────────────── -interface SortableMenuItemProps { - menu: MenuConfigItem - idx: number - totalCount: number - isEditing: boolean - emojiPickerId: string | null - emojiPickerRef: React.RefObject - onToggle: (id: string) => void - onMove: (idx: number, direction: -1 | 1) => void - onEditStart: (id: string) => void - onEditEnd: () => void - onEmojiPickerToggle: (id: string | null) => void - onLabelChange: (id: string, value: string) => void - onEmojiSelect: (emoji: { native: string }) => void -} - -function SortableMenuItem({ - menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef, - onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect, -}: SortableMenuItemProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: menu.id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - zIndex: isDragging ? 50 : undefined, - } - - return ( -
-
- - {idx + 1} - {isEditing ? ( - <> -
- - {emojiPickerId === menu.id && ( -
- -
- )} -
-
- onLabelChange(menu.id, e.target.value)} - className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" - /> -
{menu.id}
-
- - - ) : ( - <> - {menu.icon} -
-
- {menu.label} -
-
{menu.id}
-
- - - )} -
-
- -
- - -
-
-
- ) -} - -// ─── 메뉴 관리 패널 ───────────────────────────────────────── -function MenusPanel() { - const [menus, setMenus] = useState([]) - const [originalMenus, setOriginalMenus] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [editingId, setEditingId] = useState(null) - const [emojiPickerId, setEmojiPickerId] = useState(null) - const [activeId, setActiveId] = useState(null) - const emojiPickerRef = useRef(null) - const { setMenuConfig } = useMenuStore() - - const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus) - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ) - - const loadMenus = useCallback(async () => { - setLoading(true) - try { - const config = await fetchMenuConfig() - setMenus(config) - setOriginalMenus(config) - } catch (err) { - console.error('메뉴 설정 조회 실패:', err) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - loadMenus() - }, [loadMenus]) - - useEffect(() => { - if (!emojiPickerId) return - const handler = (e: MouseEvent) => { - if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) { - setEmojiPickerId(null) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [emojiPickerId]) - - const toggleMenu = (id: string) => { - setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) - } - - const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => { - setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m)) - } - - const handleEmojiSelect = (emoji: { native: string }) => { - if (emojiPickerId) { - updateMenuField(emojiPickerId, 'icon', emoji.native) - setEmojiPickerId(null) - } - } - - const moveMenu = (idx: number, direction: -1 | 1) => { - const targetIdx = idx + direction - if (targetIdx < 0 || targetIdx >= menus.length) return - setMenus(prev => { - const arr = [...prev] - ;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]] - return arr.map((m, i) => ({ ...m, order: i + 1 })) - }) - } - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event - setActiveId(null) - if (!over || active.id === over.id) return - setMenus(prev => { - const oldIndex = prev.findIndex(m => m.id === active.id) - const newIndex = prev.findIndex(m => m.id === over.id) - const reordered = arrayMove(prev, oldIndex, newIndex) - return reordered.map((m, i) => ({ ...m, order: i + 1 })) - }) - } - - const handleSave = async () => { - setSaving(true) - try { - const updated = await updateMenuConfigApi(menus) - setMenus(updated) - setOriginalMenus(updated) - setMenuConfig(updated) - } catch (err) { - console.error('메뉴 설정 저장 실패:', err) - } finally { - setSaving(false) - } - } - - if (loading) { - return ( -
-
메뉴 설정을 불러오는 중...
-
- ) - } - - const activeMenu = activeId ? menus.find(m => m.id === activeId) : null - - return ( -
-
-
-

메뉴 관리

-

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

-
- -
- -
- setActiveId(event.active.id as string)} - onDragEnd={handleDragEnd} - > - m.id)} strategy={verticalListSortingStrategy}> -
- {menus.map((menu, idx) => ( - { setEditingId(null); setEmojiPickerId(null) }} - onEmojiPickerToggle={setEmojiPickerId} - onLabelChange={(id, value) => updateMenuField(id, 'label', value)} - onEmojiSelect={handleEmojiSelect} - /> - ))} -
-
- - {activeMenu ? ( -
- - {activeMenu.icon} - {activeMenu.label} -
- ) : null} -
-
-
-
- ) -} - -// ─── 시스템 설정 패널 ──────────────────────────────────────── -function SettingsPanel() { - const [settings, setSettings] = useState(null) - const [oauthSettings, setOauthSettings] = useState(null) - const [oauthDomainInput, setOauthDomainInput] = useState('') - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [savingOAuth, setSavingOAuth] = useState(false) - - useEffect(() => { - loadSettings() - }, []) - - const loadSettings = async () => { - setLoading(true) - try { - const [regData, oauthData] = await Promise.all([ - fetchRegistrationSettings(), - fetchOAuthSettings(), - ]) - setSettings(regData) - setOauthSettings(oauthData) - setOauthDomainInput(oauthData.autoApproveDomains) - } catch (err) { - console.error('설정 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const handleToggle = async (key: keyof RegistrationSettings) => { - if (!settings) return - const newValue = !settings[key] - setSaving(true) - try { - const updated = await updateRegistrationSettingsApi({ [key]: newValue }) - setSettings(updated) - } catch (err) { - console.error('설정 변경 실패:', err) - } finally { - setSaving(false) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-

시스템 설정

-

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

-
- -
-
- {/* 사용자 등록 설정 */} -
-
-

사용자 등록 설정

-

신규 사용자 등록 시 적용되는 정책을 설정합니다

-
- -
- {/* 자동 승인 */} -
-
-
자동 승인
-

- 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. - 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. -

-
- -
- - {/* 기본 역할 자동 할당 */} -
-
-
기본 역할 자동 할당
-

- 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. - 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. -

-
- -
-
-
- - {/* OAuth 설정 */} -
-
-

Google OAuth 설정

-

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

-
-
-
-
자동 승인 도메인
-

- 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. - 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. - 여러 도메인은 쉼표(,)로 구분합니다. -

-
- setOauthDomainInput(e.target.value)} - placeholder="gcsc.co.kr, example.com" - className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" - /> - -
-
- {oauthSettings?.autoApproveDomains && ( -
- {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( - - @{domain} - - ))} -
- )} -
-
- - {/* 현재 설정 상태 요약 */} -
-
-

설정 상태 요약

-
-
-
-
- - - 신규 사용자 등록 시{' '} - {settings?.autoApprove ? ( - 즉시 활성화 - ) : ( - 관리자 승인 필요 - )} - -
-
- - - 기본 역할 자동 할당{' '} - {settings?.defaultRole ? ( - 활성 - ) : ( - 비활성 - )} - -
-
- - - Google OAuth 자동 승인 도메인{' '} - {oauthSettings?.autoApproveDomains ? ( - {oauthSettings.autoApproveDomains} - ) : ( - 미설정 - )} - -
-
-
-
-
-
-
- ) -} +import UsersPanel from './UsersPanel' +import PermissionsPanel from './PermissionsPanel' +import MenusPanel from './MenusPanel' +import SettingsPanel from './SettingsPanel' // ─── AdminView ──────────────────────────────────────────── export function AdminView() { diff --git a/frontend/src/tabs/admin/components/MenusPanel.tsx b/frontend/src/tabs/admin/components/MenusPanel.tsx new file mode 100644 index 0000000..80a4ffd --- /dev/null +++ b/frontend/src/tabs/admin/components/MenusPanel.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { + fetchMenuConfig, + updateMenuConfigApi, + type MenuConfigItem, +} from '@common/services/authApi' +import { useMenuStore } from '@common/store/menuStore' +import SortableMenuItem from './SortableMenuItem' + +// ─── 메뉴 관리 패널 ───────────────────────────────────────── +function MenusPanel() { + const [menus, setMenus] = useState([]) + const [originalMenus, setOriginalMenus] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [editingId, setEditingId] = useState(null) + const [emojiPickerId, setEmojiPickerId] = useState(null) + const [activeId, setActiveId] = useState(null) + const emojiPickerRef = useRef(null) + const { setMenuConfig } = useMenuStore() + + const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const loadMenus = useCallback(async () => { + setLoading(true) + try { + const config = await fetchMenuConfig() + setMenus(config) + setOriginalMenus(config) + } catch (err) { + console.error('메뉴 설정 조회 실패:', err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadMenus() + }, [loadMenus]) + + useEffect(() => { + if (!emojiPickerId) return + const handler = (e: MouseEvent) => { + if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) { + setEmojiPickerId(null) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [emojiPickerId]) + + const toggleMenu = (id: string) => { + setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) + } + + const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => { + setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m)) + } + + const handleEmojiSelect = (emoji: { native: string }) => { + if (emojiPickerId) { + updateMenuField(emojiPickerId, 'icon', emoji.native) + setEmojiPickerId(null) + } + } + + const moveMenu = (idx: number, direction: -1 | 1) => { + const targetIdx = idx + direction + if (targetIdx < 0 || targetIdx >= menus.length) return + setMenus(prev => { + const arr = [...prev] + ;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]] + return arr.map((m, i) => ({ ...m, order: i + 1 })) + }) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + if (!over || active.id === over.id) return + setMenus(prev => { + const oldIndex = prev.findIndex(m => m.id === active.id) + const newIndex = prev.findIndex(m => m.id === over.id) + const reordered = arrayMove(prev, oldIndex, newIndex) + return reordered.map((m, i) => ({ ...m, order: i + 1 })) + }) + } + + const handleSave = async () => { + setSaving(true) + try { + const updated = await updateMenuConfigApi(menus) + setMenus(updated) + setOriginalMenus(updated) + setMenuConfig(updated) + } catch (err) { + console.error('메뉴 설정 저장 실패:', err) + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+
메뉴 설정을 불러오는 중...
+
+ ) + } + + const activeMenu = activeId ? menus.find(m => m.id === activeId) : null + + return ( +
+
+
+

메뉴 관리

+

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

+
+ +
+ +
+ setActiveId(event.active.id as string)} + onDragEnd={handleDragEnd} + > + m.id)} strategy={verticalListSortingStrategy}> +
+ {menus.map((menu, idx) => ( + { setEditingId(null); setEmojiPickerId(null) }} + onEmojiPickerToggle={setEmojiPickerId} + onLabelChange={(id, value) => updateMenuField(id, 'label', value)} + onEmojiSelect={handleEmojiSelect} + /> + ))} +
+
+ + {activeMenu ? ( +
+ + {activeMenu.icon} + {activeMenu.label} +
+ ) : null} +
+
+
+
+ ) +} + +export default MenusPanel diff --git a/frontend/src/tabs/admin/components/PermissionsPanel.tsx b/frontend/src/tabs/admin/components/PermissionsPanel.tsx new file mode 100644 index 0000000..e20ccac --- /dev/null +++ b/frontend/src/tabs/admin/components/PermissionsPanel.tsx @@ -0,0 +1,667 @@ +import { useState, useEffect, useCallback } from 'react' +import { + fetchRoles, + fetchPermTree, + updatePermissionsApi, + createRoleApi, + updateRoleApi, + deleteRoleApi, + updateRoleDefaultApi, + type RoleWithPermissions, + type PermTreeNode, +} from '@common/services/authApi' +import { getRoleColor } from './adminConstants' + +// ─── 오퍼레이션 코드 ───────────────────────────────── +const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const +type OperCode = (typeof OPER_CODES)[number] +const OPER_LABELS: Record = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' } +const OPER_FULL_LABELS: Record = { READ: '조회', CREATE: '생성', UPDATE: '수정', DELETE: '삭제' } + +// ─── 권한 상태 타입 ───────────────────────────────────── +type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied' + +// ─── 키 유틸 ────────────────────────────────────────── +function makeKey(rsrc: string, oper: string): string { return `${rsrc}::${oper}` } + +// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ───────────── +function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] { + const result: PermTreeNode[] = [] + function walk(list: PermTreeNode[]) { + for (const n of list) { + result.push(n) + if (n.children.length > 0) walk(n.children) + } + } + walk(nodes) + return result +} + +// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ────────────── +function resolvePermStateForOper( + code: string, + parentCode: string | null, + operCd: string, + explicitPerms: Map, + cache: Map, +): PermState { + const key = makeKey(code, operCd) + const cached = cache.get(key) + if (cached) return cached + + const explicit = explicitPerms.get(key) + + if (parentCode === null) { + const state: PermState = explicit === true ? 'explicit-granted' + : explicit === false ? 'explicit-denied' + : 'explicit-denied' + cache.set(key, state) + return state + } + + // 부모 READ 확인 (접근 게이트) + const parentReadKey = makeKey(parentCode, 'READ') + const parentReadState = cache.get(parentReadKey) + if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') { + cache.set(key, 'forced-denied') + return 'forced-denied' + } + + if (explicit === true) { + cache.set(key, 'explicit-granted') + return 'explicit-granted' + } + if (explicit === false) { + cache.set(key, 'explicit-denied') + return 'explicit-denied' + } + + // 부모의 같은 오퍼레이션 상속 + const parentOperKey = makeKey(parentCode, operCd) + const parentOperState = cache.get(parentOperKey) + if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') { + cache.set(key, 'inherited-granted') + return 'inherited-granted' + } + if (parentOperState === 'forced-denied') { + cache.set(key, 'forced-denied') + return 'forced-denied' + } + + cache.set(key, 'explicit-denied') + return 'explicit-denied' +} + +function buildEffectiveStates( + flatNodes: PermTreeNode[], + explicitPerms: Map, +): Map { + const cache = new Map() + for (const node of flatNodes) { + // READ 먼저 (CUD는 READ에 의존) + resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache) + for (const oper of OPER_CODES) { + if (oper === 'READ') continue + resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache) + } + } + return cache +} + +// ─── 체크박스 셀 컴포넌트 ──────────────────────────── +interface PermCellProps { + state: PermState + onToggle: () => void + label?: string +} + +function PermCell({ state, onToggle, label }: PermCellProps) { + const isDisabled = state === 'forced-denied' + + const baseClasses = 'w-7 h-7 rounded border text-xs font-bold transition-all flex items-center justify-center' + + let classes: string + let icon: string + + switch (state) { + case 'explicit-granted': + classes = `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan cursor-pointer hover:bg-[rgba(6,182,212,0.3)]` + icon = '✓' + break + case 'inherited-granted': + classes = `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-primary-cyan` + icon = '✓' + break + case 'explicit-denied': + classes = `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-pointer hover:border-red-400` + icon = '—' + break + case 'forced-denied': + classes = `${baseClasses} bg-bg-2 border-border text-text-3 opacity-40 cursor-not-allowed` + icon = '—' + break + } + + return ( + + ) +} + +// ─── 트리 행 컴포넌트 ──────────────────────────────── +interface TreeRowProps { + node: PermTreeNode + stateMap: Map + expanded: Set + onToggleExpand: (code: string) => void + onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void +} + +function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: TreeRowProps) { + const hasChildren = node.children.length > 0 + const isExpanded = expanded.has(node.code) + const indent = node.level * 24 + + // 이 노드의 READ 상태 (CUD 비활성 판단용) + const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied' + const readDenied = readState === 'explicit-denied' || readState === 'forced-denied' + + return ( + <> + + +
+ {hasChildren ? ( + + ) : ( + + {node.level > 0 ? '├' : ''} + + )} + {node.icon && {node.icon}} +
+
+ {node.name} +
+ {node.description && node.level === 0 && ( +
{node.description}
+ )} +
+
+ + {OPER_CODES.map(oper => { + const key = makeKey(node.code, oper) + const state = stateMap.get(key) ?? 'forced-denied' + // READ 거부 시 CUD도 강제 거부 + const effectiveState = (oper !== 'READ' && readDenied) ? 'forced-denied' as PermState : state + return ( + +
+ onTogglePerm(node.code, oper, effectiveState)} + /> +
+ + ) + })} + + {hasChildren && isExpanded && node.children.map(child => ( + + ))} + + ) +} + +// ─── 메인 PermissionsPanel ────────────────────────── +function PermissionsPanel() { + const [roles, setRoles] = useState([]) + const [permTree, setPermTree] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + const [showCreateForm, setShowCreateForm] = useState(false) + const [newRoleCode, setNewRoleCode] = useState('') + const [newRoleName, setNewRoleName] = useState('') + const [newRoleDesc, setNewRoleDesc] = useState('') + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState('') + const [editingRoleSn, setEditingRoleSn] = useState(null) + const [editRoleName, setEditRoleName] = useState('') + const [expanded, setExpanded] = useState>(new Set()) + const [selectedRoleSn, setSelectedRoleSn] = useState(null) + + // 역할별 명시적 권한: Map> + const [rolePerms, setRolePerms] = useState>>(new Map()) + + const loadData = useCallback(async () => { + setLoading(true) + try { + const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()]) + setRoles(rolesData) + setPermTree(treeData) + + // 명시적 권한 맵 초기화 (rsrc::oper 키 형식) + const permsMap = new Map>() + for (const role of rolesData) { + const roleMap = new Map() + for (const p of role.permissions) { + roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted) + } + permsMap.set(role.sn, roleMap) + } + setRolePerms(permsMap) + + // 최상위 노드 기본 펼침 + setExpanded(new Set(treeData.map(n => n.code))) + // 첫 번째 역할 선택 + if (rolesData.length > 0 && !selectedRoleSn) { + setSelectedRoleSn(rolesData[0].sn) + } + setDirty(false) + } catch (err) { + console.error('권한 데이터 조회 실패:', err) + } finally { + setLoading(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행 + }, []) + + useEffect(() => { + loadData() + }, [loadData]) + + // 플랫 노드 목록 + const flatNodes = flattenTree(permTree) + + // 선택된 역할의 effective state 계산 + const currentStateMap = selectedRoleSn + ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) + : new Map() + + const handleToggleExpand = useCallback((code: string) => { + setExpanded(prev => { + const next = new Set(prev) + if (next.has(code)) next.delete(code) + else next.add(code) + return next + }) + }, []) + + const handleTogglePerm = useCallback((code: string, oper: OperCode, currentState: PermState) => { + if (!selectedRoleSn) return + + setRolePerms(prev => { + const next = new Map(prev) + const roleMap = new Map(next.get(selectedRoleSn) ?? new Map()) + + const key = makeKey(code, oper) + const node = flatNodes.find(n => n.code === code) + const isRoot = node ? node.parentCode === null : false + + switch (currentState) { + case 'explicit-granted': + roleMap.set(key, false) + break + case 'inherited-granted': + roleMap.set(key, false) + break + case 'explicit-denied': + if (isRoot) { + roleMap.set(key, true) + } else { + roleMap.delete(key) + } + break + default: + return prev + } + + next.set(selectedRoleSn, roleMap) + return next + }) + setDirty(true) + }, [selectedRoleSn, flatNodes]) + + const handleSave = async () => { + setSaving(true) + try { + for (const role of roles) { + const perms = rolePerms.get(role.sn) + if (!perms) continue + + const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = [] + for (const [key, granted] of perms) { + const sepIdx = key.indexOf('::') + permsList.push({ + resourceCode: key.substring(0, sepIdx), + operationCode: key.substring(sepIdx + 2), + granted, + }) + } + await updatePermissionsApi(role.sn, permsList) + } + setDirty(false) + } catch (err) { + console.error('권한 저장 실패:', err) + } finally { + setSaving(false) + } + } + + const handleCreateRole = async () => { + setCreating(true) + setCreateError('') + try { + await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined }) + await loadData() + setShowCreateForm(false) + setNewRoleCode('') + setNewRoleName('') + setNewRoleDesc('') + } catch (err) { + const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.' + setCreateError(message) + } finally { + setCreating(false) + } + } + + const handleDeleteRole = async (roleSn: number, roleName: string) => { + if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) { + return + } + try { + await deleteRoleApi(roleSn) + if (selectedRoleSn === roleSn) setSelectedRoleSn(null) + await loadData() + } catch (err) { + console.error('역할 삭제 실패:', err) + } + } + + const handleStartEditName = (role: RoleWithPermissions) => { + setEditingRoleSn(role.sn) + setEditRoleName(role.name) + } + + const handleSaveRoleName = async (roleSn: number) => { + if (!editRoleName.trim()) return + try { + await updateRoleApi(roleSn, { name: editRoleName.trim() }) + setRoles(prev => prev.map(r => + r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r + )) + setEditingRoleSn(null) + } catch (err) { + console.error('역할 이름 수정 실패:', err) + } + } + + const toggleDefault = async (roleSn: number) => { + const role = roles.find(r => r.sn === roleSn) + if (!role) return + const newValue = !role.isDefault + try { + await updateRoleDefaultApi(roleSn, newValue) + setRoles(prev => prev.map(r => + r.sn === roleSn ? { ...r, isDefault: newValue } : r + )) + } catch (err) { + console.error('기본 역할 변경 실패:', err) + } + } + + if (loading) { + return
불러오는 중...
+ } + + return ( +
+ {/* 헤더 */} +
+
+

사용자 권한 관리

+

역할별 리소스 × CRUD 권한을 설정합니다

+
+
+ + +
+
+ + {/* 역할 탭 바 */} +
+ {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + const isSelected = selectedRoleSn === role.sn + return ( +
+ + {isSelected && ( +
+ + {role.code !== 'ADMIN' && ( + + )} +
+ )} +
+ ) + })} +
+ + {/* 범례 */} +
+ + + 명시적 허용 + + + + 상속 허용 + + + + 명시적 거부 + + + + 강제 거부 + + + R=조회 C=생성 U=수정 D=삭제 + +
+ + {/* CRUD 매트릭스 테이블 */} + {selectedRoleSn ? ( +
+ + + + + {OPER_CODES.map(oper => ( + + ))} + + + + {permTree.map(rootNode => ( + + ))} + +
기능 +
{OPER_LABELS[oper]}
+
{OPER_FULL_LABELS[oper]}
+
+
+ ) : ( +
+ 역할을 선택하세요 +
+ )} + + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} + placeholder="CUSTOM_ROLE" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" + /> +

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

+
+
+ + setNewRoleName(e.target.value)} + placeholder="사용자 정의 역할" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> +
+
+ + setNewRoleDesc(e.target.value)} + placeholder="역할에 대한 설명" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )} +
+ ) +} + +export default PermissionsPanel diff --git a/frontend/src/tabs/admin/components/SettingsPanel.tsx b/frontend/src/tabs/admin/components/SettingsPanel.tsx new file mode 100644 index 0000000..fd30cc9 --- /dev/null +++ b/frontend/src/tabs/admin/components/SettingsPanel.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react' +import { + fetchRegistrationSettings, + updateRegistrationSettingsApi, + fetchOAuthSettings, + updateOAuthSettingsApi, + type RegistrationSettings, + type OAuthSettings, +} from '@common/services/authApi' + +// ─── 시스템 설정 패널 ──────────────────────────────────────── +function SettingsPanel() { + const [settings, setSettings] = useState(null) + const [oauthSettings, setOauthSettings] = useState(null) + const [oauthDomainInput, setOauthDomainInput] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [savingOAuth, setSavingOAuth] = useState(false) + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + setLoading(true) + try { + const [regData, oauthData] = await Promise.all([ + fetchRegistrationSettings(), + fetchOAuthSettings(), + ]) + setSettings(regData) + setOauthSettings(oauthData) + setOauthDomainInput(oauthData.autoApproveDomains) + } catch (err) { + console.error('설정 조회 실패:', err) + } finally { + setLoading(false) + } + } + + const handleToggle = async (key: keyof RegistrationSettings) => { + if (!settings) return + const newValue = !settings[key] + setSaving(true) + try { + const updated = await updateRegistrationSettingsApi({ [key]: newValue }) + setSettings(updated) + } catch (err) { + console.error('설정 변경 실패:', err) + } finally { + setSaving(false) + } + } + + if (loading) { + return
불러오는 중...
+ } + + return ( +
+
+

시스템 설정

+

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

+
+ +
+
+ {/* 사용자 등록 설정 */} +
+
+

사용자 등록 설정

+

신규 사용자 등록 시 적용되는 정책을 설정합니다

+
+ +
+ {/* 자동 승인 */} +
+
+
자동 승인
+

+ 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. + 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. +

+
+ +
+ + {/* 기본 역할 자동 할당 */} +
+
+
기본 역할 자동 할당
+

+ 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. + 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. +

+
+ +
+
+
+ + {/* OAuth 설정 */} +
+
+

Google OAuth 설정

+

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

+
+
+
+
자동 승인 도메인
+

+ 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. + 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. + 여러 도메인은 쉼표(,)로 구분합니다. +

+
+ setOauthDomainInput(e.target.value)} + placeholder="gcsc.co.kr, example.com" + className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" + /> + +
+
+ {oauthSettings?.autoApproveDomains && ( +
+ {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( + + @{domain} + + ))} +
+ )} +
+
+ + {/* 현재 설정 상태 요약 */} +
+
+

설정 상태 요약

+
+
+
+
+ + + 신규 사용자 등록 시{' '} + {settings?.autoApprove ? ( + 즉시 활성화 + ) : ( + 관리자 승인 필요 + )} + +
+
+ + + 기본 역할 자동 할당{' '} + {settings?.defaultRole ? ( + 활성 + ) : ( + 비활성 + )} + +
+
+ + + Google OAuth 자동 승인 도메인{' '} + {oauthSettings?.autoApproveDomains ? ( + {oauthSettings.autoApproveDomains} + ) : ( + 미설정 + )} + +
+
+
+
+
+
+
+ ) +} + +export default SettingsPanel diff --git a/frontend/src/tabs/admin/components/SortableMenuItem.tsx b/frontend/src/tabs/admin/components/SortableMenuItem.tsx new file mode 100644 index 0000000..ed5e6f1 --- /dev/null +++ b/frontend/src/tabs/admin/components/SortableMenuItem.tsx @@ -0,0 +1,161 @@ +import data from '@emoji-mart/data' +import EmojiPicker from '@emoji-mart/react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { type MenuConfigItem } from '@common/services/authApi' + +// ─── 메뉴 항목 (Sortable) ──────────────────────────────────── +export interface SortableMenuItemProps { + menu: MenuConfigItem + idx: number + totalCount: number + isEditing: boolean + emojiPickerId: string | null + emojiPickerRef: React.RefObject + onToggle: (id: string) => void + onMove: (idx: number, direction: -1 | 1) => void + onEditStart: (id: string) => void + onEditEnd: () => void + onEmojiPickerToggle: (id: string | null) => void + onLabelChange: (id: string, value: string) => void + onEmojiSelect: (emoji: { native: string }) => void +} + +function SortableMenuItem({ + menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef, + onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect, +}: SortableMenuItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: menu.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 50 : undefined, + } + + return ( +
+
+ + {idx + 1} + {isEditing ? ( + <> +
+ + {emojiPickerId === menu.id && ( +
+ +
+ )} +
+
+ onLabelChange(menu.id, e.target.value)} + className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" + /> +
{menu.id}
+
+ + + ) : ( + <> + {menu.icon} +
+
+ {menu.label} +
+
{menu.id}
+
+ + + )} +
+
+ +
+ + +
+
+
+ ) +} + +export default SortableMenuItem diff --git a/frontend/src/tabs/admin/components/UsersPanel.tsx b/frontend/src/tabs/admin/components/UsersPanel.tsx new file mode 100644 index 0000000..417c58b --- /dev/null +++ b/frontend/src/tabs/admin/components/UsersPanel.tsx @@ -0,0 +1,350 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + fetchUsers, + fetchRoles, + updateUserApi, + approveUserApi, + rejectUserApi, + assignRolesApi, + type UserListItem, + type RoleWithPermissions, +} from '@common/services/authApi' +import { getRoleColor, statusLabels } from './adminConstants' + +// ─── 사용자 관리 패널 ───────────────────────────────────────── +function UsersPanel() { + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [allRoles, setAllRoles] = useState([]) + const [roleEditUserId, setRoleEditUserId] = useState(null) + const [selectedRoleSns, setSelectedRoleSns] = useState([]) + const roleDropdownRef = useRef(null) + + const loadUsers = useCallback(async () => { + setLoading(true) + try { + const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) + setUsers(data) + } catch (err) { + console.error('사용자 목록 조회 실패:', err) + } finally { + setLoading(false) + } + }, [searchTerm, statusFilter]) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + useEffect(() => { + fetchRoles().then(setAllRoles).catch(console.error) + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { + setRoleEditUserId(null) + } + } + if (roleEditUserId) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [roleEditUserId]) + + const handleUnlock = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'ACTIVE' }) + await loadUsers() + } catch (err) { + console.error('계정 잠금 해제 실패:', err) + } + } + + const handleApprove = async (userId: string) => { + try { + await approveUserApi(userId) + await loadUsers() + } catch (err) { + console.error('사용자 승인 실패:', err) + } + } + + const handleReject = async (userId: string) => { + try { + await rejectUserApi(userId) + await loadUsers() + } catch (err) { + console.error('사용자 거절 실패:', err) + } + } + + const handleDeactivate = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'INACTIVE' }) + await loadUsers() + } catch (err) { + console.error('사용자 비활성화 실패:', err) + } + } + + const handleActivate = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'ACTIVE' }) + await loadUsers() + } catch (err) { + console.error('사용자 활성화 실패:', err) + } + } + + const handleOpenRoleEdit = (user: UserListItem) => { + setRoleEditUserId(user.id) + setSelectedRoleSns(user.roleSns || []) + } + + const toggleRoleSelection = (roleSn: number) => { + setSelectedRoleSns(prev => + prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn] + ) + } + + const handleSaveRoles = async (userId: string) => { + try { + await assignRolesApi(userId, selectedRoleSns) + await loadUsers() + setRoleEditUserId(null) + } catch (err) { + console.error('역할 할당 실패:', err) + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString('ko-KR', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + } + + const pendingCount = users.filter(u => u.status === 'PENDING').length + + return ( +
+
+
+
+

사용자 관리

+

총 {users.length}명

+
+ {pendingCount > 0 && ( + + 승인대기 {pendingCount}명 + + )} +
+
+ + setSearchTerm(e.target.value)} + className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> + +
+
+ +
+ {loading ? ( +
불러오는 중...
+ ) : ( + + + + + + + + + + + + + + + {users.map((user) => { + const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE + return ( + + + + + + + + + + + ) + })} + +
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} +
+
handleOpenRoleEdit(user)} + title="클릭하여 역할 변경" + > + {user.roles.length > 0 ? user.roles.map((roleCode, idx) => { + const color = getRoleColor(roleCode, idx) + const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode + return ( + + {roleName} + + ) + }) : ( + 역할 없음 + )} + + + +
+ {roleEditUserId === user.id && ( +
+
역할 선택
+ {allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} +
+ + +
+
+ )} +
+
+ {user.oauthProvider ? ( + + + Google + + ) : ( + + + ID/PW + + )} + + + + {statusInfo.label} + + {formatDate(user.lastLogin)} +
+ {user.status === 'PENDING' && ( + <> + + + + )} + {user.status === 'LOCKED' && ( + + )} + {user.status === 'ACTIVE' && ( + + )} + {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( + + )} +
+
+ )} +
+
+ ) +} + +export default UsersPanel diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/tabs/admin/components/adminConstants.ts new file mode 100644 index 0000000..b27ed50 --- /dev/null +++ b/frontend/src/tabs/admin/components/adminConstants.ts @@ -0,0 +1,24 @@ +export const DEFAULT_ROLE_COLORS: Record = { + ADMIN: 'var(--red)', + MANAGER: 'var(--orange)', + USER: 'var(--cyan)', + VIEWER: 'var(--t3)', +} + +export const CUSTOM_ROLE_COLORS = [ + '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf', +] + +export function getRoleColor(code: string, index: number): string { + return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length] +} + +export const statusLabels: Record = { + PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, + ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, + LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, + INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, + REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, +} + +// PERM_RESOURCES 제거됨 — GET /api/roles/perm-tree에서 동적 로드 (PermissionsPanel) diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/tabs/aerial/components/AerialView.tsx index 321850f..26a171c 100755 --- a/frontend/src/tabs/aerial/components/AerialView.tsx +++ b/frontend/src/tabs/aerial/components/AerialView.tsx @@ -1,2445 +1,19 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' import { AerialTheoryView } from './AerialTheoryView' +import { MediaManagement } from './MediaManagement' +import { OilAreaAnalysis } from './OilAreaAnalysis' +import { RealtimeDrone } from './RealtimeDrone' +import { SensorAnalysis } from './SensorAnalysis' +import { SatelliteRequest } from './SatelliteRequest' +import { CctvView } from './CctvView' type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor' -// ── Mock Data ── - -interface MediaFile { - id: number - incident: string - location: string - filename: string - equipment: string - equipType: 'drone' | 'plane' | 'satellite' - mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학' - datetime: string - size: string - resolution: string -} - -const mediaFiles: MediaFile[] = [ - { id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' }, - { id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' }, - { id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' }, - { id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' }, - { id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' }, - { id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' }, - { id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' }, - { id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' }, - { id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' }, - { id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' }, - { id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' }, - { id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' }, - { id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' }, - { id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' }, - { id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' }, -] - -const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰' - -const equipTagCls = (t: string) => - t === 'drone' - ? 'bg-[rgba(59,130,246,0.12)] text-primary-blue' - : t === 'plane' - ? 'bg-[rgba(34,197,94,0.12)] text-status-green' - : 'bg-[rgba(168,85,247,0.12)] text-primary-purple' - -const mediaTagCls = (t: string) => - t === '영상' - ? 'bg-[rgba(239,68,68,0.12)] text-status-red' - : 'bg-[rgba(234,179,8,0.12)] text-status-yellow' - -// ── Tab 0: 영상·사진 관리 ── - -const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => ( - -) - -function MediaManagementTab() { - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [equipFilter, setEquipFilter] = useState('all') - const [typeFilter, setTypeFilter] = useState>(new Set()) - const [searchTerm, setSearchTerm] = useState('') - const [sortBy, setSortBy] = useState('latest') - const [showUpload, setShowUpload] = useState(false) - const modalRef = useRef(null) - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - setShowUpload(false) - } - } - if (showUpload) document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [showUpload]) - - const filtered = mediaFiles.filter(f => { - if (equipFilter !== 'all' && f.equipType !== equipFilter) return false - if (typeFilter.size > 0) { - const isPhoto = !['영상'].includes(f.mediaType) - const isVideo = f.mediaType === '영상' - if (typeFilter.has('photo') && !isPhoto) return false - if (typeFilter.has('video') && !isVideo) return false - } - if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false - return true - }) - - const sorted = [...filtered].sort((a, b) => { - if (sortBy === 'name') return a.filename.localeCompare(b.filename) - if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size) - return b.datetime.localeCompare(a.datetime) - }) - - const toggleId = (id: number) => { - setSelectedIds(prev => { - const next = new Set(prev) - if (next.has(id)) { next.delete(id) } else { next.add(id) } - return next - }) - } - - const toggleAll = () => { - if (selectedIds.size === sorted.length) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(sorted.map(f => f.id))) - } - } - - const toggleTypeFilter = (t: string) => { - setTypeFilter(prev => { - const next = new Set(prev) - if (next.has(t)) { next.delete(t) } else { next.add(t) } - return next - }) - } - - const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length - const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length - const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length - - return ( -
- {/* Filters */} -
-
- 촬영 장비: - setEquipFilter('all')} /> - setEquipFilter('drone')} /> - setEquipFilter('plane')} /> - setEquipFilter('satellite')} /> - - 유형: - toggleTypeFilter('photo')} /> - toggleTypeFilter('video')} /> -
-
- setSearchTerm(e.target.value)} - className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan" - /> - -
-
- - {/* Summary Stats */} -
- {[ - { icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' }, - { icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' }, - { icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' }, - { icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' }, - { icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' }, - ].map((s, i) => ( -
- {s.icon} -
-
{s.value}
-
{s.label}
-
-
- ))} -
- - {/* File Table */} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {sorted.map(f => ( - toggleId(f.id)} - className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${ - selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : '' - }`} - > - - - - - - - - - - - - - ))} - -
- 0} - onChange={toggleAll} - className="accent-primary-blue" - /> - - 사고명위치파일명장비유형촬영일시용량해상도📥
e.stopPropagation()}> - toggleId(f.id)} - className="accent-primary-blue" - /> - {equipIcon(f.equipType)}{f.incident}{f.location}{f.filename} - - {f.equipment} - - - - {f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType} - - {f.datetime}{f.size}{f.resolution} e.stopPropagation()}> - -
-
-
- - {/* Bottom Actions */} -
-
- 선택된 파일: {selectedIds.size}건 -
-
- - - -
-
- - {/* Upload Modal */} - {showUpload && ( -
-
-
- 📤 영상·사진 업로드 - -
-
-
📁
-
파일을 드래그하거나 클릭하여 업로드
-
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
-
-
- - -
-
- - -
-
- -