Compare commits

...

72 Commits

Author SHA1 Message Date
ttipo 9e29d0d0b0 refactor: 업무 삭제 모달을 공통 다이얼로그로 흡수
업무 삭제 모달은 공통 다이얼로그와 동일 구조(아이콘+제목+설명+취소/삭제)라
공통 시스템으로 통합한다. 구조가 다른 할 일 검토 모달은 그대로 유지.

- showDelete·confirmDelete·삭제 모달 마크업 제거
- askDelete() 에서 dialog.confirm(danger, 삭제)로 확인받아 삭제

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:35:03 +09:00
ttipo b1afe5480c feat: alert/confirm을 공통 모달 다이얼로그로 통합
모든 window.alert/confirm 을 업무 삭제 모달과 동일한 구조/디자인의
공통 모달로 대체하고, 한 곳에서 관리하도록 다이얼로그 시스템을 도입한다.

- dialog.store: 알림/확인 큐 + Promise 기반 상태 관리
- useDialog(): alert/confirm 컴포저블 API
- AppDialog: App 루트 1회 마운트, 전역 .modal-*/.mbtn 클래스로 렌더
  (variant: info/success/warning/danger → 아이콘·버튼 색)
- relay.css: modal-ico.info/.warning, mbtn.primary 변형 추가
- 교체: API 에러 알림(useApi), 검증/성공 알림(Signup/ProjectCreate/
  ProjectSettings/TaskCreate), 삭제·제거 확인(ProjectSettings/
  ProjectMembers/AgentChatPanel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:46:28 +09:00
ttipo 118d12f829 style: 지시자 카드의 지시 시각을 카드 우측 상단으로 이동
업무 상세 지시자 카드 하단에 있던 '언제 지시했는지' 텍스트를 '지시자' 라벨과
같은 줄의 우측 상단으로 배치한다.

- side-head(라벨 + 우측 보조 텍스트) 한 줄 배치 추가, issued-at 우측 정렬
- 하단 .meta-line 제거(미사용 스타일 정리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:16:27 +09:00
ttipo 5c3b1e5ab4 feat: 겹친 담당자 아바타 스택을 최대 3개 + N 형태로 통일
가로로 겹쳐 표시되는 프로필 스택이 3개를 넘으면 마지막에 +N 으로 표기한다.

- 업무 목록(ProjectDetailPage): 담당자 스택 3개 초과 시 +N
- 내 업무(MyTasksPage): 미니 담당자 스택 3개 초과 시 +N(.mini-more 추가)
- 프로젝트 멤버 스택(목록/헤더)은 이미 백엔드 AVATAR_LIMIT=3 으로 동일 처리됨
- 담당자 세로 목록(상세)·칩(지시 작성)은 겹침 스택이 아니라 제외

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:12:57 +09:00
ttipo f6b01b01a8 fix: 프로젝트 헤더에서 설명 없을 때 제목·메타 사이 빈 여백 제거
ProjectHeader(상세·멤버·활동·설정 공용)의 .rh-desc 가 설명이 비어도 빈 줄로
렌더되어 제목과 프로필·업데이트 시간 사이 간격이 벌어지던 문제 수정 —
설명이 있을 때만 렌더. (프로젝트 목록과 동일 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:04:08 +09:00
ttipo d9a343e5e9 fix: 프로젝트 목록에서 설명 없을 때 제목·메타 사이 빈 여백 제거
설명(desc)이 비어도 .repo-desc 가 빈 줄로 렌더되어 제목과 프로필·업데이트
시간 영역 사이 간격이 과하게 벌어지던 문제 수정 — 설명이 있을 때만 렌더.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:48:09 +09:00
ttipo 32815a0627 fix: 로그아웃을 사이드바 하단 상시 노출 버튼으로 변경
기존 드롭다운 메뉴 안에 숨어 보이지 않던 로그아웃을 항상 노출되는
버튼으로 변경한다.

- 사용자 프로필 행은 표시 전용, 아래에 로그아웃 버튼 상시 노출(빨강 호버)
- 접힘 시 아이콘만 표시
- 미사용 드롭다운(menuOpen·me-menu·me-overlay) 로직/스타일 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:40:30 +09:00
ttipo f4a1253526 feat: 사이드바 접기 + 모바일 드로어 + 검색 아이콘 제거
- 데스크톱: 사이드바 접기 토글(아이콘 레일, 상태 localStorage 보존)
  접힘 시 브랜드/네비/사용자가 아이콘만 표시, 메뉴는 레일 우측으로 펼침
- 모바일(≤48rem): 사이드바를 오버레이 드로어로 전환, 툴바 햄버거로 열고
  배경/화면 전환 시 닫힘(접힘 상태는 모바일에서 무시)
- 전역 도구에서 미사용 검색 아이콘 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:33:39 +09:00
ttipo f60f705f56 refactor: 상단 헤더 네비게이션을 좌측 사이드바로 전환
추후 메뉴 확장을 고려해 AppShell 레이아웃을 사이드바 구조로 변경한다.

- 좌측 사이드바: 브랜드 + 세로 네비(내 업무/프로젝트, 아이콘) + 하단 사용자 프로필·메뉴(위로 펼침)
- 우측 본문 컬럼: 슬림 툴바(페이지 액션 슬롯 + AI/검색/알림) + 기존 페이지 슬롯
- 알림 드롭다운·AI 패널 동작은 그대로 유지(.page 본문은 컬럼 내 중앙 정렬)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:23:09 +09:00
ttipo 3b78574855 feat: 수정 요청 메시지를 선택 사항으로 변경
수정 요청 시 공용 메시지 입력을 필수에서 선택으로 완화한다.

- 백엔드: ApprovalChangesDto.note 를 선택값으로 변경, requestChanges 가
  빈 메시지면 changesNote 를 null 로 저장
- 프론트: 제출 버튼 비활성(메시지 필수) 해제, 빈 메시지는 미전송,
  안내/라벨 문구를 '선택'으로 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:42:37 +09:00
ttipo 70d75cf88d refactor: 승인 요청 흐름에서 메시지 입력·표시 제거
수정 요청(검토 모달)을 제외한 나머지 승인 흐름에서는 메시지를 사용하지 않는다.

- 진행 중 → 승인 요청 카드의 메시지 입력창 제거
- 승인 요청 도착 카드의 승인 메시지 표시 제거
- approvalNote 상태 제거, sendApproval 은 메시지 없이 요청
- 미사용이 된 .ap-note 입력 스타일 정리(수정 요청 메모 .ap-note-box 는 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:28:16 +09:00
ttipo 8b3cebbc62 style: 승인 요청 카드의 설명과 버튼·입력 간격 확대
설명 텍스트와 아래 버튼/입력창이 너무 붙어 보여 간격을 넓힌다.

- 액션 버튼 영역 상단 여백 0.8125rem → 1.125rem
- 승인 메시지 입력창 상단 여백 추가(0.875rem)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:16:07 +09:00
ttipo b387b7e378 refactor: 수정 요청 후 다시 승인 요청 시 메시지 입력 제거
작업자가 수정 요청을 받은 뒤 다시 승인을 요청하는 화면에서
보완 메시지 입력창을 제거한다. (지시자의 수정 요청 내용은 그대로 표시)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:03:55 +09:00
ttipo 9d004dd64e feat: 할 일 항목별 보완 내용 개별 입력·표시 추가
공용 수정 요청 메시지는 그대로 두고, 검토 모달에서 각 보완 항목 아래에
항목별 보완 내용을 개별 등록할 수 있게 하고 상세 화면에 함께 표시한다.

- 백엔드: ChecklistItem.reviewNote 컬럼 추가, ChecklistReviewDto.note 수용,
  requestChanges 가 미완성 항목에 보완 내용 보관(완료/승인 시 비움), 상세 응답 동봉
- 프론트: 검토 모달 보완 항목 아래 보완 내용 textarea(개별), 상세 체크리스트
  보완 항목 아래 보완 내용 표시. 공용 메시지 입력은 유지
- 테스트: 미완성 항목 보완 내용 보관·완료 항목 비움 검증(9 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:26:48 +09:00
ttipo c32df3cf39 feat: 수정 요청 상태에서 보완 항목을 체크박스에 X로 표기
업무가 '수정 요청됨(changes)' 상태일 때 상세 화면 체크리스트의
미완료 항목을 빨강 X(보완 필요)로 표시해 담당자가 보완 대상을 명확히 보게 한다.

- 완료 항목은 초록 체크, 보완 항목은 빨강 X + '보완 필요' 태그
- changes 상태가 아닐 때는 기존대로 체크/빈 박스 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:10:19 +09:00
ttipo 5f0b80593f refactor: 승인/수정 요청을 '할 일 검토' 모달로 단일화
지시자 검토 카드의 '승인하기' 버튼과 별도 승인 모달을 제거하고,
단일 '할 일 검토하기' 버튼이 검토 모달을 열도록 통합한다.

- 검토 카드 버튼을 '할 일 검토하기' 하나로 정리(승인하기 제거)
- 미사용이 된 승인 확인 모달·approveTask·showApprove 상태 제거
- 승인은 검토 모달에서 전 항목 완료 시 '승인 완료' 버튼으로 수행

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:06:59 +09:00
ttipo 20f593bd5a feat: 수정 요청 모달을 '할 일 완료 검토' 디자인으로 개편
시안(수정 요청 모달.html)을 반영해 모달 UI를 재구성한다.

- 헤더(그라데이션 배지·안내)·스크롤 본문·고정 푸터 구조로 변경
- 항목별 완료/보완을 세그먼트 토글(완료=초록 / 보완=빨강 좌측 바)로 표시,
  카테고리 그룹·진행률 바·완료 카운트 동봉
- 보완 항목 수에 따라 안내 배너(warn/good)와 메시지 필수 여부가 전환
- 모든 항목 완료 시 버튼이 '승인 완료'(초록)로 바뀌어 곧바로 승인,
  보완 항목이 있으면 '수정 요청 보내기 (N건)'(빨강)으로 메시지와 함께 반려
- 모달 진입 시 전 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:18:53 +09:00
ttipo 79e0c820ba feat: 수정 요청 시 할 일 항목별 완료 체크/X 검토 추가
지시자가 승인을 검토하며 수정 요청할 때, 단순 텍스트 입력에 더해
할 일 목록에서 어떤 항목이 완성/미완성인지 체크(✓)·X로 표시할 수 있게 한다.

- 백엔드: ApprovalChangesDto 에 항목별 검토 결과(checklist) 추가,
  requestChanges 가 전달된 항목만 done 상태로 반영(미전달 항목은 보존)
- 프론트: 수정 요청 모달에 카테고리별 체크/X 토글 UI 추가, 검토 결과를
  note 와 함께 전송
- 테스트: 전달된 항목만 완료 체크가 반영되는지 검증(9 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:46:03 +09:00
ttipo c86c04f99e feat: 체크리스트를 필수·관리·보안·부가 카테고리 탭으로 구조화
업무 지시(생성/편집)·상세 화면의 '해야 할 일'을 AI 추천과 동일한
4개 영역(필수/관리/보안/부가)으로 분류한다.

- 백엔드: ChecklistItem 에 category 컬럼(기본 '필수') 추가, 생성/동기화
  DTO·서비스·응답에 category 반영
- 프론트: 생성/편집은 카테고리 탭 + 탭별 추가 입력, AI 추천 항목은
  소속 영역으로 매핑해 일괄 추가, 상세는 카테고리별 그룹 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:18:37 +09:00
ttipo 93c8ae2a22 docs: 단독 실행(non-Docker) 관련 내용 전부 제거
Docker 만 사용하므로 standalone 안내를 정리:
- 프론트 env(.example): '단독 실행 시 주석 해제' 안내 + 주석 처리된 VITE 라인 제거
  (VITE_ 값은 compose build arg 로만 주입됨)
- CLAUDE.md: '단독 실행 시 주석 예시 활성화' 규칙 삭제
- README: 사전요구사항/cp 명령/‘단독 실행’ 섹션/env 표의 standalone 문구 제거
  (모바일은 compose 비대상이라 별도 실행 안내만 한 줄 유지)
- integration-progress: Redis 폴백 설명의 '단독 실행' 표현을 'REDIS_HOST 미설정 시'로 교정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:53:31 +09:00
ttipo 1f2a93b01a chore: 모든 env 파일 구조·주석 정리 및 env↔example 규격 통일
- 죽은 설정 제거: GITEA_*/REPO_IMPORT_ADMIN_EMAIL (저장소·Gitea 기능 삭제됨)
- 섹션 헤더 스타일을 # === 블록으로 전 파일 통일(루트/백엔드/프론트)
- 루트: AI 섹션(AI_TIMEOUT_MS) 형식 통일 + production 파일에 누락됐던 AI 섹션 추가
- 백엔드: JWT/앱URL/OAuth/AI 4섹션으로 일관 정렬, production 에 누락됐던 URL/OAuth/AI 추가
- 프론트: 빌드 주입 변수 안내(VITE_API_BASE_URL·VITE_AI_TIMEOUT_MS) 명시
- 각 .env 와 .example 의 키·순서·주석을 동일하게(값만 실값/placeholder 차이)

(실제 .env.* 는 gitignore 라 미커밋 — 로컬에선 동일 구조로 함께 정리함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:21:40 +09:00
ttipo 042d724b3a refactor: AI 타임아웃을 백엔드·프론트 동일 값 + 환경변수(AI_TIMEOUT_MS)로 통일
기존: 백엔드 30초(하드코딩 const)·프론트 60초(하드코딩)로 불일치
변경: 루트 .env 의 AI_TIMEOUT_MS(기본 60000)를 단일 소스로
- 백엔드 ai.service: REQUEST_TIMEOUT_MS const → ConfigService(AI_TIMEOUT_MS) 주입, 기본 60s
- 프론트 useAi: import.meta.env.VITE_AI_TIMEOUT_MS 사용, 기본 60s
- docker-compose: 백엔드 environment 에 AI_TIMEOUT_MS, 프론트 build arg 에 VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
- 프론트 Dockerfile ARG/ENV, env.d.ts 타입, 루트 .env.example 추가
미설정/비정상 값이면 양쪽 모두 기본 60초로 폴백.

검증: 백엔드 build/lint 0, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:01:51 +09:00
ttipo ff33fb2e6c fix: AI 추천 프론트 타임아웃(10초) → 60초, 응답 잘림 없이 수신
근본 원인: useApi 기본 axios timeout 이 10초인데 suggestChecklist 가 이를
덮어쓰지 않아, AI 생성이 10초를 넘으면(항목 제한 해제 후 ~10.5초로 일관 초과)
백엔드가 200 으로 완료해도 프론트가 먼저 끊어 '추천을 가져오지 못했습니다' 발생.
(백엔드 에러 로그가 없던 이유 — 백엔드는 성공했음)
suggestChecklist 호출에 timeout 60000(백엔드 abort 30초보다 길게) 지정.

런타임 측정: 백엔드 suggest 응답 10.48초/HTTP 200 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:54:40 +09:00
ttipo 0d47e989ba feat: AI 추천 카테고리당 항목 수 제한 해제
- 프롬프트의 '3~6개씩' 제한 제거 → 카테고리별로 필요한 만큼 제안
- parseSuggestions 의 카테고리당 15개 슬라이스 캡 제거(응답 길이는 max_tokens 가 자연 제한)
- 항목이 늘어 잘리지 않도록 suggest max_tokens 3072 → 4096

런타임 재현: 동일 입력에서 필수8·관리6·보안8·부가6(총 28개)로 더 풍부하게 반환.
백엔드 build/lint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:32:35 +09:00
ttipo f8ae813c05 fix: AI 추천 토큰 한도 부족으로 응답 잘림 → 추천 실패 수정
4개 카테고리(필수/관리/보안/부가) 구조화 JSON 응답이 max_tokens=1024 를
넘으면 중간에 잘려 JSON 파싱이 실패하고 '추천을 가져오지 못했습니다' 로 떴음.
- callClaude 에 maxTokens 파라미터 추가, suggestChecklist 는 3072 사용
- parseSuggestions 파싱 실패/빈 결과 시 원본 일부를 warn 로그로 남겨 진단 가능

런타임 재현: 동일 입력으로 필수5·관리5·보안5·부가4(총 19개) 정상 반환 확인.
백엔드 build/lint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:27:31 +09:00
ttipo 3379c54033 feat: AI 추천 모달 디자인 개편 + 4개 고정 카테고리(필수/관리/보안/부가)
백엔드:
- SUGGEST_SYSTEM_PROMPT 를 정확히 4개 카테고리(필수/관리/보안/부가 기능)로 고정
  (parseSuggestions 가 빈 카테고리는 자동 제외)

프론트(시안 'AI 추천 모달.html' 반영):
- 그라데이션 헤더(배지 아이콘 + '자동 생성' 뱃지 + 분석 요약 서브타이틀)
- 카테고리별 색상 아이콘 dot + 개수 pill + 전체 추가/해제 토글
- 항목을 체크 토글로 '선택' 후 푸터의 'N개 업무에 추가'로 일괄 반영(기존 즉시추가 → 선택→커밋 모델)
- 이미 체크리스트에 있는 항목은 잠금(added) 표시
- 푸터: 선택 개수 + 취소/추가 버튼(.ai-btn — 페이지 .btn 과 분리)

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:19:03 +09:00
ttipo bd21557e39 fix: AI 추천 검증을 제목·내용 둘 다로 확장
AI 추천은 제목과 내용을 함께 분석하므로, 둘 중 하나라도 비면 해당 필드에
인라인 에러(빨강 테두리+메시지)를 표시하고 요청을 중단하도록 수정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:55:20 +09:00
ttipo 867f47ba8f feat: AI 추천 버튼도 비활성화 제거 + 인라인 검증으로 통일
기존: 제목 미입력 시 'AI 추천' 버튼 비활성화(canRequestAi)
변경: 제출 버튼과 동일 로직 — 버튼 상시 활성(분석 중 in-flight 만 disabled),
클릭 시 제목이 비어 있으면 titleError 로 인라인 안내(빨강 테두리+메시지) 후 중단.
사용 안 하게 된 canRequestAi computed 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:46:07 +09:00
ttipo 116560b549 style: 업무 제목 인풋도 에러 시 빨간 테두리 표시
borderless 제목 인풋에 has-error 시 빨간 테두리(+가로 패딩) 부여 —
에디터·담당자 필드와 일관. box-sizing:border-box 라 overflow 없음,
세로 패딩 유지로 레이아웃 점프 최소화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:41:10 +09:00
ttipo 612d6c7de8 fix: 업무 내용 필수 검증 추가 + 제목 에러 표기 정리
- 업무 내용(*)이 표기상 필수인데 검증이 없던 문제 → 필수로 강제
  · 프론트: 인라인 검증(에디터 빨강 테두리 + '업무 내용을 입력해 주세요.'),
    입력 시 즉시 해제
  · 백엔드: CreateTaskDto.content 를 @ArrayNotEmpty 필수로(데이터 계약 일치)
- 제목 에러 표기 정리: 1.5rem 큰 placeholder 가 빨갛게 변하며 아래 메시지와
  이중으로 강조되던 것을 제거 → 작은 인라인 메시지만 표시(차분하게)

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:36:58 +09:00
ttipo 9f66c6d0ec feat: 업무 지시 필드별 인라인 검증 + 제출 버튼 상시 활성화
기존: 제목·담당자 미입력 시 '지시 보내기' 버튼이 비활성화(이유 안내 없음)
변경:
- 버튼 비활성화 해제(제출 중 중복 클릭 방지용 submitting 만 유지)
- 제출 시 validateForm 으로 검증 → 미입력 필드에 인라인 에러 표시
  · 제목: 빨강 placeholder + '업무 제목을 입력해 주세요.'
  · 담당자: combo 빨강 테두리 + '담당자를 1명 이상 지정해 주세요.'
- 해당 필드를 채우면 watch 로 에러 즉시 해제

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:29:36 +09:00
ttipo ae6af518dd style: 업무 생성 AI 추천을 하단 인라인 패널 → 모달로 변경
기존: 체크리스트 아래에 인라인으로 펼쳐지던 AI 추천 패널
변경: Teleport 기반 중앙 모달(반투명 배경, 헤더 고정 + 본문 스크롤).
배경 클릭/× 로 닫기(분석 중에는 닫기 비활성). 추천 항목 추가 동작은 동일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:50:41 +09:00
ttipo 544d49aa97 style: 업무 생성 첨부파일 선택을 강조 없는 일반 버튼으로 변경
기존: 점선 테두리 + 강조색(accent) 굵은 '클릭하여 파일 선택' 드롭존(강조 스타일)
변경: 클립 아이콘 + '파일 선택' 의 차분한 보조 버튼(.attach-btn). 미사용
.dropzone.is-disabled 잔여 스타일도 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:46:35 +09:00
ttipo 73eb53625c fix: 멤버 초대/역할변경/제거 알림에 actorName 누락 → '누군가' 표기 수정
ProjectMemberService 의 notify payload 에 actorName 이 빠져 있어, 프론트
notification.store 가 actorName 부재 시 '누군가'로 폴백하던 문제.
세 member.* 알림(invited/role_changed/removed) payload 에 actorName 추가
(actingUserId 로 사용자명 조회하는 actorName 헬퍼 신설). 활동 피드는 actor
관계로 별도 해결되므로 영향 없음.

런타임 검증: 초대 시 수신자 알림 payload.actorName = 초대자 이름으로 채워짐.
백엔드 build/lint 0·8 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:31:03 +09:00
ttipo 32a87d8f62 chore: 잔존 '저장소' 주석·문구를 '프로젝트'로 정리
저장소 기능 제거 후 남은 '저장소' 표현을 프로젝트로 통일:
- 백엔드: activity/notification/task 주석·ApiResponse 문구, AI 시스템 프롬프트
  (SUGGEST_SYSTEM_PROMPT 의 저장소→프로젝트, 무의미해진 'slug=영문이름' 지시 제거),
  CONTEXT_REPO_LIMIT→CONTEXT_PROJECT_LIMIT(프로젝트 컨텍스트 한도)
- 프론트: 페이지/스토어 주석 및 TaskCreate/TaskDetail 의 사용자 노출 문구('저장소'→'프로젝트')
- 단, auth OAuth state 'store' 의미의 '저장소'(cookie-state/strategies)는 정당하여 유지

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:57:06 +09:00
ttipo be0a1bc9d1 refactor: 'Repo' 접두 타입·헬퍼 식별자를 'Project' 로 일괄 rename
저장소 기능 제거 후 남은 레거시 'Repo' 명칭을 프로젝트 의미에 맞게 정리(동작 변화 없음):
- 타입: ApiRepoMember→ApiProjectMember, ApiRepoTask→ApiProjectTask,
  ApiRepoTaskListResult→ApiProjectTaskListResult, RepoTask(View)→ProjectTask(View),
  RepoMember(Response)→ProjectMember(Response), RepoActivityItem/Day→ProjectActivityItem/Day,
  RepoTaskResponse/ListResult→ProjectTaskResponse/ListResult (백엔드 task.service 포함)
- 헬퍼: toRepoTaskView→toProjectTaskView, toRepoActivityItem→toProjectActivityItem,
  loadRepo→loadProject, filteredRepos→filteredProjects, removeRepo→removeProject
- 백엔드 spec mock repoRepo→projectRepo, 잔존 RepoDetailPage/저장소(Repo) 주석 정리

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0 (식별자 충돌 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:52:23 +09:00
ttipo ef1757181d refactor: 저장소(Repo)·Gitea 연동 기능 완전 제거 — 프로젝트 단독 구조
복잡도를 줄이기 위해 저장소 연동 기능을 전부 제거하고 프로젝트를 단독 단위로 운영한다.

백엔드:
- modules/repo, modules/gitea 모듈 전체 삭제(엔티티/서비스/컨트롤러/DTO/역방향 동기화)
- app.module 에서 RepoModule 제거
- ProjectService: repoCount 집계·repoRepo·"미분류" 시스템 프로젝트(ensureUnassigned/
  onApplicationBootstrap)·isSystem 게이트 전부 제거. ProjectResponse 에서 repoCount/isSystem 제거
- Project 엔티티 is_system 컬럼 제거(dev synchronize 가 DROP)
- ProjectModule forFeature 에서 Repo 제거
- Activity 타입 repo.linked/unlinked 제거
- env example 에서 Gitea/REPO_IMPORT 섹션 제거

프론트:
- ProjectReposPage, useRepo 삭제. ProjectTabs 의 '저장소' 탭·repoCount prop 제거
- 4개 페이지의 :repo-count 제거, 라우트 /projects/:id/repos 제거
- types/project·project.store·mock 에서 repoCount/isSystem·Repo 뷰 제거
- ProjectListPage 저장소수/시스템 배지·gitea CSS 제거, activity 의 repo.* 케이스 제거
- types/repo 는 공용 ApiMember 만 유지
- 잔여 '저장소'/Gitea 사용자 문구를 프로젝트로 정리

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:44:15 +09:00
ttipo 861c8efa6b chore: 테스트 편의를 위해 rate limit 을 분당 1000회로 완화
세 겹의 제한을 모두 분당 1000회로 상향(테스트 중 429 회피):
- main.ts express-rate-limit: 15분 150회 → 1분 1000회
- 전역 ThrottlerModule: 분당 100회 → 1000회
- 라우트별 @Throttle(login/signup/resend/forgot/reset/ai): 3~20회 → 1000회

각 라우트 주석에 운영 권장값을 남겨 추후 복원/ env 화 용이.
운영 배포 전 적정값으로 되돌릴 것.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:27:41 +09:00
ttipo d0599787f7 style: 저장소 탭 breadcrumb 을 다른 탭과 동일하게 통일
ProjectReposPage 가 .crumb .lnk(accent색)·.sep(여백) 를 로컬로 덮어써 다른 탭과
달라 보이던 것을 제거 → 전역 relay.css 의 crumb 스타일(text-2 링크·opacity 구분자)
을 따르도록 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:19:42 +09:00
ttipo 647336b14d style: 저장소 탭 가이드 문구를 멤버 탭과 동일한 형식(tab-note)으로 변경
기존 .lede 단락 → 멤버 탭의 안내(정보 아이콘 + 작은 회색 텍스트) 형식으로 통일

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:12:15 +09:00
ttipo 3667f48897 fix: 저장소 연동 추가 폼이 열려 있을 때 기존 저장소 목록 숨김
연동 추가 화면(showForm)에서는 하단의 연동 저장소 목록 섹션을 표시하지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:06:43 +09:00
ttipo 4d943e8d0d refactor: 저장소 공개 범위(visibility) UI 제거 + 기본 공개(public) 생성
사용자 입력/표시에서 저장소 공개 범위를 제거하고 새 저장소는 공개로 생성한다.
(Gitea 역방향 동기화에 쓰이는 Repo 엔티티 visibility 컬럼은 유지)

백엔드:
- CreateRepoDto/UpdateRepoDto 에서 visibility 제거
- RepoService.create 가 visibility 를 'public' 으로 고정
- update 의 visibility 변경·Gitea private 푸시 로직 제거(공개범위는 Gitea 기준)
- RepoResponse/toResponse 에서 visibility 제거

프론트:
- types/repo ApiRepo/Create/Update 페이로드에서 visibility 제거
- ProjectReposPage 연동 폼의 공개 범위 입력 + 목록 배지 + 관련 CSS 제거
- mock Visibility 타입·orphan Repo 뷰의 visibility 제거

런타임 검증: 저장소 응답에 visibility 없음, repos.visibility 컬럼 유지(Gitea 동기화).
백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:51:21 +09:00
ttipo 135cc0759f refactor: 저장소 연동 폼 — 표시 제목 최상단 + 템플릿 셀렉트 복원
- 필드 순서 변경: 표시 제목(name)을 최상단으로(기존 slug 가 최상단)
- 템플릿을 체크박스 → 기존 셀렉트 구조로 복원
  (없음[빈 저장소] / {템플릿slug} 템플릿, 가용 시에만 옵션 노출)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:36:48 +09:00
ttipo 58c0b9510d refactor: 프로젝트에서 공개 범위(visibility) 제거
불필요한 프로젝트 공개 범위 데이터를 백엔드·프론트에서 일괄 제거(저장소 Repo 의 visibility 는 Gitea 연동용이라 유지).

백엔드:
- Project 엔티티 visibility 컬럼·ProjectVisibility 타입 제거(dev synchronize 가 컬럼 DROP)
- create/update DTO, ProjectResponse, create/update/ensureUnassigned/toResponse 에서 제거
- ai.service 프로젝트 컨텍스트 문자열에서 공개범위 표기 제거
- activity 타입 project.visibility_changed 제거

프론트:
- types/project·project.store·mock Project 에서 visibility 제거
- ProjectCreatePage·ProjectSettingsPage 공개 범위 입력 폼+CSS 제거
- ProjectListPage 공개여부 세그먼트 필터+배지 제거, ProjectHeader 배지 제거
- activity 타입/스토어 project.visibility_changed 케이스 제거

런타임 검증: DB projects.visibility 컬럼 DROP, 생성/목록 응답에 visibility 없음, CRUD 정상.
백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:39:48 +09:00
ttipo 9fdcecace8 feat: 이메일 인증 링크를 결과 페이지(창 닫기)로 분리 + 가입 대기 화면에 로그인 버튼
- 인증 링크(verify-email)의 리다이렉트 대상을 /login?verified → /email-verified?ok=1|0 으로 변경
- 신규 EmailVerifiedPage(/email-verified): 인증 완료/실패 표시 후 창 자동 닫기 시도.
  브라우저가 사용자가 연 탭의 close 를 막을 수 있어 안내 문구 + 수동 로그인/가입 링크 병행
- SignupPage 인증 대기 화면에 '인증을 완료했어요 · 로그인하러 가기' 기본 버튼 추가
  (다른 탭에서 인증을 마친 뒤 기존 화면에서 로그인 진입)

런타임 검증: 유효 토큰→/email-verified?ok=1·무효→ok=0, 인증 후 로그인 200, SPA 라우트 제공.
백엔드 build/lint 0, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:22:23 +09:00
ttipo 72b05f1b69 feat: 비밀번호 찾기 화면을 로그인 2단 레이아웃으로 변경
- 시안(비밀번호 찾기.html) 적용 — 좌측 브랜드 패널 + 우측 폼(로그인 화면과 동일 구조)
- 1단계 이메일 입력(뒤로가기 링크·재설정 링크 보내기) → 2단계 전송 완료
  (메일 아이콘·수신 이메일 칩·스팸함 안내 박스·다시 보내기·로그인 복귀)
- 다시 보내기 시 동일 이메일 재요청 + '전송됨 ✓' 일시 피드백
- 시안의 '30분 유효'는 실제 백엔드 토큰 TTL(1시간)에 맞춰 표기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:15:22 +09:00
ttipo 52b70363d9 feat: 비밀번호 재설정 플로우 (이메일 링크 기반)
백엔드:
- User 엔티티에 passwordResetTokenHash/passwordResetExpiresAt(select:false) 추가
- UserService: setPasswordResetToken/findByPasswordResetTokenHash/updatePassword
- MailService: sendPasswordResetEmail (dev=로그 출력, prod=수신자만)
- AuthService: requestPasswordReset(존재 비노출·로컬계정만·1시간 토큰),
  resetPassword(토큰 해시 검증→새 비번 저장→전 세션 폐기)
- POST /auth/forgot-password(3/분)·/auth/reset-password(5/분), 가입과 동일 비번 규칙
- OAuth 콜백의 잔여 /repos 리다이렉트를 /projects 로 교정(리팩터 누락분)

프론트:
- useAuth: forgotPassword/resetPassword
- ForgotPasswordPage(/forgot-password), ResetPasswordPage(/reset-password, 토큰=쿼리)
- LoginPage '비밀번호 찾기' 링크 연결 + 재설정 완료(?reset=1) 배너

런타임 검증: 재설정→새 비번 로그인 200·구 비번 거부·토큰 재사용 거부·약한 비번 VAL_001·
없는 이메일 동일응답(비노출). 백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:50:34 +09:00
ttipo cb6aa07ebc docs: 프로젝트 우선 리팩터 런타임 점검 완료 기록 (Phase 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:12:19 +09:00
ttipo 9aa22f092d fix: 백엔드·프론트 헬스체크 오탐 수정 (런타임 점검 발견)
- 백엔드: 앱이 전역 프리픽스 /api 하위라 루트(/) 프로브가 404 → /api/health 프로브로 교정
- 공통: localhost 가 IPv6(::1)로 풀려 IPv4 전용 바인딩에 연결 실패하던 것을 127.0.0.1 고정
- 두 컨테이너가 정상 동작에도 unhealthy 로 표시되던 선존 버그(리팩터 무관)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:11:40 +09:00
ttipo 8a2e34d617 feat: 프로젝트 저장소 연동 UI + 화면 문구 정리 (Phase 2b)
- useRepo 복구(프로젝트 종속 /projects/:projectId/repos): list/meta/checkName/create/remove
- types/repo ApiRepo 를 새 RepoResponse 와 정합(projectId 추가, members/counts 제거)
- ProjectTabs 에 '저장소' 탭(repoCount) 추가, 4개 페이지 repo-count 전달
- ProjectReposPage 신설(/projects/:id/repos): 연동 저장소 목록 + 연동(생성) 폼
  (slug 가용성 디바운스 체크·템플릿 옵션) + Gitea 열기 + 연동 해제(admin)
- 화면 잔여 '저장소' 문구를 '프로젝트'로 정리(crumb/설정/멤버 모달/주석/not-found)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:52:23 +09:00
ttipo e028449312 feat: 프로젝트 카운트·멤버 활동/알림 배선 (Phase 1 마무리)
- ProjectService: 프로젝트별 연동 저장소 수·업무 완료/전체 수 집계
  (Repo/Task 엔티티 forFeature 등록, 그룹 쿼리로 N+1 회피, findOne 멤버 로딩)
- ProjectMemberService: 멤버 목록 taskCount(담당 업무 수) 집계
- ProjectMemberService: 초대/역할변경/제거 시 활동 적재 + 대상자 알림 발송
  (ActivityModule/NotificationModule 주입, member.* 페이로드 projectId/projectName/targetName)
- TaskService: 다른 모듈로 이관되어 미사용된 countsByProject/assigneeCounts 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:43:52 +09:00
ttipo 7cd6676ef9 feat: 프론트엔드 프로젝트 우선 구조 재구성 (Phase 2-a)
- 데이터 계층 신설: types/project·useProject·project.store (프로젝트 CRUD·페이지네이션)
- useTask/useMember/useActivity 경로를 /projects/:projectId/... 로 전환
- task·my-task·activity 타입/스토어 repoId/repoName → projectId/projectName
- 알림 store 라우팅·문구 프로젝트화, 활동 타입 repo.linked/unlinked·project.* 반영
- 라우팅 /projects/... 전면 교체, / → /projects 리다이렉트
- 페이지 rename Repo*→Project* (List/Create/Detail/Settings/Members/Activity)
- ProjectCreatePage: Gitea 저장소 폼 → 단순 프로젝트 생성 폼으로 재작성
- RepoHeader→ProjectHeader, RepoTabs→ProjectTabs, AppShell 네비 프로젝트화
- 죽은 파일 삭제: repo.store·useRepo·GiteaRepoBar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:37:51 +09:00
ttipo 6ba148314c refactor: 백엔드를 프로젝트 우선 구조로 이전 (업무·멤버·활동·저장소)
저장소=프로젝트 1:1 구조를 프로젝트 우선 + 프로젝트 1:N 저장소로 이전(2단계).

- Activity: repo FK → project FK, listForProject, /projects/:projectId/activity
- Repo: project FK 추가, RepoMember 폐지(멤버십은 프로젝트), /projects/:projectId/repos 로 이동
- Task: project 종속(seq=프로젝트 내 순번 @Unique[project,seq]), 권한=ProjectMember, /projects/:projectId/tasks
- RepoService: 프로젝트 종속(멤버·업무카운트·캐시 제거), 권한은 ProjectService 위임
- RepoSyncService: Gitea import → "미분류" 시스템 프로젝트로 연결, import-admin 로직 제거
- AiService: 컨텍스트·추천을 프로젝트 기준으로
- 삭제: repo-member 엔티티/멤버 서비스·컨트롤러/repo-cache
- 알림 payload repoId/repoName → projectId/projectName

검증: build/lint 0, 14 tests. 잔여(후속): ProjectResponse 카운트 집계·멤버 활동/알림 배선.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:03:09 +09:00
ttipo 2f66cdda8a feat: 프로젝트(Project) 모듈 기반 추가 — 프로젝트 우선 구조 1단계
저장소=프로젝트 1:1 구조를 프로젝트 우선 + 프로젝트 1:N 저장소로 전환하는 리팩터의 기반.
기존 코드는 건드리지 않고 추가만 함(기존 저장소 기반 동작 유지).

- Project / ProjectMember 엔티티 (isSystem '미분류' 버킷, admin/member/owner)
- ProjectService: CRUD + 권한 헬퍼(assertProjectMember/Admin) + 부팅 시 '미분류' 시스템 프로젝트 보장
- ProjectMemberService: 초대/역할/제거 (활동·알림 배선은 Activity 이관 단계 TODO)
- /projects, /projects/:id/members 라우트, app.module 등록
- docs/project-refactor-plan.md (리팩터 SSOT)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:41:58 +09:00
ttipo 9bcbc1564f revert: AI 코드 검토 기능 제거 (85ae82f 되돌림)
통짜 코드 전송 방식이 토큰 비효율·부정확해, 검색 기반(grep으로 좁힌 뒤 관련
스니펫만 판정) 방식으로 재설계하기 위해 일괄 제거. 추후 개선안으로 재작업 예정.

This reverts commit 85ae82f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:56:43 +09:00
ttipo e72e6aee83 chore: 디자인 시안 HTML 파일 제거
Vue 이식이 완료된 초기 디자인 시안(Relay 화면 전체/*.html) 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:20:13 +09:00
ttipo 85ae82f3ce feat: AI 코드 검토 — 연결된 Gitea 코드로 체크리스트 구현 여부 판정
- ChecklistItem.reviewVerdict('done'|'missing'|null) 컬럼 추가 + 응답/프론트 타입 전파
- GiteaService.fetchRepoCode: 기본 브랜치 텍스트 파일 스냅샷(의존성/빌드/바이너리 제외, ~120KB 예산)
- AiService.reviewChecklist: 코드+체크리스트를 Claude 에 보내 항목별 done/missing 판정 후 체크리스트에 반영
- POST /ai/review-checklist(throttle 6/분, Claude 타임아웃 60s), Gitea 미연동/코드없음/항목없음 400
- TaskService.getTaskForReview/applyReviewVerdicts
- 프론트: TaskDetailPage 승인 요청 카드(prog/changes)에 "AI 검토" 버튼 → 체크박스 ✓(초록)/✗(빨강), 요약 배너

베스트에포트 휴리스틱(코드 일부만 검토), Gitea 연동 저장소 한정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:19:01 +09:00
ttipo 57a88ff741 feat: 업무 생성 화면 첨부파일 활성화 (제출 시 업로드)
업로드 API 가 기존 taskId 종속이라 막혀 있던 생성 화면 첨부를 2단계 제출로 해결.
선택한 파일을 메모리에 보유하다 업무 생성/수정 후 그 taskId 로 순차 업로드(베스트에포트,
실패 개수 안내). 수정 모드는 기존 첨부 표시·즉시 삭제 + 새 파일 업로드. 25MB 사전 필터.
백엔드 변경 없음(기존 업로드 엔드포인트 재사용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:18:35 +09:00
ttipo df97719e0f fix: 첨부 다운로드 링크를 백엔드 절대 URL 로 변환
백엔드가 내려준 루트 상대경로(/api/.../download)를 <a href> 가 프론트 오리진으로
해석해 404 페이지가 뜨던 문제 수정. useApi 에 toAbsoluteApiUrl 추가, 첨부 매핑에 적용.
(dev 프론트 5273 / 백엔드 3100 오리진 분리 환경)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:18:14 +09:00
ttipo 406e13da1c fix: 업무 상세 활동 탭 내용·시간 간격 축소
활동 항목의 내용과 아래 시간 텍스트 사이 간격(.act-time margin-top)을 0.375rem→0.1875rem 으로 줄임.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:17:44 +09:00
ttipo 0976a5057b feat: 업무 작성 AI 할 일 추천
- POST /ai/suggest-checklist — 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내
  카테고리별(필수/추가/보안) 할 일 항목을 JSON 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502)
- AiSuggestController + SuggestChecklistDto + AiService.suggestChecklist/parseSuggestions
- 프론트: TaskCreatePage 액션바 "취소 / AI 추천 / 지시 보내기" + 체크리스트 추천 패널
  (카테고리별 항목 클릭 추가·전체 추가·중복 스킵), useAi.suggestChecklist(silent)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:34 +09:00
ttipo 51b608efeb feat: AI 채팅 다듬기 — 저장소 컨텍스트·표시제목·마크다운 렌더·말투
- 컨텍스트에 저장소 목록 추가(RepoService.findAll), slug 빼고 표시 제목만 노출
- AI 응답 마크다운 렌더링: shared/utils/markdown.ts (escapeHtml 후 신뢰 태그만 조립, 의존성 0, XSS 안전 — 굵게/목록/코드/제목/링크)
- 시스템 프롬프트: 간결·이모지/사과 남발 금지·과도한 존칭 지양·마크다운 사용·저장소는 표시제목으로 안내

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:03:59 +09:00
ttipo b8e8095170 fix: 내 업무·AI 컨텍스트 orderBy 컬럼명 버그(databaseName) 수정
listAssigned/listIssued 가 to-many 조인 + 페이지네이션 정렬에서 DB 컬럼명
'task.due_date' 를 사용해, TypeORM distinct 경로가 컬럼 메타데이터를 못 찾아
"Cannot read properties of undefined (reading 'databaseName')" 로 크래시.
정렬 키를 엔티티 속성명 'task.dueDate' 로 변경 — 내 업무 화면 + AI 컨텍스트 동시 복구.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:59:45 +09:00
ttipo 2f7f28dd7b feat: AI 에이전트 주제 한정(가드레일) + 사용자 업무 컨텍스트 주입
- 시스템 프롬프트를 업무/Relay 한정으로 재작성 — 무관한 주제는 정중히 거절
- 매 요청 시 사용자 컨텍스트 주입: 오늘 날짜(KST) + 담당 업무 최대 30건(제목/상태/마감/체크리스트) + 지시 업무 수
- TaskService.listAssigned/listIssued 활용(AiModule→TaskModule 단방향), 조회 실패해도 채팅 진행
- callClaude(messages, system)로 시그니처 변경(매 메시지 최신 컨텍스트)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:03:45 +09:00
ttipo e432832c92 feat: AI 대화 DB 영속 + 다중 대화/기록 목록
- AiConversation·AiMessage 엔티티(user/conversation CASCADE), 제목 자동 생성
- 대화 스코프 API: GET/POST /ai/conversations, GET/:id, POST/:id/messages, DELETE/:id (전부 본인 소유 스코프 → IDOR 차단)
- 클라는 새 메시지 1건만 전송, 서버가 DB 이력으로 Claude 호출(무상태 /ai/chat 폐기)
- Claude 실패 시 사용자 메시지·빈 대화 롤백(고아 데이터 방지)
- 프론트: AgentChatPanel 채팅↔지난 대화 목록 뷰(열기/삭제), ai.store 재작성(목록·현재 대화)
- 다른 기기·재로그인·새로고침에도 기록 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:58:40 +09:00
ttipo 5da3ec8818 feat: AI 채팅 패널 (Claude API)
- 백엔드 modules/ai: POST /ai/chat — Anthropic Messages API 프록시(키 서버 env 한정, 30s 타임아웃, throttle 20/분, 미설정 503/외부오류 502)
- ChatDto 검증(role enum, content 8000자, 메시지 50개 상한), 모델 CLAUDE_MODEL(기본 claude-sonnet-4-6)
- 프론트 AgentChatPanel: 우측 슬라이드 채팅(말풍선/타이핑/에러/새 대화), plain text 렌더로 XSS 안전
- AppShell 헤더에 AI 버튼 상시 노출 — 모든 화면에서 열림
- 대화 이력은 stores/ai.store(싱글톤)에 보관 → 화면 이동에도 유지, '새 대화'로만 초기화
- TaskDetailPage 의 기존 목업 에이전트 패널 전부 제거(보안 L1 해소)
- env.example 에 CLAUDE_MODEL 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:04:48 +09:00
ttipo db6a80a6f4 feat: refresh 토큰 완전 회전 + 재사용 탐지
- RefreshSession(패밀리) 테이블 도입 — 한 로그인=1행(currentJti/prevJti/expiresAt)
- /auth/refresh 회전형으로 교체: 현재 jti→새 jti 회전, 옛 토큰 재제출 시 재사용 탐지→패밀리 폐기(401)
- 동시 갱신 오탐 방지: 직전 jti 15초 유예 + 프론트 useApi single-flight refresh
- 로그아웃=해당 세션만 폐기, revokeAllSessions(전 기기) 준비
- tokenVersion·jwt-refresh.strategy·JwtRefreshGuard 제거(refresh는 가드 없이 서비스 직접 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:57:01 +09:00
ttipo df268d3b5e chore: env.example 파일 현행화 (development 기준)
- backend/.env.development.example: CLAUDE_API_KEY 추가(dev 실제와 키 셋 일치)
- backend/.env.production.example: 소셜 OAuth·APP_API_URL/FRONTEND_URL·CLAUDE_API_KEY 추가(운영 placeholder)
- frontend/.env.*.example: 고유 키 없음 + VITE_API_BASE_URL docker 주입 설명 헤더 추가

실제 시크릿/인프라 값은 미포함(전부 placeholder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:01:59 +09:00
ttipo b9e2b0e67d fix: 보안 LOW 하드닝 (L2~L6)
- DTO 길이 바운드: 업무 content/assigneeIds/checklist ArrayMaxSize·요소 MaxLength, UpdateRepoDto.description MaxLength(300)
- 비밀번호 정책: 8~72자 + 영문·숫자 포함 강제, bcrypt rounds 10→12
- 그랜드페더링을 컷오프(2026-06-20) 이전 생성 계정으로 한정
- multer 업로드 오류를 400(VAL_001)로 매핑
- 운영 환경에서 CORS localhost:3000·Swagger /api-docs 노출 제외

(L1 에이전트 터미널 v-html 은 에이전트 작업 시 함께 처리 — 보류)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:46:08 +09:00
ttipo 8efcacb6bb fix: 보안 점검 보완 — 소셜 로그인·세션·업로드 강화 (HIGH+MEDIUM)
- OAuth 제공자 이메일 검증 미확인 차단(email_verified/is_email_verified 미검증 시 거부)
- OAuth state CSRF 방지: 세션 없는 CookieStateStore(httpOnly 1회용 state 쿠키) 두 전략에 주입
- refresh 토큰 폐기: User.tokenVersion + refresh payload ver 검증, 로그아웃 시 일괄 무효화
- 파일 업로드 MIME 화이트리스트(html/svg 차단) + 다운로드 attachment·nosniff(저장형 XSS 차단)
- 인증 라우트 throttle(login 10·signup 5·resend 3 /분)
- 첨부 삭제 업로더/admin 한정
- 인증 토큰 로그 운영 환경 게이트(원문 토큰 미기록)
- docs/security-review.md(감사 보고서) 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:52:36 +09:00
ttipo 8766a4f5b6 feat: 소셜 로그인(Google/Kakao) 및 회원가입 이메일 인증 추가
- User 엔티티 확장: passwordHash nullable, provider, emailVerified, 인증 토큰 해시/만료(select:false)
- 회원가입: 자동 로그인 폐지 → 미인증 계정 생성 + 인증 메일 발송(MailService 플레이스홀더, 백엔드 로그로 링크 출력)
- 이메일 인증: GET /auth/verify-email(해시 매칭+만료 검사) → 프론트로 리다이렉트, 재발송 엔드포인트
- 로그인 게이트: 미인증 로컬 403 차단, 소셜 전용 계정은 소셜 로그인 안내
- 소셜 OAuth: passport google/kakao 전략(키 미설정 시 자동 비활성), 이메일로 계정 매칭/생성
- 기존 로컬 계정 그랜드페더링(부팅 시 인증 완료 보정)
- 프론트: 가입 후 인증 메일 안내 화면, 로그인 소셜 버튼 실연동 + 인증 결과 배너
- env(APP_API_URL/FRONTEND_URL/GOOGLE_*/KAKAO_*) 문서화, 문서·계약 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:04:30 +09:00
ttipo 7a2c70a260 feat: 저장소 생성 이름 실시간 가용성 체크 + 소유자 아바타 제거
- GET /repos/name-available?slug= 추가(:repoId 앞 선언, Relay slug 중복만 검사)
- 생성 폼: 형식 즉시 검사 + 350ms 디바운스 서버 조회로 상태별(빈칸/형식위반/확인중/사용가능/중복) 색·아이콘 표기
- 항상 떠 있던 정적 "사용 가능한 이름입니다" 표시 제거
- 조직 이니셜 소유자 아바타 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:03:59 +09:00
157 changed files with 9959 additions and 12344 deletions
+20 -5
View File
@@ -1,5 +1,11 @@
# ==========================================
# 프로젝트 전역 설정
# 루트 .env (development) — 모든 설정의 단일 진실 공급원(SSOT)
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# ==========================================
# 프로젝트 전역
# ==========================================
PROJECT_NAME=comrelay
APP_ENV=development
@@ -8,7 +14,7 @@ FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# 포트
# ==========================================
FRONTEND_PORT=5273
BACKEND_PORT=3100
@@ -17,7 +23,7 @@ DB_PORT=5532
REDIS_PORT=6479
# ==========================================
# Database 설정
# Database (PostgreSQL)
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=dev_ttipo
@@ -26,14 +32,23 @@ DB_NAME=comrelay_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# Redis
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로
# ==========================================
# BACKEND_API_URL: 프론트 빌드에 주입(VITE_API_BASE_URL)
# CORS_ORIGIN : 백엔드가 허용할 프론트 오리진
BACKEND_API_URL=http://localhost:3100/api
CORS_ORIGIN=http://localhost:5273
# ==========================================
# AI (백엔드·프론트 공통)
# ==========================================
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
+20 -5
View File
@@ -1,5 +1,11 @@
# ==========================================
# 프로젝트 전역 설정
# 루트 .env (production) — 모든 설정의 단일 진실 공급원(SSOT)
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# ==========================================
# 프로젝트 전역
# ==========================================
PROJECT_NAME=comrelay
APP_ENV=production
@@ -8,7 +14,7 @@ FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# 포트
# ==========================================
FRONTEND_PORT=5173
BACKEND_PORT=3000
@@ -17,7 +23,7 @@ DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# Database 설정
# Database (PostgreSQL)
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=prd_ttipo
@@ -26,14 +32,23 @@ DB_NAME=comrelay_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# Redis
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로
# ==========================================
# BACKEND_API_URL: 프론트 빌드에 주입(VITE_API_BASE_URL) — 운영 도메인으로 교체
# CORS_ORIGIN : 백엔드가 허용할 프론트 오리진 — 운영 도메인으로 교체
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
# ==========================================
# AI (백엔드·프론트 공통)
# ==========================================
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
-1
View File
@@ -99,7 +99,6 @@
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml``environment` / `args` 로 전달한다.
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
- `docker-compose.yml``env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **단독 실행(non-Docker)** 시에는 주입이 없으므로, 각 서비스 `.env.{APP_ENV}` 의 주석 처리된 예시 값을 활성화해 사용한다.
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore``!*.example` 재포함 규칙)
+5 -19
View File
@@ -15,8 +15,8 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
## 사전 요구사항
- Docker / Docker Compose (권장 구동 방식)
- 단독 실행 시: Node.js `^20.19 || >=22.12`, Flutter SDK `^3.11`
- Docker / Docker Compose
- 모바일 앱(선택): Flutter SDK `^3.11`
## 빠른 시작 (Docker)
@@ -26,7 +26,6 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
cp .env.development.example .env.development # ← 여기에만 값 입력
cp backend/.env.development.example backend/.env.development # (존재만 필요, 내용 최소)
cp frontend/.env.development.example frontend/.env.development # (단독 실행 시에만 사용)
# 2) 개발 환경 기동 (코드만 수정 시)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
@@ -43,21 +42,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
전체 실행/배포 명령어는 [`command.md`](./command.md) 참조.
## 단독 실행 (Docker 미사용)
```bash
# 백엔드
cd backend && npm install && npm run start:dev
# 프론트엔드
cd frontend && npm install && npm run dev
# 모바일
cd flutter && cp .env.example .env && flutter pub get && flutter run
```
> 단독 실행 시 백엔드 `.env.development` 의 `DB_HOST`/`REDIS_HOST` 를 `localhost` 로 두고,
> PostgreSQL·Redis 를 별도로 띄워야 합니다.
> 모바일 앱은 docker-compose 대상이 아니므로 별도로 실행합니다:
> `cd flutter && cp .env.example .env && flutter pub get && flutter run`
## 환경변수
@@ -70,7 +56,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 단독 실행용 `VITE_API_BASE_URL` | 단독 실행 시에만 |
| `frontend/.env.{env}.example` | 프론트 — 값 없음(compose 가 build arg 주입) | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
@@ -1,329 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 업무 · 지시한 업무 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee;
--blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
--amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1040px; margin: 0 auto; padding: 0 24px 70px; }
.pagehead { display: flex; align-items: flex-end; gap: 12px; padding: 24px 0 16px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
.pagehead .sub { font-size: 13px; color: var(--text-2); padding-bottom: 2px; white-space: nowrap; }
.pagehead .new-btn { margin-left: auto; height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--accent); background: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; white-space: nowrap; }
.pagehead .new-btn:hover { background: var(--accent-hover); }
.pagehead .new-btn svg { width: 15px; height: 15px; }
/* view toggle (담당 / 지시) */
.viewtabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.viewtab { display: flex; align-items: center; gap: 8px; padding: 10px 4px; margin-right: 18px; font-size: 14px; font-weight: 600; color: var(--text-2); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap; text-decoration: none; }
.viewtab:hover { color: var(--text); }
.viewtab.active { color: var(--accent); border-bottom-color: var(--accent); }
.viewtab .vt-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; height: 17px; min-width: 17px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.viewtab.active .vt-count { background: var(--accent-weak); color: var(--accent); }
.viewtab .vt-flag { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 20px; padding: 1px 7px; line-height: 1.4; white-space: nowrap; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 260px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.repofilter { display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.repofilter svg { width: 15px; height: 15px; color: var(--text-3); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* highlight banner for pending approvals */
.pending-note { display: flex; align-items: center; gap: 10px; padding: 11px 15px; background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 9px; margin-bottom: 22px; font-size: 13px; color: #8a5a12; }
.pending-note svg { width: 17px; height: 17px; color: var(--amber); flex-shrink: 0; }
.pending-note span { white-space: nowrap; }
.pending-note b { font-weight: 700; }
/* status group */
.group { margin-bottom: 22px; }
.group-head { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; padding-left: 2px; }
.gh-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.gh-dot.changes { background: var(--red); } .gh-dot.prog { background: var(--blue); }
.gh-dot.todo { background: #c4c8cf; } .gh-dot.review { background: var(--amber); } .gh-dot.done { background: var(--green); }
.gh-label { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; }
.gh-count { font-size: 11.5px; font-weight: 600; color: var(--text-2); background: #eceef1; border-radius: 20px; height: 18px; min-width: 18px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.gh-action { margin-left: 4px; font-size: 11.5px; font-weight: 600; color: var(--amber); white-space: nowrap; }
/* list + rows */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.list.attn { border-color: var(--amber-border); box-shadow: 0 0 0 3px rgba(183,121,31,.08), var(--shadow-sm); }
.task-row { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.task-row:first-child { border-top: none; }
.task-row:hover { background: #fafbfc; }
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; } .st-dot.changes { background: var(--red); }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.task-title .ttext { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); flex-shrink: 0; }
.task-row:hover .task-title .ttext { color: var(--accent); }
.task-row.is-done .task-title { color: var(--text-2); }
.task-meta { display: flex; align-items: center; gap: 12px; margin-top: 4px; font-size: 12px; color: var(--text-3); }
.repo-chip { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.repo-chip svg { width: 13px; height: 13px; color: var(--accent); flex-shrink: 0; }
.mi { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.mi svg { width: 13px; height: 13px; }
.mi.attn { color: var(--amber); font-weight: 600; }
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: #d7dae0; flex-shrink: 0; }
/* assignee avatars */
.mini-stack { display: inline-flex; align-items: center; }
.mini-av { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; color: #fff; box-shadow: 0 0 0 1.5px #fff; flex-shrink: 0; }
.mini-stack .mini-av + .mini-av { margin-left: -5px; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
.due { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); font-weight: 500; white-space: nowrap; }
.due svg { width: 14px; height: 14px; color: var(--text-3); }
.due.overdue { color: var(--red); font-weight: 600; }
.due.overdue svg { color: var(--red); }
.dday { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 20px; white-space: nowrap; line-height: 1.5; }
.dday.urgent { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.dday.soon { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.dday.calm { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 64px; text-align: center; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); }
.st-badge.changes { color: var(--red); background: var(--red-weak); }
.st-badge.done { color: var(--green); background: var(--green-weak); }
.st-badge.todo { color: var(--text-2); background: #f1f2f4; }
/* action button on pending-approval rows */
.review-btn { font-size: 12px; font-weight: 600; color: #fff; background: var(--amber); border: 1px solid var(--amber); border-radius: var(--radius); padding: 5px 11px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html" class="active">내 업무</a>
<a href="저장소 목록.html">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="내 업무 · 지시한 업무">
<div class="pagehead">
<h1>내 업무</h1>
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
<a class="new-btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무 지시</a>
</div>
<!-- 담당 / 지시 토글 -->
<div class="viewtabs">
<a class="viewtab" href="내 업무.html"><span class="tb-label">담당 업무</span> <span class="vt-count">6</span></a>
<a class="viewtab active" href="내 업무 (지시한 업무).html"><span class="tb-label">지시한 업무</span> <span class="vt-count">5</span> <span class="vt-flag">승인 대기 1</span></a>
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="업무 검색…">
</div>
<div class="repofilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
모든 저장소
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
마감 임박순
</div>
</div>
<!-- 승인 대기 강조 배너 -->
<div class="pending-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"/><circle cx="12" cy="12" r="9"/></svg>
<span><b>1건</b>의 업무가 내 승인을 기다리고 있어요. 검토 후 승인하거나 수정을 요청하세요.</span>
</div>
<!-- 승인 대기 (내가 결정) -->
<div class="group">
<div class="group-head"><span class="gh-dot review"></span><span class="gh-label">승인 대기</span><span class="gh-count">1</span><span class="gh-action">· 내 승인 필요</span></div>
<div class="list attn">
<a class="task-row" href="업무 상세.html">
<span class="st-dot review"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">메인 배너 이미지 2종 (가로/세로) 디자인</span> <span class="tid">#9</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-b"></span><span class="mini-av av-d"></span></span></span>
<span class="meta-sep"></span>
<span class="mi attn">박지훈님이 2시간 전 승인 요청</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 18일 <span class="dday soon">D-3</span></span>
<span class="review-btn">검토하기</span>
</span>
</a>
</div>
</div>
<!-- 진행 중 -->
<div class="group">
<div class="group-head"><span class="gh-dot prog"></span><span class="gh-label">진행 중</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row" href="업무 상세.html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">법무 검토 요청 및 회신 반영</span> <span class="tid">#11</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-c"></span></span> 이도윤</span>
</span>
</span>
<span class="task-right">
<span class="due overdue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>6월 12일 <span class="dday urgent">3일 지남</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">SNS 콘텐츠 2차 시안 제작</span> <span class="tid">#16</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>3/6</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-e"></span></span> 정민호</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 19일 <span class="dday soon">D-4</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
</div>
</div>
<!-- 수정 요청 -->
<div class="group">
<div class="group-head"><span class="gh-dot changes"></span><span class="gh-label">수정 요청함</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (수정 요청).html">
<span class="st-dot changes"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">상세페이지 카피라이팅</span> <span class="tid">#8</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-c"></span></span> 이도윤에게 수정 요청 · 1일 전</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 20일 <span class="dday soon">D-5</span></span>
<span class="st-badge changes">수정 요청</span>
</span>
</a>
</div>
</div>
<!-- 할 일 -->
<div class="group">
<div class="group-head"><span class="gh-dot todo"></span><span class="gh-label">할 일</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세.html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">SNS 콘텐츠 1차 시안</span> <span class="tid">#7</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-e"></span></span> 정민호</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 23일 <span class="dday calm">D-8</span></span>
<span class="st-badge todo">할 일</span>
</span>
</a>
</div>
</div>
<!-- 완료 -->
<div class="group">
<div class="group-head"><span class="gh-dot done"></span><span class="gh-label">완료</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">캠페인 핵심 메시지 시안 작성</span> <span class="tid">#6</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-a"></span></span> 김서연</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 9일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">퍼포먼스 광고 소재 세팅</span> <span class="tid">#4</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><span class="mini-stack"><span class="mini-av av-b"></span></span> 박지훈</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 5일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
</div>
</div>
</div>
</body>
</html>
-314
View File
@@ -1,314 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 업무 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee;
--blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
--amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1040px; margin: 0 auto; padding: 0 24px 70px; }
.pagehead { display: flex; align-items: flex-end; gap: 12px; padding: 24px 0 16px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
.pagehead .sub { font-size: 13px; color: var(--text-2); padding-bottom: 2px; white-space: nowrap; }
/* view toggle (담당 / 지시) */
.viewtabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.viewtab { display: flex; align-items: center; gap: 8px; padding: 10px 4px; margin-right: 18px; font-size: 14px; font-weight: 600; color: var(--text-2); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap; text-decoration: none; }
.viewtab:hover { color: var(--text); }
.viewtab.active { color: var(--accent); border-bottom-color: var(--accent); }
.viewtab .vt-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; height: 17px; min-width: 17px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.viewtab.active .vt-count { background: var(--accent-weak); color: var(--accent); }
.viewtab .vt-flag { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 20px; padding: 1px 7px; line-height: 1.4; white-space: nowrap; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 260px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.repofilter { display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.repofilter svg { width: 15px; height: 15px; color: var(--text-3); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* status group */
.group { margin-bottom: 22px; }
.group-head { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; padding-left: 2px; }
.gh-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.gh-dot.changes { background: var(--red); } .gh-dot.prog { background: var(--blue); }
.gh-dot.todo { background: #c4c8cf; } .gh-dot.review { background: var(--amber); } .gh-dot.done { background: var(--green); }
.gh-label { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; }
.gh-count { font-size: 11.5px; font-weight: 600; color: var(--text-2); background: #eceef1; border-radius: 20px; height: 18px; min-width: 18px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
/* list + rows */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.task-row { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.task-row:first-child { border-top: none; }
.task-row:hover { background: #fafbfc; }
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; } .st-dot.changes { background: var(--red); }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.task-title .ttext { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); flex-shrink: 0; }
.task-row:hover .task-title .ttext { color: var(--accent); }
.task-row.is-done .task-title { color: var(--text-2); }
.task-meta { display: flex; align-items: center; gap: 12px; margin-top: 4px; font-size: 12px; color: var(--text-3); }
.repo-chip { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.repo-chip svg { width: 13px; height: 13px; color: var(--accent); flex-shrink: 0; }
.mi { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.mi svg { width: 13px; height: 13px; }
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: #d7dae0; flex-shrink: 0; }
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
.due { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); font-weight: 500; white-space: nowrap; }
.due svg { width: 14px; height: 14px; color: var(--text-3); }
.dday { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 20px; white-space: nowrap; line-height: 1.5; }
.dday.urgent { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.dday.soon { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.dday.calm { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 64px; text-align: center; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); }
.st-badge.changes { color: var(--red); background: var(--red-weak); }
.st-badge.done { color: var(--green); background: var(--green-weak); }
.st-badge.todo { color: var(--text-2); background: #f1f2f4; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="#" class="active">내 업무</a>
<a href="저장소 목록.html">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="내 업무">
<div class="pagehead">
<h1>내 업무</h1>
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
</div>
<!-- 담당 / 지시 토글 -->
<div class="viewtabs">
<a class="viewtab active" href="내 업무.html"><span class="tb-label">담당 업무</span> <span class="vt-count">6</span></a>
<a class="viewtab" href="내 업무 (지시한 업무).html"><span class="tb-label">지시한 업무</span> <span class="vt-count">5</span> <span class="vt-flag">승인 대기 1</span></a>
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="업무 검색…">
</div>
<div class="repofilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
모든 저장소
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
마감 임박순
</div>
</div>
<!-- 수정 요청 -->
<div class="group">
<div class="group-head"><span class="gh-dot changes"></span><span class="gh-label">수정 요청</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (수정 요청).html">
<span class="st-dot changes"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">3분기 캠페인 예산안 재작성</span> <span class="tid">#14</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 16일 <span class="dday urgent">D-1</span></span>
<span class="st-badge changes">수정 요청</span>
</span>
</a>
</div>
</div>
<!-- 진행 중 -->
<div class="group">
<div class="group-head"><span class="gh-dot prog"></span><span class="gh-label">진행 중</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">브랜드 리뉴얼 킥오프 기획서 작성</span> <span class="tid">#12</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>브랜드 리뉴얼 2026</span>
<span class="meta-sep"></span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>2/5</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 18일 <span class="dday soon">D-3</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">9월 신제품 GTM 전략 수립</span> <span class="tid">#10</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/4</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 21일 <span class="dday calm">D-6</span></span>
<span class="st-badge prog">진행 중</span>
</span>
</a>
</div>
</div>
<!-- 할 일 -->
<div class="group">
<div class="group-head"><span class="gh-dot todo"></span><span class="gh-label">할 일</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">월간 뉴스레터 8월호 주제 선정</span> <span class="tid">#15</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>월간 뉴스레터</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 24일 <span class="dday calm">D-9</span></span>
<span class="st-badge todo">할 일</span>
</span>
</a>
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">SEO 키워드 전략 분기 리뷰</span> <span class="tid">#13</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>SEO 콘텐츠 허브</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 27일 <span class="dday calm">D-12</span></span>
<span class="st-badge todo">할 일</span>
</span>
</a>
</div>
</div>
<!-- 승인 대기 -->
<div class="group">
<div class="group-head"><span class="gh-dot review"></span><span class="gh-label">승인 대기</span><span class="gh-count">1</span></div>
<div class="list">
<a class="task-row" href="업무 상세 (승인 요청).html">
<span class="st-dot review"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">브랜드 무드보드 방향 정리</span> <span class="tid">#2</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>브랜드 리뉴얼 2026</span>
<span class="meta-sep"></span>
<span class="mi">한승우님 검토 중 · 1일 전 요청</span>
</span>
</span>
<span class="task-right">
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 17일 <span class="dday soon">D-2</span></span>
<span class="st-badge review">승인 대기</span>
</span>
</a>
</div>
</div>
<!-- 완료 -->
<div class="group">
<div class="group-head"><span class="gh-dot done"></span><span class="gh-label">완료</span><span class="gh-count">2</span></div>
<div class="list">
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">2분기 캠페인 성과 회고 정리</span> <span class="tid">#6</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>월간 뉴스레터</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 11일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title"><span class="ttext">신제품 네이밍 후보 정리</span> <span class="tid">#5</span></span>
<span class="task-meta">
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
<span class="meta-sep"></span>
<span class="mi">지시 · 한승우</span>
</span>
</span>
<span class="task-right">
<span class="due">6월 5일 승인됨</span>
<span class="st-badge done">완료</span>
</span>
</a>
</div>
</div>
</div>
</body>
</html>
-187
View File
@@ -1,187 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--kakao: #FEE500; --kakao-text: #191600;
--radius: 8px; --radius-sm: 5px;
}
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
.auth { display: flex; min-height: 100vh; }
/* ---- brand panel ---- */
.brand-panel {
flex: 0 0 46%; max-width: 620px; position: relative; overflow: hidden;
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
color: #fff; padding: 44px 48px; display: flex; flex-direction: column;
}
.brand-panel .blob { position: absolute; border-radius: 50%; filter: blur(2px); pointer-events: none; }
.brand-panel .b1 { width: 360px; height: 360px; right: -120px; top: -90px; background: rgba(255,255,255,.10); }
.brand-panel .b2 { width: 260px; height: 260px; left: -90px; bottom: -70px; background: rgba(255,255,255,.07); }
.brand-panel .ring { position: absolute; right: 60px; bottom: 80px; width: 180px; height: 180px; border: 1.5px solid rgba(255,255,255,.14); border-radius: 50%; }
.brand-panel .ring::after { content: ""; position: absolute; inset: 34px; border: 1.5px solid rgba(255,255,255,.10); border-radius: 50%; }
.bp-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -.2px; position: relative; z-index: 1; }
.bp-logo .logo { width: 32px; height: 32px; border-radius: 9px; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); display: grid; place-items: center; font-size: 17px; font-weight: 800; }
.bp-body { margin-top: auto; position: relative; z-index: 1; }
.bp-head { font-size: 34px; font-weight: 700; line-height: 1.32; letter-spacing: -.8px; text-wrap: balance; }
.bp-sub { margin-top: 18px; font-size: 15px; line-height: 1.7; color: rgba(255,255,255,.82); max-width: 400px; }
.bp-points { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
.bp-point { display: flex; align-items: center; gap: 11px; font-size: 14px; color: rgba(255,255,255,.92); }
.bp-point .ic { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; flex-shrink: 0; }
.bp-point .ic svg { width: 13px; height: 13px; }
.bp-foot { margin-top: 40px; font-size: 12.5px; color: rgba(255,255,255,.6); position: relative; z-index: 1; }
/* ---- form panel ---- */
.form-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
.auth-card { width: 100%; max-width: 384px; }
.ac-logo { display: none; }
.ac-title { font-size: 24px; font-weight: 700; letter-spacing: -.5px; }
.ac-sub { color: var(--text-2); font-size: 14px; margin-top: 7px; }
.socials { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; }
.sbtn {
height: 46px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff;
display: flex; align-items: center; justify-content: center; gap: 10px; cursor: pointer;
font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text); text-decoration: none;
position: relative; transition: background .12s, border-color .12s;
}
.sbtn:hover { background: #f7f8fa; }
.sbtn .sico { position: absolute; left: 16px; display: grid; place-items: center; }
.sbtn.kakao { background: var(--kakao); border-color: var(--kakao); color: var(--kakao-text); }
.sbtn.kakao:hover { background: #f2d900; }
.divider { display: flex; align-items: center; gap: 14px; margin: 22px 0; color: var(--text-3); font-size: 12.5px; }
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
.field { margin-bottom: 14px; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 7px; display: block; }
.tinput { width: 100%; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 13px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.tinput::placeholder { color: var(--text-3); }
.pw-wrap { position: relative; }
.pw-wrap .tinput { padding-right: 44px; }
.pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; border-radius: 6px; }
.pw-toggle:hover { background: #f1f2f4; color: var(--text-2); }
.pw-toggle svg { width: 18px; height: 18px; }
.options { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 22px; }
.keep { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text-2); user-select: none; white-space: nowrap; }
.keep .box { width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; }
.keep .box.on { background: var(--accent); border-color: var(--accent); }
.keep .box svg { width: 11px; height: 11px; color: #fff; opacity: 0; }
.keep .box.on svg { opacity: 1; }
.link { color: var(--accent); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap; }
.link:hover { text-decoration: underline; }
.submit { width: 100%; height: 46px; border-radius: var(--radius); border: none; background: var(--accent); color: #fff; font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(79,70,229,.4); text-decoration: none; display: flex; align-items: center; justify-content: center; }
.submit:hover { background: var(--accent-hover); }
.signup { margin-top: 24px; text-align: center; font-size: 13.5px; color: var(--text-2); }
.signup a { color: var(--accent); font-weight: 600; text-decoration: none; }
.signup a:hover { text-decoration: underline; }
.verify-note { margin-top: 6px; text-align: center; font-size: 12px; color: var(--text-3); display: flex; align-items: center; justify-content: center; gap: 5px; }
.verify-note svg { width: 13px; height: 13px; }
@media (max-width: 880px) {
.brand-panel { display: none; }
.ac-logo { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; margin-bottom: 26px; }
.ac-logo .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; }
}
</style>
</head>
<body>
<div class="auth">
<aside class="brand-panel">
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="ring"></div>
<div class="bp-logo"><span class="logo">R</span> Relay</div>
<div class="bp-body">
<div class="bp-head">지시부터 완료까지,<br>팀의 업무 흐름을 하나로</div>
<div class="bp-sub">저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에 추적됩니다.</div>
<div class="bp-points">
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 저장소 · 업무 · 담당자로 이어지는 명확한 구조</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 체크리스트와 마감기한으로 진행 관리</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> Gitea 연동으로 안전하게</div>
</div>
</div>
<div class="bp-foot">© 2026 Relay · 사내 업무 지시 플랫폼</div>
</aside>
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo"><span class="logo">R</span> Relay</div>
<h1 class="ac-title">로그인</h1>
<p class="ac-sub">업무 지시를 시작하려면 로그인하세요.</p>
<!-- 소셜 로그인 -->
<div class="socials">
<a class="sbtn" href="저장소 목록.html">
<span class="sico"><svg width="20" height="20" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.4 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.3-.4-3.5z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.4 0 10.3-2.1 14-5.4l-6.5-5.5C29.6 34.6 26.9 36 24 36c-5.2 0-9.6-3.5-11.2-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.5l6.5 5.5C41.4 36 44 30.5 44 24c0-1.3-.1-2.3-.4-3.5z"/></svg></span>
Google로 계속하기
</a>
<a class="sbtn kakao" href="저장소 목록.html">
<span class="sico"><svg width="19" height="19" viewBox="0 0 24 24" fill="#191600"><path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.8 1.9 5.2 4.7 6.6-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.6.1 1.3.1 2 .1 5.5 0 10-3.5 10-7.8C22 6.5 17.5 3 12 3z"/></svg></span>
카카오로 계속하기
</a>
</div>
<div class="divider">또는 이메일로 로그인</div>
<!-- 이메일 로그인 -->
<div class="field">
<label class="flabel">이메일</label>
<input class="tinput" type="email" placeholder="name@acme.co" value="">
</div>
<div class="field">
<label class="flabel">비밀번호</label>
<div class="pw-wrap">
<input class="tinput" type="password" placeholder="비밀번호 입력" value="">
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<div class="options">
<span class="keep"><span class="box on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 로그인 상태 유지</span>
<a class="link" href="#">비밀번호 찾기</a>
</div>
<a class="submit" href="저장소 목록.html">로그인</a>
<div class="signup">계정이 없으신가요? <a href="회원가입.html">회원가입</a></div>
<div class="verify-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z" opacity="0"/><path d="M22 6 12 13 2 6"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
이메일 회원가입은 인증 메일 확인 후 완료됩니다
</div>
</div>
</main>
</div>
<script>
// 비밀번호 표시 토글 (정적 시안용 최소 인터랙션)
document.querySelector('.pw-toggle').addEventListener('click', function () {
var input = this.parentElement.querySelector('input');
input.type = input.type === 'password' ? 'text' : 'password';
});
document.querySelector('.keep').addEventListener('click', function () {
this.querySelector('.box').classList.toggle('on');
});
</script>
</body>
</html>
@@ -1,469 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>메인 배너 이미지 2종 디자인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
.ph-main { flex: 1; min-width: 0; }
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.icon { width: 34px; padding: 0; justify-content: center; }
/* more menu */
.more-wrap { position: relative; }
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
.menu.open { display: block; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
.menu-item:hover { background: #f5f6f8; }
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.menu-item.danger { color: var(--red); }
.menu-item.danger svg { color: var(--red); }
.menu-item.danger:hover { background: var(--red-weak); }
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
.modal-body { padding: 26px 24px 8px; text-align: center; }
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
.modal-ico svg { width: 22px; height: 22px; }
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
.modal-desc b { color: var(--text); font-weight: 600; }
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
.mbtn.danger:hover { background: #c23a51; }
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.mbtn.approve:hover { background: #188a4b; }
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.request-send:hover { background: var(--accent-hover); }
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.modal-textarea::placeholder { color: var(--text-3); }
/* approval sidebar card */
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
.ap-card.changes { border-color: var(--red-border); background: var(--red-weak); }
.ap-card.changes .ap-card-ico { color: var(--red); border-color: var(--red-border); }
.ap-card.changes .ap-card-title { color: var(--red); }
.ap-card.changes .ap-card-note { border-color: var(--red-border); }
.ap-card-head { display: flex; align-items: center; gap: 9px; }
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
.ap-card-ico svg { width: 17px; height: 17px; }
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
.ap-card.review .ap-card-title { color: #8a5a12; }
.ap-card.request .ap-card-title { color: var(--accent-hover); }
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
.ap-card-meta b { color: var(--text); font-weight: 600; }
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.ap-btn svg { width: 15px; height: 15px; }
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.ap-btn.approve:hover { background: #188a4b; }
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.ap-btn.request-send:hover { background: var(--accent-hover); }
/* approval system event in thread */
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
.sys-event .se-ico svg { width: 14px; height: 14px; }
.sys-event b { color: var(--text); font-weight: 600; }
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
/* layout */
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.main-card { padding: 22px 26px 26px; }
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
.content p { margin-bottom: 11px; }
.content p:last-child { margin-bottom: 0; }
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* checklist */
.checklist { display: flex; flex-direction: column; }
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* nested subtasks (read-only) */
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
.citem.parent .ctext { font-weight: 600; }
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* comments */
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
.comment:first-of-type { border-top: none; }
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
.c-body { flex: 1; min-width: 0; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.composer .field { flex: 1; }
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.composer textarea::placeholder { color: var(--text-3); }
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* sidebar */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
.status-badge.review .dot { background: var(--amber); }
.status-badge.changes { color: var(--red); background: var(--red-weak); border-color: var(--red-border); }
.status-badge.changes .dot { background: var(--red); }
.assignees { display: flex; flex-direction: column; gap: 9px; }
.ass { display: flex; align-items: center; gap: 9px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
.files { display: flex; flex-direction: column; gap: 7px; }
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me" style="background:#3d8bf2"></div>
</div>
</header>
<div class="page" data-screen-label="업무 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">업무</a>
<span class="sep">/</span>
<b>#9</b>
</div>
<div class="pagehead">
<div class="ph-main">
<div class="ph-titlerow">
<span class="st-badge changes">수정 요청</span>
<span class="ph-tid">#9</span>
</div>
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
</div>
<div class="actions">
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
<div class="more-wrap">
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
<div class="menu" id="moreMenu">
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
업무 복제
</button>
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
링크 복사
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" id="deleteOpen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
업무 삭제
</button>
</div>
</div>
</div>
</div>
<div class="layout">
<!-- main -->
<section class="card main-card">
<div class="sect">
<div class="sect-label">업무 내용</div>
<div class="content">
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
</div>
</div>
<div class="sect">
<div class="sect-label">해야 할 일
<span class="pr"><span class="progress-bar"><i style="width:33%"></i></span><span class="progress-txt">1 / 3 완료</span></span>
</div>
<div class="checklist">
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">가로형 메인 배너 1920×1080</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">세로형 메인 배너 1080×1920</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">모바일 적응형 600×600</span>
</div>
</div>
</div>
<div class="sect">
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
<div class="comment">
<span class="c-av av-f"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
</div>
</div>
<div class="comment">
<span class="c-av av-b"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
</div>
</div>
<div class="comment">
<span class="c-av av-d"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
</div>
</div>
<div class="sys-event">
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
<span class="se-time">2시간 전</span>
</div>
<div class="composer">
<span class="c-av" style="background:#3d8bf2"></span>
<div class="field">
<textarea placeholder="댓글을 입력하세요…"></textarea>
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
</div>
</div>
</div>
</section>
<!-- sidebar -->
<aside class="side">
<!-- 수정 요청됨: 작업자(박지훈) 시점 -->
<div class="card ap-card changes">
<div class="ap-card-head">
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg></span>
<span class="ap-card-title">수정 요청됨</span>
</div>
<div class="ap-card-meta"><b>정태경</b>님이 수정을 요청했습니다 · 1시간 전</div>
<div class="ap-card-note">세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.</div>
<div class="ap-card-actions">
<button class="ap-btn request-send" id="requestOpen" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>다시 승인 요청</button>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">상태</div>
<span class="status-badge changes"><span class="dot"></span>수정 요청</span>
</div>
<div class="side-field">
<div class="side-label">담당자 · 2명</div>
<div class="assignees">
<div class="ass"><span class="avatar av-b"></span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
<div class="ass"><span class="avatar av-d"></span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
</div>
</div>
<div class="side-field">
<div class="side-label">마감기한</div>
<div class="dl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6월 18일 (목)
<span class="dday">D-3</span>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">첨부파일 · 2개</div>
<div class="files">
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">지시자</div>
<div class="issuer"><span class="avatar av-f"></span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
<div class="meta-line">6월 8일 지시 · #9</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body">
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
<h2 class="modal-title">업무를 삭제할까요?</h2>
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-foot">
<button class="mbtn" id="deleteCancel" type="button">취소</button>
<button class="mbtn danger" type="button">삭제</button>
</div>
</div>
</div>
<!-- 승인 요청 모달 -->
<div class="modal-backdrop" id="requestModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
<h2 class="modal-title">승인 요청 보내기</h2>
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
</div>
<div class="modal-foot">
<button class="mbtn" data-close="requestModal" type="button">취소</button>
<button class="mbtn request-send" type="button">요청 보내기</button>
</div>
</div>
</div>
<script>
var moreBtn = document.getElementById('moreBtn');
var moreMenu = document.getElementById('moreMenu');
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
/* 삭제 모달 */
var dModal = document.getElementById('deleteModal');
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
/* 승인 요청 모달 */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(el) { el.classList.remove('open'); }
document.getElementById('requestOpen').addEventListener('click', function () { openModal('requestModal'); });
document.querySelectorAll('[data-close]').forEach(function (b) {
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
});
var reqModal = document.getElementById('requestModal');
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
</script>
</body>
</html>
@@ -1,463 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>메인 배너 이미지 2종 디자인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
.ph-main { flex: 1; min-width: 0; }
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.icon { width: 34px; padding: 0; justify-content: center; }
/* more menu */
.more-wrap { position: relative; }
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
.menu.open { display: block; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
.menu-item:hover { background: #f5f6f8; }
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.menu-item.danger { color: var(--red); }
.menu-item.danger svg { color: var(--red); }
.menu-item.danger:hover { background: var(--red-weak); }
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
.modal-body { padding: 26px 24px 8px; text-align: center; }
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
.modal-ico svg { width: 22px; height: 22px; }
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
.modal-desc b { color: var(--text); font-weight: 600; }
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
.mbtn.danger:hover { background: #c23a51; }
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.mbtn.approve:hover { background: #188a4b; }
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.request-send:hover { background: var(--accent-hover); }
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.modal-textarea::placeholder { color: var(--text-3); }
/* approval sidebar card */
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
.ap-card.done { border-color: #cde8d8; background: var(--green-weak); }
.ap-card.done .ap-card-ico { color: var(--green); border-color: #cde8d8; }
.ap-card.done .ap-card-title { color: #167a44; }
.ap-card-head { display: flex; align-items: center; gap: 9px; }
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
.ap-card-ico svg { width: 17px; height: 17px; }
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
.ap-card.review .ap-card-title { color: #8a5a12; }
.ap-card.request .ap-card-title { color: var(--accent-hover); }
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
.ap-card-meta b { color: var(--text); font-weight: 600; }
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.ap-btn svg { width: 15px; height: 15px; }
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.ap-btn.approve:hover { background: #188a4b; }
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.ap-btn.request-send:hover { background: var(--accent-hover); }
/* approval system event in thread */
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
.sys-event .se-ico svg { width: 14px; height: 14px; }
.sys-event b { color: var(--text); font-weight: 600; }
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
/* layout */
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.main-card { padding: 22px 26px 26px; }
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
.content p { margin-bottom: 11px; }
.content p:last-child { margin-bottom: 0; }
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* checklist */
.checklist { display: flex; flex-direction: column; }
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* nested subtasks (read-only) */
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
.citem.parent .ctext { font-weight: 600; }
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* comments */
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
.comment:first-of-type { border-top: none; }
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
.c-body { flex: 1; min-width: 0; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.composer .field { flex: 1; }
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.composer textarea::placeholder { color: var(--text-3); }
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* sidebar */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
.status-badge.review .dot { background: var(--amber); }
.status-badge.done { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.status-badge.done .dot { background: var(--green); }
.assignees { display: flex; flex-direction: column; gap: 9px; }
.ass { display: flex; align-items: center; gap: 9px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
.files { display: flex; flex-direction: column; gap: 7px; }
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me" style="background:#3d8bf2"></div>
</div>
</header>
<div class="page" data-screen-label="업무 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">업무</a>
<span class="sep">/</span>
<b>#9</b>
</div>
<div class="pagehead">
<div class="ph-main">
<div class="ph-titlerow">
<span class="st-badge done">완료</span>
<span class="ph-tid">#9</span>
</div>
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
</div>
<div class="actions">
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
<div class="more-wrap">
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
<div class="menu" id="moreMenu">
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
업무 복제
</button>
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
링크 복사
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" id="deleteOpen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
업무 삭제
</button>
</div>
</div>
</div>
</div>
<div class="layout">
<!-- main -->
<section class="card main-card">
<div class="sect">
<div class="sect-label">업무 내용</div>
<div class="content">
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
</div>
</div>
<div class="sect">
<div class="sect-label">해야 할 일
<span class="pr"><span class="progress-bar"><i style="width:100%"></i></span><span class="progress-txt">3 / 3 완료</span></span>
</div>
<div class="checklist">
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">가로형 메인 배너 1920×1080</span>
</div>
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">세로형 메인 배너 1080×1920</span>
</div>
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">모바일 적응형 600×600</span>
</div>
</div>
</div>
<div class="sect">
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
<div class="comment">
<span class="c-av av-f"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
</div>
</div>
<div class="comment">
<span class="c-av av-b"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
</div>
</div>
<div class="comment">
<span class="c-av av-d"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
</div>
</div>
<div class="sys-event">
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
<span class="se-time">2시간 전</span>
</div>
<div class="composer">
<span class="c-av" style="background:#3d8bf2"></span>
<div class="field">
<textarea placeholder="댓글을 입력하세요…"></textarea>
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
</div>
</div>
</div>
</section>
<!-- sidebar -->
<aside class="side">
<!-- 승인 완료 -->
<div class="card ap-card done">
<div class="ap-card-head">
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4 12 14.01l-3-3"/></svg></span>
<span class="ap-card-title">승인 완료</span>
</div>
<div class="ap-card-meta"><b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 할 일이 모두 완료되었습니다.</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">상태</div>
<span class="status-badge done"><span class="dot"></span>완료</span>
</div>
<div class="side-field">
<div class="side-label">담당자 · 2명</div>
<div class="assignees">
<div class="ass"><span class="avatar av-b"></span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
<div class="ass"><span class="avatar av-d"></span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
</div>
</div>
<div class="side-field">
<div class="side-label">마감기한</div>
<div class="dl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6월 18일 (목)
<span class="dday">D-3</span>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">첨부파일 · 2개</div>
<div class="files">
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">지시자</div>
<div class="issuer"><span class="avatar av-f"></span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
<div class="meta-line">6월 8일 지시 · #9</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body">
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
<h2 class="modal-title">업무를 삭제할까요?</h2>
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-foot">
<button class="mbtn" id="deleteCancel" type="button">취소</button>
<button class="mbtn danger" type="button">삭제</button>
</div>
</div>
</div>
<!-- 승인 요청 모달 -->
<div class="modal-backdrop" id="requestModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
<h2 class="modal-title">승인 요청 보내기</h2>
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
</div>
<div class="modal-foot">
<button class="mbtn" data-close="requestModal" type="button">취소</button>
<button class="mbtn request-send" type="button">요청 보내기</button>
</div>
</div>
</div>
<script>
var moreBtn = document.getElementById('moreBtn');
var moreMenu = document.getElementById('moreMenu');
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
/* 삭제 모달 */
var dModal = document.getElementById('deleteModal');
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
/* 승인 요청 모달 */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(el) { el.classList.remove('open'); }
document.querySelectorAll('[data-close]').forEach(function (b) {
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
});
var reqModal = document.getElementById('requestModal');
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
</script>
</body>
</html>
@@ -1,462 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>메인 배너 이미지 2종 디자인 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
.ph-main { flex: 1; min-width: 0; }
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.icon { width: 34px; padding: 0; justify-content: center; }
/* more menu */
.more-wrap { position: relative; }
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
.menu.open { display: block; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
.menu-item:hover { background: #f5f6f8; }
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.menu-item.danger { color: var(--red); }
.menu-item.danger svg { color: var(--red); }
.menu-item.danger:hover { background: var(--red-weak); }
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
.modal-body { padding: 26px 24px 8px; text-align: center; }
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
.modal-ico svg { width: 22px; height: 22px; }
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
.modal-desc b { color: var(--text); font-weight: 600; }
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
.mbtn.danger:hover { background: #c23a51; }
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.mbtn.approve:hover { background: #188a4b; }
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.request-send:hover { background: var(--accent-hover); }
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.modal-textarea::placeholder { color: var(--text-3); }
/* approval sidebar card */
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
.ap-card-head { display: flex; align-items: center; gap: 9px; }
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
.ap-card-ico svg { width: 17px; height: 17px; }
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
.ap-card.review .ap-card-title { color: #8a5a12; }
.ap-card.request .ap-card-title { color: var(--accent-hover); }
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
.ap-card-meta b { color: var(--text); font-weight: 600; }
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.ap-btn svg { width: 15px; height: 15px; }
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
.ap-btn.approve:hover { background: #188a4b; }
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.ap-btn.request-send:hover { background: var(--accent-hover); }
/* approval system event in thread */
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
.sys-event .se-ico svg { width: 14px; height: 14px; }
.sys-event b { color: var(--text); font-weight: 600; }
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
/* layout */
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.main-card { padding: 22px 26px 26px; }
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
.content p { margin-bottom: 11px; }
.content p:last-child { margin-bottom: 0; }
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* checklist */
.checklist { display: flex; flex-direction: column; }
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* nested subtasks (read-only) */
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
.citem.parent .ctext { font-weight: 600; }
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
/* comments */
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
.comment:first-of-type { border-top: none; }
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
.c-body { flex: 1; min-width: 0; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.composer .field { flex: 1; }
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.composer textarea::placeholder { color: var(--text-3); }
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* sidebar */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
.status-badge.review .dot { background: var(--amber); }
.assignees { display: flex; flex-direction: column; gap: 9px; }
.ass { display: flex; align-items: center; gap: 9px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
.files { display: flex; flex-direction: column; gap: 7px; }
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me" style="background:#3d8bf2"></div>
</div>
</header>
<div class="page" data-screen-label="업무 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">업무</a>
<span class="sep">/</span>
<b>#9</b>
</div>
<div class="pagehead">
<div class="ph-main">
<div class="ph-titlerow">
<span class="st-badge prog">진행 중</span>
<span class="ph-tid">#9</span>
</div>
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
</div>
<div class="actions">
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
<div class="more-wrap">
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
<div class="menu" id="moreMenu">
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
업무 복제
</button>
<button class="menu-item" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
링크 복사
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" id="deleteOpen" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
업무 삭제
</button>
</div>
</div>
</div>
</div>
<div class="layout">
<!-- main -->
<section class="card main-card">
<div class="sect">
<div class="sect-label">업무 내용</div>
<div class="content">
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
</div>
</div>
<div class="sect">
<div class="sect-label">해야 할 일
<span class="pr"><span class="progress-bar"><i style="width:33%"></i></span><span class="progress-txt">1 / 3 완료</span></span>
</div>
<div class="checklist">
<div class="citem done">
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">가로형 메인 배너 1920×1080</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">세로형 메인 배너 1080×1920</span>
</div>
<div class="citem">
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="ctext">모바일 적응형 600×600</span>
</div>
</div>
</div>
<div class="sect">
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
<div class="comment">
<span class="c-av av-f"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
</div>
</div>
<div class="comment">
<span class="c-av av-b"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
</div>
</div>
<div class="comment">
<span class="c-av av-d"></span>
<div class="c-body">
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
</div>
</div>
<div class="sys-event">
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
<span class="se-time">2시간 전</span>
</div>
<div class="composer">
<span class="c-av" style="background:#3d8bf2"></span>
<div class="field">
<textarea placeholder="댓글을 입력하세요…"></textarea>
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
</div>
</div>
</div>
</section>
<!-- sidebar -->
<aside class="side">
<!-- 승인 대기: 지시자(정태경) 시점 결정 카드 -->
<div class="card ap-card request">
<div class="ap-card-head">
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span>
<span class="ap-card-title">지시자에게 승인 요청</span>
</div>
<div class="ap-card-desc">작업을 마쳤다면 지시자 <b style="color:var(--text);font-weight:600">정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.</div>
<div class="ap-card-actions">
<button class="ap-btn request-send" id="requestOpen" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>승인 요청 보내기</button>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">상태</div>
<span class="status-badge"><span class="dot"></span>진행 중</span>
</div>
<div class="side-field">
<div class="side-label">담당자 · 2명</div>
<div class="assignees">
<div class="ass"><span class="avatar av-b"></span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
<div class="ass"><span class="avatar av-d"></span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
</div>
</div>
<div class="side-field">
<div class="side-label">마감기한</div>
<div class="dl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6월 18일 (목)
<span class="dday">D-3</span>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">첨부파일 · 2개</div>
<div class="files">
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">지시자</div>
<div class="issuer"><span class="avatar av-f"></span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
<div class="meta-line">6월 8일 지시 · #9</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body">
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
<h2 class="modal-title">업무를 삭제할까요?</h2>
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-foot">
<button class="mbtn" id="deleteCancel" type="button">취소</button>
<button class="mbtn danger" type="button">삭제</button>
</div>
</div>
</div>
<!-- 승인 요청 모달 -->
<div class="modal-backdrop" id="requestModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
<h2 class="modal-title">승인 요청 보내기</h2>
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
</div>
<div class="modal-foot">
<button class="mbtn" data-close="requestModal" type="button">취소</button>
<button class="mbtn request-send" type="button">요청 보내기</button>
</div>
</div>
</div>
<script>
var moreBtn = document.getElementById('moreBtn');
var moreMenu = document.getElementById('moreMenu');
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
/* 삭제 모달 */
var dModal = document.getElementById('deleteModal');
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
/* 승인 요청 모달 */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(el) { el.classList.remove('open'); }
document.getElementById('requestOpen').addEventListener('click', function () { openModal('requestModal'); });
document.querySelectorAll('[data-close]').forEach(function (b) {
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
});
var reqModal = document.getElementById('requestModal');
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-482
View File
@@ -1,482 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 업무 작성 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--panel: #ffffff;
--border: #e6e8ec;
--border-strong: #d7dae0;
--text: #16181d;
--text-2: #5b626e;
--text-3: #9298a3;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-weak: #eef0fe;
--accent-border: #c7ccfb;
--green: #1f9d57;
--green-weak: #e8f6ee;
--amber: #b7791f;
--radius: 6px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
--shadow-md: 0 4px 16px rgba(20,24,33,.10), 0 1px 3px rgba(20,24,33,.06);
--shadow-pop: 0 8px 28px rgba(20,24,33,.16), 0 2px 6px rgba(20,24,33,.08);
}
html, body {
background: var(--bg);
color: var(--text);
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body { min-height: 100vh; }
/* ---------- Top app bar ---------- */
.topbar {
height: 52px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 28px;
padding: 0 20px;
position: sticky;
top: 0;
z-index: 50;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo {
width: 26px; height: 26px; border-radius: 7px;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid; place-items: center; color: #fff;
font-size: 15px; font-weight: 800;
box-shadow: 0 2px 6px rgba(79,70,229,.35);
}
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a {
color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500;
padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap;
}
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent;
color: var(--text-2); display: grid; place-items: center; cursor: pointer;
}
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me {
width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px;
}
/* ---------- Page header ---------- */
.page { max-width: 1240px; margin: 0 auto; padding: 0 24px 60px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
.crumb b { color: var(--text-2); font-weight: 600; }
.crumb .sep { opacity: .5; }
.pagehead { display: flex; align-items: center; gap: 16px; padding: 8px 0 18px; }
.pagehead h1 { font-size: 21px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; flex-shrink: 0; }
.draft-tag {
font-size: 11.5px; font-weight: 600; color: var(--amber); background: #fdf4e3;
border: 1px solid #f3e2bd; padding: 3px 8px; border-radius: 20px; line-height: 1; white-space: nowrap;
}
.pagehead .actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.btn {
height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600;
border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2);
cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none;
}
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: #eceef1; }
.btn.primary {
background: var(--accent); border-color: var(--accent); color: #fff;
box-shadow: 0 1px 2px rgba(79,70,229,.4);
}
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* ---------- Layout ---------- */
.layout { display: grid; grid-template-columns: 1fr 348px; gap: 20px; align-items: start; }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow-sm);
}
.main-card { padding: 8px 26px 26px; }
/* Title field */
.title-input {
width: 100%; border: none; outline: none; background: transparent;
font-family: inherit; font-size: 24px; font-weight: 700; letter-spacing: -.5px;
color: var(--text); padding: 20px 0 14px; line-height: 1.3;
}
.title-input::placeholder { color: #c4c8cf; }
.divider { height: 1px; background: var(--border); margin: 0 -26px; }
.field { padding-top: 22px; }
.field-label {
display: flex; align-items: center; gap: 7px;
font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 9px; white-space: nowrap;
}
.field-label .req { color: var(--accent); font-weight: 700; }
.field-label .hint { margin-left: auto; font-weight: 500; color: var(--text-3); font-size: 12px; }
/* Content editor */
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius); overflow: hidden; }
.editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.toolbar {
display: flex; align-items: center; gap: 2px; padding: 6px 8px;
border-bottom: 1px solid var(--border); background: #fafbfc;
}
.tb {
height: 28px; min-width: 28px; padding: 0 7px; border-radius: var(--radius-sm); border: none; background: transparent;
color: var(--text-2); font-size: 13px; font-weight: 600; cursor: pointer; display: grid; place-items: center;
}
.tb:hover { background: #eceef1; color: var(--text); }
.tb svg { width: 16px; height: 16px; }
.tb-sep { width: 1px; height: 18px; background: var(--border-strong); margin: 0 5px; }
.editor-body { padding: 14px 16px; font-size: 14px; color: var(--text); min-height: 150px; line-height: 1.65; }
.editor-body p { margin-bottom: 11px; }
.editor-body p:last-child { margin-bottom: 0; }
.editor-body .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
/* Checklist */
.check-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.progress-wrap { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.checklist { display: flex; flex-direction: column; }
.check-item {
display: flex; align-items: center; gap: 11px; padding: 9px 0;
border-radius: var(--radius-sm); position: relative;
}
.check-item:hover { background: #f7f8fa; }
.check-item .grip { position: absolute; left: -21px; top: 50%; transform: translateY(-50%); color: #c4c8cf; cursor: grab; opacity: 0; font-size: 14px; line-height: 1; letter-spacing: -2px; }
.check-item:hover .grip { opacity: 1; }
.cbox {
width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong);
flex-shrink: 0; display: grid; place-items: center; background: #fff; cursor: pointer;
}
.cbox.done { background: var(--green); border-color: var(--green); }
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.cbox.done svg { opacity: 1; }
.check-item .txt { font-size: 13.5px; color: var(--text); flex: 1; }
.check-item.done .txt { color: var(--text-3); text-decoration: line-through; }
.check-item .row-del { opacity: 0; color: var(--text-3); cursor: pointer; width: 24px; height: 24px; display: grid; place-items: center; border-radius: 4px; }
.check-item:hover .row-del { opacity: 1; }
.check-item .row-del:hover { background: #eceef1; color: #d6455d; }
.add-row {
display: flex; align-items: center; gap: 11px; padding: 10px 0 4px 0; color: var(--text-3);
font-size: 13.5px; cursor: text;
}
.add-row .plus { width: 18px; height: 18px; border-radius: 5px; border: 1.5px dashed var(--border-strong); display: grid; place-items: center; font-size: 13px; line-height: 1; }
/* nested subtasks */
.task-group + .task-group { border-top: 1px solid var(--border); }
.check-item.parent .txt { font-weight: 600; }
.sub-count {
font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4;
border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap;
}
.check-item.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
.subtasks { margin-left: 31px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
.subtask {
display: flex; align-items: center; gap: 10px; padding: 6px 0;
border-radius: var(--radius-sm); position: relative;
}
.subtask:hover { background: #f7f8fa; }
.subtask .grip { position: absolute; left: -19px; top: 50%; transform: translateY(-50%); color: #c4c8cf; cursor: grab; opacity: 0; font-size: 13px; line-height: 1; letter-spacing: -2px; }
.subtask:hover .grip { opacity: 1; }
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
.cbox.sm svg { width: 11px; height: 11px; }
.subtask .txt { font-size: 13px; color: var(--text-2); flex: 1; }
.subtask.done .txt { color: var(--text-3); text-decoration: line-through; }
.subtask .row-del { opacity: 0; color: var(--text-3); cursor: pointer; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; }
.subtask:hover .row-del { opacity: 1; }
.subtask .row-del:hover { background: #eceef1; color: #d6455d; }
.add-sub {
display: flex; align-items: center; gap: 9px; margin-left: 31px; padding: 7px 0 9px 0;
color: var(--text-3); font-size: 12.5px; cursor: text;
}
.add-sub .plus-sm { width: 16px; height: 16px; border-radius: 5px; border: 1.5px dashed var(--border-strong); display: grid; place-items: center; font-size: 12px; line-height: 1; }
/* ---------- Sidebar ---------- */
.side { display: flex; flex-direction: column; gap: 16px; }
.side-card { padding: 16px 18px 18px; }
.side-field + .side-field { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.side-label { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 9px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.side-label .req { color: var(--accent); }
/* assignee combobox */
.combo { position: relative; }
.combo-field {
border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 7px 8px; background: #fff;
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.combo-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.combo-caret { color: var(--text-3); display: grid; place-items: center; margin-left: auto; flex-shrink: 0; }
.combo-caret svg { width: 16px; height: 16px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; background: #f1f2f4; border: 1px solid var(--border);
border-radius: 20px; padding: 3px 8px 3px 3px; font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap;
}
.chip .x { color: var(--text-3); cursor: pointer; font-size: 13px; line-height: 1; margin-left: 1px; }
.chip .x:hover { color: #d6455d; }
.combo-input { flex: 1; min-width: 70px; border: none; outline: none; font-family: inherit; font-size: 13px; padding: 4px 2px; background: transparent; }
.combo-input::placeholder { color: var(--text-3); }
.avatar { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.dropdown {
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 40;
background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow-pop);
padding: 6px; overflow: hidden;
}
.dd-search { font-size: 11px; color: var(--text-3); padding: 4px 8px 6px; font-weight: 600; }
.dd-item {
display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-sm); cursor: pointer;
}
.dd-item:hover { background: #f5f6f8; }
.dd-item.sel { background: var(--accent-weak); }
.dd-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.dd-name { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.3; }
.dd-role { font-size: 11.5px; color: var(--text-3); line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dd-check { flex-shrink: 0; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; }
.dd-item.sel .dd-check { background: var(--accent); border-color: var(--accent); }
.dd-check svg { width: 11px; height: 11px; color: #fff; opacity: 0; }
.dd-item.sel .dd-check svg { opacity: 1; }
/* date */
.input {
width: 100%; height: 38px; border: 1px solid var(--border-strong); border-radius: var(--radius);
padding: 0 11px; font-family: inherit; font-size: 13.5px; color: var(--text); background: #fff;
display: flex; align-items: center; gap: 9px;
}
.input .ico { color: var(--text-3); display: grid; place-items: center; }
.input .ico svg { width: 16px; height: 16px; }
.date-meta { font-size: 12px; color: var(--amber); font-weight: 600; margin-top: 7px; display: flex; align-items: center; gap: 5px; }
.date-meta .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--amber); }
/* attachments */
.dropzone {
border: 1.5px dashed var(--border-strong); border-radius: var(--radius); padding: 13px;
text-align: center; color: var(--text-3); font-size: 12.5px; cursor: pointer; background: #fafbfc;
}
.dropzone:hover { border-color: var(--accent-border); background: var(--accent-weak); color: var(--accent); }
.dropzone b { color: var(--accent); font-weight: 600; }
.files { display: flex; flex-direction: column; gap: 7px; margin-bottom: 10px; }
.file {
display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border);
border-radius: var(--radius); background: #fff;
}
.file-ico { width: 30px; height: 30px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 10px; font-weight: 800; color: #fff; letter-spacing: -.3px; }
.fi-pdf { background: #e25950; } .fi-zip { background: #8a64d6; } .fi-doc { background: #3d8bf2; }
.file-info { min-width: 0; flex: 1; }
.file-name { font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
.file .x { color: var(--text-3); cursor: pointer; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; flex-shrink: 0; }
.file .x:hover { background: #eceef1; color: #d6455d; }
/* issuer meta */
.issuer { display: flex; align-items: center; gap: 10px; }
.issuer .avatar { width: 30px; height: 30px; font-size: 12px; }
.issuer .nm { font-size: 13px; font-weight: 600; }
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.side-foot { font-size: 11.5px; color: var(--text-3); line-height: 1.6; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
</button>
<button class="icon-btn" title="알림">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<div class="me"></div>
</div>
</header>
<div class="page">
<div class="crumb"><a href="저장소 목록.html" class="lnk">저장소</a> <span class="sep">/</span> <a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a> <span class="sep">/</span> 새 업무</div>
<div class="pagehead">
<h1>새 업무 작성</h1>
<span class="draft-tag">임시저장됨 · 방금</span>
<div class="actions">
<a class="btn ghost" href="저장소 상세.html">취소</a>
<button class="btn">임시저장</button>
<button class="btn primary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
지시 보내기
</button>
</div>
</div>
<div class="layout">
<!-- ============ MAIN ============ -->
<section class="card main-card">
<input class="title-input" value="3분기 신제품 런칭 캠페인 소재 제작" placeholder="업무 제목을 입력하세요">
<div class="divider"></div>
<!-- 내용 -->
<div class="field">
<div class="field-label"><span class="req">*</span> 업무 내용</div>
<div class="editor">
<div class="toolbar">
<button class="tb" style="font-weight:800">B</button>
<button class="tb" style="font-style:italic;font-family:Georgia,serif">I</button>
<button class="tb" style="text-decoration:underline">U</button>
<span class="tb-sep"></span>
<button class="tb" title="목록"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg></button>
<button class="tb" title="번호 목록"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4l2-3H4"/></svg></button>
<span class="tb-sep"></span>
<button class="tb" title="링크"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg></button>
<button class="tb" title="멘션">@</button>
<button class="tb" title="코드"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></button>
</div>
<div class="editor-body">
<p>9월 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1차 시안 확정 후 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
<p>각 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
</div>
</div>
</div>
<!-- 체크리스트 -->
<div class="field">
<div class="check-head">
<div class="field-label" style="margin-bottom:0">해야 할 일</div>
<div class="progress-wrap">
<div class="progress-bar"><i style="width:40%"></i></div>
<span class="progress-txt">2 / 5 완료</span>
</div>
</div>
<div class="checklist">
<div class="check-item done">
<span class="grip"></span>
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">캠페인 핵심 메시지 3개 시안 작성</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item done">
<span class="grip"></span>
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">레퍼런스 리서치 및 무드보드 정리</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item">
<span class="grip"></span>
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">메인 배너 이미지 2종 (가로/세로) 디자인</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item">
<span class="grip"></span>
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">상세페이지 카피라이팅 및 법무 검토</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="check-item">
<span class="grip"></span>
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span class="txt">최종 산출물 공유 드라이브 업로드</span>
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
</div>
<div class="add-row"><span class="plus">+</span> 항목 추가</div>
</div>
</section>
<!-- ============ SIDEBAR ============ -->
<aside class="side">
<div class="card side-card">
<!-- 담당자 (열린 드롭다운) -->
<div class="side-field">
<div class="side-label"><span class="req">*</span> 담당자 <span style="color:var(--text-3);font-weight:500">· 3명</span></div>
<div class="combo">
<div class="combo-field">
<span class="chip"><span class="avatar av-a"></span>김서연 <span class="x">×</span></span>
<span class="chip"><span class="avatar av-b"></span>박지훈 <span class="x">×</span></span>
<span class="chip"><span class="avatar av-c"></span>이도윤 <span class="x">×</span></span>
<input class="combo-input" placeholder="이름 검색…" value="">
<span class="combo-caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
</div>
</div>
</div>
<div class="card side-card">
<!-- 마감기한 -->
<div class="side-field">
<div class="side-label"><span class="req">*</span> 마감기한</div>
<div class="input">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></span>
2026년 6월 30일 (화) 18:00
</div>
<div class="date-meta"><span class="dot"></span> 마감까지 15일 남음</div>
</div>
<!-- 첨부파일 -->
<div class="side-field">
<div class="side-label">첨부파일 <span style="color:var(--text-3);font-weight:500">· 3개</span></div>
<div class="files">
<div class="file">
<span class="file-ico fi-pdf">PDF</span>
<span class="file-info"><span class="file-name">캠페인_브리프_v2.pdf</span><span class="file-size">2.4 MB</span></span>
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="file">
<span class="file-ico fi-pdf">PDF</span>
<span class="file-info"><span class="file-name">브랜드_가이드라인.pdf</span><span class="file-size">8.1 MB</span></span>
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
<div class="file">
<span class="file-ico fi-zip">ZIP</span>
<span class="file-info"><span class="file-name">레퍼런스_이미지.zip</span><span class="file-size">34.7 MB</span></span>
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
</div>
</div>
<div class="dropzone">파일을 끌어다 놓거나 <b>찾아보기</b></div>
</div>
</div>
<div class="card side-card">
<div class="side-field" style="border:none;padding:0;margin:0">
<div class="side-label">지시자</div>
<div class="issuer">
<span class="avatar av-f"></span>
<span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span>
</div>
<div class="side-foot" style="margin-top:12px">지시를 보내면 담당자 3명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.</div>
</div>
</div>
</aside>
</div>
</div>
</body>
</html>
-430
View File
@@ -1,430 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign · 멤버 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --red: #d6455d; --amber: #b7791f;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
/* btn */
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-name .owner { color: var(--text-3); font-weight: 500; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 6px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
.tab-note { font-size: 12.5px; color: var(--text-3); margin: 12px 0 16px; display: flex; align-items: center; gap: 7px; }
.tab-note svg { width: 14px; height: 14px; color: var(--text-3); flex-shrink: 0; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 240px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.segmented button .n { font-size: 11px; color: var(--text-3); font-weight: 600; }
.segmented button.active .n { color: var(--accent); }
/* member list */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.mem-row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--border); }
.mem-row:first-child { border-top: none; }
.mem-row:hover { background: #fafbfc; }
.mem-av { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0; }
.mem-info { flex: 1; min-width: 0; }
.mem-name { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.you-pill { font-size: 10.5px; font-weight: 700; color: var(--accent); background: var(--accent-weak); border: 1px solid var(--accent-border); border-radius: 20px; padding: 1px 7px; line-height: 1.5; }
.mem-sub { font-size: 12.5px; color: var(--text-3); margin-top: 2px; }
.mem-sub .dot { margin: 0 6px; opacity: .5; }
.mem-tasks { font-size: 12.5px; color: var(--text-2); width: 86px; text-align: right; flex-shrink: 0; white-space: nowrap; }
.mem-tasks b { color: var(--text); }
.mem-tasks.zero { color: var(--text-3); }
/* role control */
.role { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 11px; border: 1px solid var(--border-strong); border-radius: var(--radius); font-size: 13px; font-weight: 600; color: var(--text-2); background: #fff; cursor: pointer; white-space: nowrap; width: 104px; }
.role:hover { background: #f7f8fa; }
.role .role-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.role.admin { color: var(--accent); border-color: var(--accent-border); background: var(--accent-weak); }
.role.admin .role-dot { background: var(--accent); }
.role .caret { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
.role .caret svg { width: 14px; height: 14px; }
.role.fixed { background: #f1f2f4; cursor: not-allowed; color: var(--text-2); border-color: var(--border-strong); }
.role.fixed .role-dot { background: var(--accent); }
.role.fixed .caret { color: var(--text-3); }
.role.fixed .lock { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
.role.fixed .lock svg { width: 13px; height: 13px; }
.mem-remove { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; opacity: 0; }
.mem-row:hover .mem-remove { opacity: 1; }
.mem-remove:hover { background: #f1f2f4; color: var(--red); }
.mem-remove svg { width: 16px; height: 16px; }
.mem-remove.disabled { opacity: 0 !important; pointer-events: none; }
/* invite modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-backdrop.open { display: flex; }
.modal { width: 100%; max-width: 524px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; animation: pop .14s ease-out; }
@keyframes pop { from { transform: translateY(8px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
.modal-head { display: flex; align-items: flex-start; gap: 12px; padding: 22px 24px 16px; }
.modal-head h2 { font-size: 18px; font-weight: 700; letter-spacing: -.3px; }
.modal-head .mh-sub { font-size: 13px; color: var(--text-2); margin-top: 4px; }
.modal-close { margin-left: auto; width: 32px; height: 32px; border: none; background: transparent; color: var(--text-3); border-radius: var(--radius-sm); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; }
.modal-close:hover { background: #f1f2f4; color: var(--text); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { padding: 4px 24px 8px; }
.m-label { font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 8px; display: block; }
.icombo { position: relative; }
.icombo-field { border: 1px solid var(--accent); border-radius: var(--radius); padding: 7px 8px; background: #fff; box-shadow: 0 0 0 3px var(--accent-weak); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ichip { display: inline-flex; align-items: center; gap: 6px; background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 3px 8px 3px 4px; font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
.ichip.ext { background: var(--accent-weak); border-color: var(--accent-border); color: var(--accent); }
.ichip .x { color: var(--text-3); cursor: pointer; font-size: 13px; line-height: 1; }
.ichip .x:hover { color: var(--red); }
.icombo-input { flex: 1; min-width: 120px; border: none; outline: none; font-family: inherit; font-size: 13px; padding: 4px 2px; background: transparent; }
.icombo-input::placeholder { color: var(--text-3); }
.idd { display: none; position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 5; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 6px; }
.icombo .idd.show { display: block; }
.idd-cap { font-size: 11px; color: var(--text-3); font-weight: 600; padding: 5px 8px; }
.idd-item { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-sm); cursor: pointer; }
.idd-item:hover { background: #f5f6f8; }
.idd-item .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
.idd-item .em { font-size: 12px; color: var(--text-3); margin-left: auto; white-space: nowrap; flex-shrink: 0; }
.idd-invite { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; border-top: 1px solid var(--border); margin-top: 4px; }
.idd-invite:hover { background: var(--accent-weak); }
.idd-invite .pl { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.idd-invite .pl svg { width: 15px; height: 15px; }
.idd-invite .t1 { font-size: 13px; font-weight: 600; color: var(--accent); }
.idd-invite .t2 { font-size: 11.5px; color: var(--text-3); }
.m-row { display: flex; gap: 12px; margin-top: 18px; }
.m-role { flex: 0 0 150px; }
.m-grow { flex: 1; }
.select-wrap { position: relative; }
.select-wrap select { appearance: none; -webkit-appearance: none; width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 34px 0 12px; font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text); background: #fff; cursor: pointer; }
.select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.select-wrap .caret { position: absolute; right: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; display: grid; place-items: center; }
.select-wrap .caret svg { width: 15px; height: 15px; }
.m-input { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 13.5px; color: var(--text); background: #fff; }
.m-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.m-input::placeholder { color: var(--text-3); }
.role-hint { font-size: 12px; color: var(--text-3); margin-top: 8px; line-height: 1.5; }
.modal-foot { display: flex; align-items: center; gap: 9px; padding: 16px 24px; border-top: 1px solid var(--border); background: #fafbfc; margin-top: 16px; }
.modal-foot .fnote { font-size: 12px; color: var(--text-3); margin-right: auto; }
.mbtn { height: 38px; padding: 0 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.mbtn:hover { background: #f1f2f4; color: var(--text); }
.mbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.mbtn.primary:hover { background: var(--accent-hover); }
.mbtn svg { width: 15px; height: 15px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 멤버">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캘페인</a>
<span class="sep">/</span>
<b>멤버</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캘페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-f"></span><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span></span>
<span class="mi">멤버 5명</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="#" class="active"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
</nav>
<div class="tab-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
이 저장소의 멤버만 업무 담당자로 지정할 수 있습니다. <b style="color:var(--text-2);font-weight:600;white-space:nowrap">관리자</b>는 업무 지시·멤버 관리가 가능하고, <b style="color:var(--text-2);font-weight:600;white-space:nowrap">멤버</b>는 담당자로 배정되어 업무를 수행합니다.
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="이름·이메일 검색…">
</div>
<div class="segmented">
<button class="active">전체 <span class="n">5</span></button>
<button>관리자 <span class="n">1</span></button>
<button>멤버 <span class="n">4</span></button>
</div>
<button class="btn primary" id="inviteBtn" type="button" style="margin-left:auto;border:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg>
멤버 초대
</button>
</div>
<!-- 멤버 목록 -->
<div class="list">
<!-- 관리자 (소유자·현재 사용자) -->
<div class="mem-row">
<span class="mem-av av-f"></span>
<div class="mem-info">
<div class="mem-name">정태경 <span class="you-pill"></span></div>
<div class="mem-sub">tkjung@acme.co <span class="dot">·</span> 리드</div>
</div>
<div class="mem-tasks"><b>1</b>건 담당</div>
<div class="role fixed admin">
<span class="role-dot"></span>관리자
<span class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
</div>
<button class="mem-remove disabled" title="소유자는 제거할 수 없습니다"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<!-- 멤버 -->
<div class="mem-row">
<span class="mem-av av-a"></span>
<div class="mem-info">
<div class="mem-name">김서연</div>
<div class="mem-sub">sykim@acme.co <span class="dot">·</span> 콘텐츠 디자인</div>
</div>
<div class="mem-tasks"><b>2</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="mem-row">
<span class="mem-av av-b"></span>
<div class="mem-info">
<div class="mem-name">박지훈</div>
<div class="mem-sub">jhpark@acme.co <span class="dot">·</span> 퍼포먼스 마케팅</div>
</div>
<div class="mem-tasks"><b>2</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="mem-row">
<span class="mem-av av-c"></span>
<div class="mem-info">
<div class="mem-name">이도윤</div>
<div class="mem-sub">dylee@acme.co <span class="dot">·</span> 카피라이터</div>
</div>
<div class="mem-tasks"><b>2</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="mem-row">
<span class="mem-av av-d"></span>
<div class="mem-info">
<div class="mem-name">최유진</div>
<div class="mem-sub">yjchoi@acme.co <span class="dot">·</span> UX 디자인</div>
</div>
<div class="mem-tasks"><b>1</b>건 담당</div>
<div class="role">
<span class="role-dot"></span>멤버
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
</div>
</div>
<!-- 멤버 초대 모달 -->
<div class="modal-backdrop" id="inviteModal">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div>
<h2>멤버 초대</h2>
<div class="mh-sub"><b style="color:var(--text-2);font-weight:600">3분기 신제품 런칭 캠페인</b> 저장소에 멤버를 추가합니다.</div>
</div>
<button class="modal-close" id="inviteClose" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="modal-body">
<label class="m-label">초대할 사람</label>
<div class="icombo">
<div class="icombo-field">
<span class="ichip"><span class="avatar av-f" style="width:18px;height:18px;font-size:9px"></span>한지민 <span class="x">×</span></span>
<span class="ichip ext"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg>partner@vendor.com <span class="x">×</span></span>
<input class="icombo-input" placeholder="이름 또는 이메일 입력…">
</div>
<div class="idd">
<div class="idd-cap">조직 멤버 · 이 저장소에 없는 사람</div>
<div class="idd-item">
<span class="avatar av-b" style="width:26px;height:26px;font-size:11px"></span>
<span class="nm">오세훈</span><span class="em">sehoon.oh@acme.co</span>
</div>
<div class="idd-item">
<span class="avatar av-c" style="width:26px;height:26px;font-size:11px"></span>
<span class="nm">강민서</span><span class="em">minseo.kang@acme.co</span>
</div>
<div class="idd-item">
<span class="avatar av-e" style="width:26px;height:26px;font-size:11px"></span>
<span class="nm">윤하늘</span><span class="em">haneul.yoon@acme.co</span>
</div>
<div class="idd-invite">
<span class="pl"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg></span>
<span><div class="t1">이메일로 외부 초대</div><div class="t2">조직에 없는 사람도 이메일로 초대할 수 있어요</div></span>
</div>
</div>
</div>
<div class="m-row">
<div class="m-role">
<label class="m-label">역할</label>
<div class="select-wrap">
<select>
<option>멤버</option>
<option>관리자</option>
</select>
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
</div>
<div class="m-grow">
<label class="m-label">초대 메시지 <span style="color:var(--text-3);font-weight:500">(선택)</span></label>
<input class="m-input" type="text" placeholder="함께 캠페인 준비해요!">
</div>
</div>
<div class="role-hint"><b style="color:var(--text-2);font-weight:600">멤버</b>는 담당자로 배정되어 업무를 수행하고, <b style="color:var(--text-2);font-weight:600">관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.</div>
</div>
<div class="modal-foot">
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
<button class="mbtn" id="inviteCancel" type="button">취소</button>
<button class="mbtn primary" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
초대 보내기 (2)
</button>
</div>
</div>
</div>
<script>
var modal = document.getElementById('inviteModal');
function openModal() { modal.classList.add('open'); }
function closeModal() { modal.classList.remove('open'); }
document.getElementById('inviteBtn').addEventListener('click', openModal);
document.getElementById('inviteClose').addEventListener('click', closeModal);
document.getElementById('inviteCancel').addEventListener('click', closeModal);
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
var iInput = document.querySelector('.icombo-input');
var idd = document.querySelector('.idd');
iInput.addEventListener('focus', function () { idd.classList.add('show'); });
iInput.addEventListener('blur', function () { setTimeout(function () { idd.classList.remove('show'); }, 120); });
</script>
</body>
</html>
-301
View File
@@ -1,301 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>저장소 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --amber: #b7791f;
--radius: 6px; --radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 0; white-space: nowrap; }
.crumb b { color: var(--text-2); font-weight: 600; }
.pagehead { display: flex; align-items: center; gap: 11px; padding: 9px 0 4px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
.count-pill { font-size: 13px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 2px 11px; }
.gitea-badge { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8; padding: 4px 10px; border-radius: 20px; line-height: 1; white-space: nowrap; }
.gitea-badge svg { width: 13px; height: 13px; }
.lede { color: var(--text-2); font-size: 13.5px; margin: 4px 0 18px; }
/* btn */
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 280px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 13px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* list */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.repo-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.repo-row:first-child { border-top: none; }
.repo-row:hover { background: #fafbfc; }
.repo-ico { width: 40px; height: 40px; border-radius: 9px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; align-self: flex-start; margin-top: 2px; }
.repo-ico svg { width: 21px; height: 21px; }
.repo-main { flex: 1; min-width: 0; }
.repo-title { display: flex; align-items: center; column-gap: 9px; row-gap: 2px; flex-wrap: wrap; }
.repo-name { font-size: 15px; font-weight: 600; color: var(--text); white-space: nowrap; }
.repo-name .owner { color: var(--text-3); font-weight: 500; }
.repo-slug { order: 2; flex-basis: 100%; font-size: 12px; font-weight: 500; color: var(--text-3); margin-top: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.repo-row:hover .repo-name { color: var(--accent); }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.vis.public { color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8; }
.repo-desc { font-size: 13px; color: var(--text-2); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; }
.repo-meta { display: flex; align-items: center; gap: 15px; margin-top: 10px; font-size: 12px; color: var(--text-3); }
.repo-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.repo-meta .mi svg { width: 13px; height: 13px; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
.repo-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: 168px; flex-shrink: 0; }
.stat-top { font-size: 12.5px; color: var(--text-2); font-weight: 600; white-space: nowrap; }
.stat-top b { color: var(--text); }
.pbar { width: 156px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
.pbar > i { display: block; height: 100%; border-radius: 4px; background: var(--green); }
.pbar > i.mid { background: var(--accent); }
.pbar > i.low { background: var(--amber); }
.stat-pct { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
.chev { color: var(--text-3); flex-shrink: 0; display: grid; place-items: center; }
.chev svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 목록">
<div class="crumb"><b>저장소</b></div>
<div class="pagehead">
<h1>저장소</h1>
<span class="count-pill">6</span>
<span class="gitea-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg> Gitea 연동됨</span>
</div>
<p class="lede"><b style="color:var(--text-2)">marketing-team</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게 전달하세요.</p>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="저장소 검색…">
</div>
<div class="segmented">
<button class="active">전체</button>
<button>비공개</button>
<button>공개</button>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
최근 업데이트순
</div>
<a class="btn primary" href="저장소 생성.html">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>
새 저장소
</a>
</div>
<div class="list">
<!-- 1 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">3분기 신제품 런칭 캘페인</span>
<span class="repo-slug">marketing-team/q3-launch-campaign</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-more">+2</span></span>
<span class="mi">2시간 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>8</b> / 12 업무</div>
<div class="pbar"><i class="mid" style="width:67%"></i></div>
<div class="stat-pct">67% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 2 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">브랜드 리뉴얼 2026</span>
<span class="repo-slug">marketing-team/brand-refresh-2026</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">브랜드 아이덴티티 리뉴얼 및 가이드라인 개편 프로젝트</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-d"></span><span class="avatar av-e"></span><span class="avatar av-f"></span><span class="avatar av-more">+1</span></span>
<span class="mi">어제 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>5</b> / 9 업무</div>
<div class="pbar"><i class="mid" style="width:56%"></i></div>
<div class="stat-pct">56% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 3 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">월간 뉴스레터 운영</span>
<span class="repo-slug">marketing-team/monthly-newsletter</span>
<span class="vis public"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>공개</span>
</div>
<div class="repo-desc">월간 뉴스레터 기획 · 콘텐츠 작성 · 발송 정기 운영</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-c"></span><span class="avatar av-b"></span><span class="avatar av-a"></span></span>
<span class="mi">3일 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>14</b> / 14 업무</div>
<div class="pbar"><i style="width:100%"></i></div>
<div class="stat-pct">100% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 4 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">SEO 콘텐츠 허브</span>
<span class="repo-slug">marketing-team/seo-content-hub</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">검색 유입 강화를 위한 SEO 콘텐츠 기획 및 제작 허브</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-b"></span><span class="avatar av-f"></span><span class="avatar av-c"></span><span class="avatar av-more">+3</span></span>
<span class="mi">5시간 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>2</b> / 11 업무</div>
<div class="pbar"><i class="low" style="width:18%"></i></div>
<div class="stat-pct">18% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 5 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">사내 행사 핵드북</span>
<span class="repo-slug">marketing-team/event-handbook</span>
<span class="vis public"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>공개</span>
</div>
<div class="repo-desc">사내 행사 운영 가이드 및 준비 체크리스트 모음</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-e"></span><span class="avatar av-a"></span></span>
<span class="mi">1주 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>0</b> / 4 업무</div>
<div class="pbar"><i class="low" style="width:0%"></i></div>
<div class="stat-pct">시작 전</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
<!-- 6 -->
<a class="repo-row" href="저장소 상세.html">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">디자인 시스템 에셋</span>
<span class="repo-slug">marketing-team/design-system-assets</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="repo-desc">공통 디자인 시스템 에셋 및 컴포넌트 관리</div>
<div class="repo-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>develop</span>
<span class="mi avstack"><span class="avatar av-a"></span><span class="avatar av-d"></span><span class="avatar av-c"></span><span class="avatar av-more">+1</span></span>
<span class="mi">30분 전 업데이트</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top"><b>9</b> / 10 업무</div>
<div class="pbar"><i style="width:90%"></i></div>
<div class="stat-pct">90% 완료</div>
</div>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</a>
</div>
</div>
</body>
</html>
-350
View File
@@ -1,350 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --red-weak: #fdecef; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
/* btn */
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-name .owner { color: var(--text-3); font-weight: 500; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* progress strip */
.prog-strip { display: flex; align-items: center; gap: 16px; margin-top: 16px; padding-top: 15px; border-top: 1px solid var(--border); }
.prog-strip .pbar { flex: 1; height: 8px; border-radius: 5px; background: #eceef1; overflow: hidden; max-width: 360px; }
.prog-strip .pbar > i { display: block; height: 100%; border-radius: 5px; background: var(--green); }
.prog-txt { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; }
.prog-txt span { color: var(--text-3); font-weight: 500; }
.prog-warn { font-size: 12.5px; font-weight: 600; color: var(--red); display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.prog-warn svg { width: 14px; height: 14px; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 18px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 240px; }
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.search svg { width: 16px; height: 16px; color: var(--text-3); }
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
.search input::placeholder { color: var(--text-3); }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.segmented button .n { font-size: 11px; color: var(--text-3); font-weight: 600; }
.segmented button.active .n { color: var(--accent); }
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
/* task list */
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.task-row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
.task-row:first-child { border-top: none; }
.task-row:hover { background: #fafbfc; }
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); }
.task-row.is-done .task-title { color: var(--text-2); }
.task-meta { display: flex; align-items: center; gap: 13px; margin-top: 3px; font-size: 12px; color: var(--text-3); }
.task-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.task-meta .mi svg { width: 13px; height: 13px; }
.task-meta .mi.overdue { color: var(--red); font-weight: 600; }
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 56px; text-align: center; }
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
.st-badge.todo { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.chev { color: var(--text-3); display: grid; place-items: center; }
.chev svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 상세">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<b>3분기 신제품 런칭 캘페인</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캘페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span><span class="avatar av-more">+1</span></span>
<span class="mi">2시간 전 업데이트</span>
</div>
<div class="prog-strip">
<span class="prog-txt">8 <span>/ 12 업무 완료</span> · 67%</span>
<div class="pbar"><i style="width:67%"></i></div>
<span class="prog-warn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>지연 1건</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="#" class="active"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
</nav>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<input type="text" placeholder="업무 검색…">
</div>
<div class="segmented">
<button class="active">전체 <span class="n">12</span></button>
<button>진행 중 <span class="n">2</span></button>
<button>대기 <span class="n">2</span></button>
<button>완료 <span class="n">8</span></button>
</div>
<div class="sort">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
마감 임박순
</div>
</div>
<!-- 업무 목록 -->
<div class="list">
<a class="task-row" href="업무 상세.html">
<span class="st-dot prog"></span>
<span class="task-main">
<span class="task-title">법무 검토 요청 및 회신 반영 <span class="tid">#11</span></span>
<span class="task-meta">
<span class="mi overdue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>2일 지남</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/3</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-c"></span></span>
<span class="st-badge prog">진행 중</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot review"></span>
<span class="task-main">
<span class="task-title">메인 배너 이미지 2종 (가로/세로) 디자인 <span class="tid">#9</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-3</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/3</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>2</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-b"></span><span class="avatar av-d"></span></span>
<span class="st-badge review">승인 대기</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title">상세페이지 카피라이팅 <span class="tid">#8</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-5</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>0/3</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-c"></span></span>
<span class="st-badge todo">대기</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row" href="업무 상세.html">
<span class="st-dot todo"></span>
<span class="task-main">
<span class="task-title">SNS 콘텐츠 1차 시안 <span class="tid">#7</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-8</span>
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>0/4</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-e"></span></span>
<span class="st-badge todo">대기</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">캠페인 핵심 메시지 시안 작성 <span class="tid">#6</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>3/3</span>
<span class="mi">6월 9일 완료</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-a"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">레퍼런스 리서치 및 무드보드 정리 <span class="tid">#5</span></span>
<span class="task-meta"><span class="mi">6월 6일 완료</span></span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-a"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">퍼포먼스 광고 소재 세팅 <span class="tid">#4</span></span>
<span class="task-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>2/2</span>
<span class="mi">6월 5일 완료</span>
</span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-b"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">타깃 오디언스 정의 및 페르소나 정리 <span class="tid">#3</span></span>
<span class="task-meta"><span class="mi">6월 4일 완료</span></span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-b"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
<a class="task-row is-done" href="업무 상세.html">
<span class="st-dot done"></span>
<span class="task-main">
<span class="task-title">킥오프 미팅 및 일정 수립 <span class="tid">#1</span></span>
<span class="task-meta"><span class="mi">6월 2일 완료</span></span>
</span>
<span class="task-right">
<span class="avstack"><span class="avatar av-f"></span></span>
<span class="st-badge done">완료</span>
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
</span>
</a>
</div>
</div>
</body>
</html>
-289
View File
@@ -1,289 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 저장소 만들기 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7;
--panel: #ffffff;
--border: #e6e8ec;
--border-strong: #d7dae0;
--text: #16181d;
--text-2: #5b626e;
--text-3: #9298a3;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-weak: #eef0fe;
--accent-border: #c7ccfb;
--green: #1f9d57;
--green-weak: #e8f6ee;
--amber: #b7791f;
--radius: 6px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
--shadow-md: 0 4px 16px rgba(20,24,33,.10), 0 1px 3px rgba(20,24,33,.06);
}
html, body {
background: var(--bg); color: var(--text);
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
body { min-height: 100vh; }
/* ---------- Top app bar ---------- */
.topbar {
height: 52px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 28px; padding: 0 20px;
position: sticky; top: 0; z-index: 50;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo {
width: 26px; height: 26px; border-radius: 7px;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800;
box-shadow: 0 2px 6px rgba(79,70,229,.35);
}
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a {
color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500;
padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap;
}
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* ---------- Page ---------- */
.page { max-width: 760px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 0; white-space: nowrap; }
.crumb b { color: var(--text-2); font-weight: 600; }
.crumb .sep { opacity: .5; }
.pagehead { display: flex; align-items: center; gap: 12px; padding: 9px 0 6px; }
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; flex-shrink: 0; }
.gitea-badge {
display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600;
color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8;
padding: 4px 10px; border-radius: 20px; line-height: 1; white-space: nowrap;
}
.gitea-badge svg { width: 13px; height: 13px; }
.lede { color: var(--text-2); font-size: 13.5px; margin-bottom: 20px; }
/* ---------- Card / form ---------- */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
.fblock { padding: 22px 24px; border-top: 1px solid var(--border); }
.fblock:first-child { border-top: none; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; display: flex; align-items: center; gap: 7px; }
.flabel .req { color: var(--accent); font-weight: 700; }
.fhint { font-size: 12.5px; color: var(--text-3); margin-bottom: 11px; line-height: 1.5; }
.fhint.below { margin-top: 9px; margin-bottom: 0; }
.tinput { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.tinput::placeholder { color: var(--text-3); }
textarea.tarea { min-height: 96px; padding: 10px 12px; line-height: 1.6; resize: vertical; height: auto; }
/* owner / repo name */
.repo-id { display: flex; }
.owner-fixed {
display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 13px;
background: #f1f2f4; border: 1px solid var(--border-strong); border-right: none;
border-radius: var(--radius) 0 0 var(--radius); color: var(--text-2);
font-weight: 600; font-size: 14px; cursor: not-allowed; white-space: nowrap; user-select: none;
}
.owner-fixed .lock { color: var(--text-3); display: grid; place-items: center; }
.owner-avatar { width: 20px; height: 20px; border-radius: 5px; background: #0ea5a3; color: #fff; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; }
.name-wrap { flex: 1; height: 40px; display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 0 var(--radius) var(--radius) 0; background: #fff; padding: 0 12px; min-width: 0; }
.name-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); position: relative; z-index: 1; }
.name-wrap .slash { color: var(--text-3); margin-right: 7px; font-weight: 500; font-size: 15px; }
.name-wrap input { flex: 1; min-width: 0; border: none; outline: none; height: 100%; font-family: inherit; font-size: 14px; background: transparent; }
.name-wrap input::placeholder { color: var(--text-3); }
.avail { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--green); margin-top: 9px; }
.avail svg { width: 14px; height: 14px; }
.fixed-pill { font-size: 11px; font-weight: 600; color: var(--text-2); background: #e9ebef; border: 1px solid var(--border-strong); padding: 1px 7px; border-radius: 20px; line-height: 1.5; white-space: nowrap; }
/* visibility radio cards */
.radio-group { display: flex; flex-direction: column; gap: 10px; }
.radio-card { display: flex; align-items: flex-start; gap: 13px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer; background: #fff; transition: border-color .12s, background .12s; }
.radio-card:hover { border-color: var(--accent-border); }
.radio-card.sel { border-color: var(--accent); background: var(--accent-weak); }
.rc-ico { width: 34px; height: 34px; border-radius: 8px; background: #f1f2f4; color: var(--text-2); display: grid; place-items: center; flex-shrink: 0; }
.rc-ico svg { width: 18px; height: 18px; }
.radio-card.sel .rc-ico { background: #fff; color: var(--accent); }
.rc-body { flex: 1; }
.rc-title { font-size: 14px; font-weight: 600; color: var(--text); }
.rc-desc { font-size: 12.5px; color: var(--text-2); margin-top: 2px; line-height: 1.45; }
.rc-radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); flex-shrink: 0; margin-top: 8px; display: grid; place-items: center; background: #fff; }
.radio-card.sel .rc-radio { border-color: var(--accent); }
.radio-card.sel .rc-radio::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
/* select */
.select-wrap { position: relative; }
.select-wrap select {
appearance: none; -webkit-appearance: none; width: 100%; height: 40px;
border: 1px solid var(--border-strong); border-radius: var(--radius);
padding: 0 38px 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; cursor: pointer;
}
.select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.select-wrap .caret { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; display: grid; place-items: center; }
.select-wrap .caret svg { width: 16px; height: 16px; }
/* input with icon */
.input-ico { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius); background: #fff; padding: 0 12px; gap: 9px; }
.input-ico:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.input-ico .ico { color: var(--text-3); display: grid; place-items: center; }
.input-ico .ico svg { width: 16px; height: 16px; }
.input-ico input { flex: 1; border: none; outline: none; height: 40px; font-family: inherit; font-size: 14px; background: transparent; color: var(--text); }
/* footer */
.form-footer { display: flex; align-items: center; gap: 9px; padding: 16px 24px; border-top: 1px solid var(--border); background: #fafbfc; border-radius: 0 0 10px 10px; }
.form-footer .note { color: var(--text-3); font-size: 12.5px; margin-right: auto; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
.form-footer .note .step { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); font-size: 10px; font-weight: 700; }
.btn { height: 36px; padding: 0 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: #eceef1; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
</button>
<button class="icon-btn" title="알림">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<div class="me"></div>
</div>
</header>
<div class="page">
<div class="crumb"><a href="저장소 목록.html" class="lnk">저장소</a> <span class="sep">/</span> 새 저장소</div>
<div class="pagehead">
<h1>새 저장소 만들기</h1>
<span class="gitea-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg>
Gitea 연동됨
</span>
</div>
<p class="lede">Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고 담당자에게 전달할 수 있습니다.</p>
<div class="card">
<!-- 소유자 / 저장소 이름 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 소유자 / 저장소 이름 <span class="fixed-pill">소유자 고정</span></div>
<div class="fhint">소유자는 현재 조직으로 고정되어 변경할 수 없습니다.</div>
<div class="repo-id">
<div class="owner-fixed" title="소유자는 변경할 수 없습니다">
<span class="owner-avatar">M</span>
marketing-team
<span class="lock"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
</div>
<div class="name-wrap">
<span class="slash">/</span>
<input type="text" value="q3-launch-campaign" placeholder="repository-name" spellcheck="false">
</div>
</div>
<div class="avail">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
사용 가능한 이름입니다
</div>
<div class="fhint below">영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다.</div>
</div>
<!-- 표시 제목 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 표시 제목</div>
<div class="fhint">Relay 목록과 화면에 표시되는 이름입니다. 한글로 입력하면 저장소를 알아보기 쉽습니다. 위 Gitea 저장소 이름(영문)과는 별개이며 언제든 바꿀 수 있습니다.</div>
<input type="text" class="tinput" value="3분기 신제품 런칭 캠페인" placeholder="예: 3분기 신제품 런칭 캠페인" maxlength="40">
</div>
<!-- 공개 범위 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 공개 범위</div>
<div class="fhint">저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.</div>
<div class="radio-group">
<label class="radio-card sel">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="rc-body">
<span class="rc-title">비공개</span>
<span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
</span>
<span class="rc-radio"></span>
</label>
<label class="radio-card">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg></span>
<span class="rc-body">
<span class="rc-title">공개</span>
<span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span>
</span>
<span class="rc-radio"></span>
</label>
</div>
</div>
<!-- 프로젝트 설명 -->
<div class="fblock">
<div class="flabel">프로젝트 설명</div>
<div class="fhint">저장소 목록과 상단에 표시됩니다. (선택)</div>
<textarea class="tinput tarea" placeholder="이 프로젝트의 목적과 범위를 간단히 적어주세요.">3분기 신제품(라인 클렌저) 런칭 관련 캠페인 소재 제작 및 검수 업무를 관리하는 저장소입니다.</textarea>
</div>
<!-- 템플릿 -->
<div class="fblock">
<div class="flabel">템플릿</div>
<div class="fhint">선택한 템플릿의 폴더 구조와 기본 업무가 새 저장소에 복제됩니다.</div>
<div class="select-wrap">
<select>
<option>없음 (빈 저장소)</option>
<option>기본 업무 템플릿</option>
<option>디자인 프로젝트 템플릿</option>
<option>개발 프로젝트 템플릿</option>
</select>
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
</div>
</div>
<!-- 메인 브랜치 -->
<div class="fblock">
<div class="flabel"><span class="req">*</span> 메인 브랜치</div>
<div class="fhint">저장소의 기본 브랜치 이름입니다.</div>
<div class="input-ico" style="max-width:280px">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
<input type="text" value="main" spellcheck="false">
</div>
</div>
<!-- footer -->
<div class="form-footer">
<span class="note"><span class="step">1</span> 저장소 생성 → 업무 생성 → 전달</span>
<a class="btn ghost" href="저장소 목록.html">취소</a>
<a class="btn primary" href="저장소 상세.html">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>
저장소 생성
</a>
</div>
</div>
</div>
</body>
</html>
-275
View File
@@ -1,275 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign · 설정 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-f { background: #d0982a; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 22px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
/* settings layout */
.settings { max-width: 720px; }
.sec-title { font-size: 15px; font-weight: 700; letter-spacing: -.2px; margin: 0 0 12px; }
.sec-title.danger { color: var(--red); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); margin-bottom: 30px; }
.fblock { padding: 20px 22px; border-top: 1px solid var(--border); }
.fblock:first-child { border-top: none; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 5px; display: flex; align-items: center; gap: 7px; }
.fhint { font-size: 12.5px; color: var(--text-3); margin-bottom: 11px; line-height: 1.5; }
.fixed-pill { font-size: 11px; font-weight: 600; color: var(--text-2); background: #e9ebef; border: 1px solid var(--border-strong); padding: 1px 7px; border-radius: 20px; line-height: 1.5; white-space: nowrap; }
.tinput { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
textarea.tarea { min-height: 84px; padding: 10px 12px; line-height: 1.6; resize: vertical; height: auto; }
.readonly { display: flex; align-items: center; gap: 9px; height: 40px; padding: 0 13px; background: #f5f6f8; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-2); font-size: 14px; font-weight: 500; cursor: not-allowed; }
.readonly .owner-avatar { width: 20px; height: 20px; border-radius: 5px; background: #0ea5a3; color: #fff; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; }
.readonly .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); font-weight: 600; }
.readonly .lock { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
.readonly .lock svg { width: 14px; height: 14px; }
/* visibility radio cards */
.radio-group { display: flex; flex-direction: column; gap: 10px; }
.radio-card { display: flex; align-items: flex-start; gap: 13px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer; background: #fff; }
.radio-card:hover { border-color: var(--accent-border); }
.radio-card.sel { border-color: var(--accent); background: var(--accent-weak); }
.rc-ico { width: 32px; height: 32px; border-radius: 8px; background: #f1f2f4; color: var(--text-2); display: grid; place-items: center; flex-shrink: 0; }
.rc-ico svg { width: 17px; height: 17px; }
.radio-card.sel .rc-ico { background: #fff; color: var(--accent); }
.rc-body { flex: 1; }
.rc-title { font-size: 14px; font-weight: 600; color: var(--text); }
.rc-desc { font-size: 12.5px; color: var(--text-2); margin-top: 2px; line-height: 1.45; }
.rc-radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); flex-shrink: 0; margin-top: 7px; display: grid; place-items: center; background: #fff; }
.radio-card.sel .rc-radio { border-color: var(--accent); }
.radio-card.sel .rc-radio::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
.input-ico { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius); background: #fff; padding: 0 12px; gap: 9px; max-width: 300px; }
.input-ico:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.input-ico .ico { color: var(--text-3); display: grid; place-items: center; }
.input-ico .ico svg { width: 16px; height: 16px; }
.input-ico input { flex: 1; border: none; outline: none; height: 40px; font-family: inherit; font-size: 14px; background: transparent; color: var(--text); }
.card-foot { display: flex; justify-content: flex-end; padding: 14px 22px; border-top: 1px solid var(--border); background: #fafbfc; border-radius: 0 0 10px 10px; }
/* danger zone */
.danger-card { background: #fff; border: 1px solid var(--red-border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
.danger-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid #f6dde2; }
.danger-row:first-child { border-top: none; }
.dr-main { flex: 1; min-width: 0; }
.dr-title { font-size: 14px; font-weight: 600; color: var(--text); }
.dr-desc { font-size: 12.5px; color: var(--text-2); margin-top: 3px; line-height: 1.5; }
.btn-danger { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13px; font-weight: 600; border: 1px solid var(--red-border); background: #fff; color: var(--red); cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.btn-danger:hover { background: var(--red-weak); }
.btn-danger.solid { background: var(--red); border-color: var(--red); color: #fff; }
.btn-danger.solid:hover { background: #c23a51; }
.admin-note { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-3); margin-bottom: 18px; }
.admin-note svg { width: 14px; height: 14px; flex-shrink: 0; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 설정">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<b>설정</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캠페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-f"></span><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span></span>
<span class="mi">멤버 5명</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
<a href="#" class="active"><span class="tb-label">설정</span></a>
</nav>
<div class="settings">
<div class="admin-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
저장소 설정은 <b style="color:var(--text-2);font-weight:600;white-space:nowrap">관리자</b>만 변경할 수 있습니다.
</div>
<!-- ===== 일반 ===== -->
<h2 class="sec-title">일반</h2>
<div class="card">
<div class="fblock">
<div class="flabel">표시 제목</div>
<div class="fhint">Relay 목록과 화면에 표시되는 이름입니다. (한글 가능)</div>
<input class="tinput" type="text" value="3분기 신제품 런칭 캠페인" maxlength="40">
</div>
<div class="fblock">
<div class="flabel">소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span></div>
<div class="fhint">Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.</div>
<div class="readonly">
<span class="owner-avatar">M</span> marketing-team <span style="color:var(--text-3)">/</span> <span class="mono">q3-launch-campaign</span>
<span class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
</div>
</div>
<div class="fblock">
<div class="flabel">프로젝트 설명</div>
<textarea class="tinput tarea">3분기 신제품(라인 클렌저) 런칭 관련 캠페인 소재 제작 및 검수 업무를 관리하는 저장소입니다.</textarea>
</div>
<div class="fblock">
<div class="flabel">공개 범위</div>
<div class="fhint">저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.</div>
<div class="radio-group">
<label class="radio-card sel">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="rc-body"><span class="rc-title">비공개</span><span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span></span>
<span class="rc-radio"></span>
</label>
<label class="radio-card">
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg></span>
<span class="rc-body"><span class="rc-title">공개</span><span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span></span>
<span class="rc-radio"></span>
</label>
</div>
</div>
<div class="fblock">
<div class="flabel">메인 브랜치</div>
<div class="fhint">저장소의 기본 브랜치 이름입니다.</div>
<div class="input-ico">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
<input type="text" value="main" spellcheck="false">
</div>
</div>
<div class="card-foot">
<button class="btn primary" type="button">변경 저장</button>
</div>
</div>
<!-- ===== 위험 구역 ===== -->
<h2 class="sec-title danger">위험 구역</h2>
<div class="danger-card">
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">Gitea 저장소 이름 변경</div>
<div class="dr-desc">영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 수 있습니다.</div>
</div>
<button class="btn-danger" type="button">이름 변경</button>
</div>
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">저장소 삭제</div>
<div class="dr-desc">저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 수 없습니다.</div>
</div>
<button class="btn-danger solid" type="button">저장소 삭제</button>
</div>
</div>
</div>
</div>
</body>
</html>
-286
View File
@@ -1,286 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>q3-launch-campaign · 활동 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
--red: #d6455d; --amber: #b7791f; --amber-weak: #fdf4e3;
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
}
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
/* topbar */
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
.topnav { display: flex; align-items: center; gap: 2px; }
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
.topnav a:hover { background: #f1f2f4; color: var(--text); }
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
/* page */
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
.crumb .lnk:hover { color: var(--accent); }
.crumb .sep { opacity: .5; }
.crumb b { color: var(--text-2); font-weight: 600; }
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
.btn:hover { background: #f7f8fa; color: var(--text); }
.btn svg { width: 15px; height: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* repo header */
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
.repo-ico svg { width: 24px; height: 24px; }
.rh-main { flex: 1; min-width: 0; }
.rh-title { display: flex; align-items: center; gap: 10px; }
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
.vis svg { width: 11px; height: 11px; }
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.rh-meta .mi svg { width: 13px; height: 13px; }
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.avstack { display: flex; align-items: center; }
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
.avstack .avatar:first-child { margin-left: 0; }
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* tabs */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 18px; }
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs a .tb-label { line-height: 1; }
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 13px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
.mfilter { margin-left: auto; display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
.mfilter svg { width: 15px; height: 15px; color: var(--text-3); }
/* activity feed */
.feed { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); padding: 6px 20px 18px; }
.day { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .2px; padding: 18px 0 6px; }
.act { display: flex; align-items: center; gap: 13px; padding: 9px 0; position: relative; }
.act-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; flex-shrink: 0; z-index: 1; }
.act-ico svg { width: 16px; height: 16px; }
.act-ico.task { background: var(--blue-weak); color: var(--blue); }
.act-ico.member { background: var(--green-weak); color: var(--green); }
.act-ico.setting { background: var(--amber-weak); color: var(--amber); }
.act-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.act-av { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
.act-text { font-size: 13.5px; color: var(--text-2); line-height: 1.5; }
.act-text b { color: var(--text); font-weight: 600; }
.act-text .tgt { color: var(--accent); font-weight: 600; text-decoration: none; }
.act-text .tgt:hover { text-decoration: underline; }
.act-text .st { font-weight: 600; }
.act-text .st.prog { color: var(--blue); } .act-text .st.done { color: var(--green); }
.act-time { font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; margin-left: 12px; }
/* vertical connector line */
.feed-inner { position: relative; }
.act::before { content: ""; position: absolute; left: 15.5px; top: 50%; bottom: -9px; width: 1.5px; background: var(--border); z-index: 0; }
.act.last-in-group::before { display: none; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="logo">R</div>Relay</div>
<nav class="topnav">
<a href="내 업무.html">내 업무</a>
<a href="저장소 목록.html" class="active">저장소</a>
</nav>
<div class="topbar-right">
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
<div class="me"></div>
</div>
</header>
<div class="page" data-screen-label="저장소 활동">
<div class="crumb">
<a href="저장소 목록.html" class="lnk">저장소</a>
<span class="sep">/</span>
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
<span class="sep">/</span>
<b>활동</b>
</div>
<!-- 저장소 헤더 -->
<div class="repo-head">
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">3분기 신제품 런칭 캠페인</span>
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
</div>
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
<div class="rh-meta">
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
<span class="mi avstack"><span class="avatar av-f"></span><span class="avatar av-a"></span><span class="avatar av-b"></span><span class="avatar av-c"></span><span class="avatar av-d"></span></span>
<span class="mi">2시간 전 업데이트</span>
</div>
</div>
<div class="rh-actions">
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
</div>
</div>
<!-- 서브탭 -->
<nav class="tabs">
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
<a href="#" class="active"><span class="tb-label">활동</span></a>
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
</nav>
<!-- 툴바 -->
<div class="toolbar">
<div class="segmented">
<button class="active">전체</button>
<button>업무</button>
<button>멤버</button>
<button>설정</button>
</div>
<div class="mfilter">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
모든 멤버
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<!-- 활동 피드 -->
<div class="feed">
<div class="feed-inner">
<div class="day">오늘</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-c"></span>
<span class="act-text"><b>이도윤</b>님이 <a class="tgt" href="#">법무 검토 요청 및 회신 반영</a> 업무를 <span class="st prog">진행 중</span>으로 변경했습니다</span>
</div>
<span class="act-time">2시간 전</span>
</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-b"></span>
<span class="act-text"><b>박지훈</b>님이 <a class="tgt" href="#">메인 배너 이미지 2종 디자인</a>의 체크리스트 ‘가로형 메인 배너’를 완료했습니다</span>
</div>
<span class="act-time">3시간 전</span>
</div>
<div class="act">
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 <b>한지민</b>님을 멤버로 초대했습니다</span>
</div>
<span class="act-time">5시간 전</span>
</div>
<div class="act last-in-group">
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/></svg></span>
<div class="act-main">
<span class="act-av av-d"></span>
<span class="act-text"><b>한지민</b>님이 멤버로 합류했습니다</span>
</div>
<span class="act-time">5시간 전</span>
</div>
<div class="day">어제</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-d"></span>
<span class="act-text"><b>최유진</b>님이 <a class="tgt" href="#">메인 비주얼 컨셉 확정</a> 업무를 <span class="st done">완료</span>했습니다</span>
</div>
<span class="act-time">어제 18:40</span>
</div>
<div class="act last-in-group">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 <a class="tgt" href="#">SNS 콘텐츠 1차 시안</a> 업무를 지시했습니다 <span style="color:var(--text-3)">· 담당 정민호</span></span>
</div>
<span class="act-time">어제 14:10</span>
</div>
<div class="day">이번 주</div>
<div class="act">
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 저장소 공개 범위를 <b>비공개</b>로 변경했습니다</span>
</div>
<span class="act-time">6월 11일</span>
</div>
<div class="act">
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/></svg></span>
<div class="act-main">
<span class="act-av av-b"></span>
<span class="act-text"><b>박지훈</b>님의 역할이 <b>멤버</b>로 설정되었습니다</span>
</div>
<span class="act-time">6월 11일</span>
</div>
<div class="act">
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 표시 제목을 ‘3분기 신제품 런칭 캠페인’으로 변경했습니다</span>
</div>
<span class="act-time">6월 10일</span>
</div>
<div class="act">
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<div class="act-main">
<span class="act-av av-a"></span>
<span class="act-text"><b>김서연</b>님이 <a class="tgt" href="#">캠페인 핵심 메시지 시안 작성</a> 업무를 <span class="st done">완료</span>했습니다</span>
</div>
<span class="act-time">6월 9일</span>
</div>
<div class="act last-in-group">
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></span>
<div class="act-main">
<span class="act-av av-f"></span>
<span class="act-text"><b>정태경</b>님이 저장소를 생성했습니다</span>
</div>
<span class="act-time">6월 2일</span>
</div>
</div>
</div>
</div>
</body>
</html>
-222
View File
@@ -1,222 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입 — Relay</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
--green: #1f9d57; --kakao: #FEE500; --kakao-text: #191600;
--radius: 8px; --radius-sm: 5px;
}
html, body { height: 100%; }
body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
.auth { display: flex; min-height: 100vh; }
/* brand panel */
.brand-panel { flex: 0 0 46%; max-width: 620px; position: relative; overflow: hidden; background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%); color: #fff; padding: 44px 48px; display: flex; flex-direction: column; }
.brand-panel .blob { position: absolute; border-radius: 50%; pointer-events: none; }
.brand-panel .b1 { width: 360px; height: 360px; right: -120px; top: -90px; background: rgba(255,255,255,.10); }
.brand-panel .b2 { width: 260px; height: 260px; left: -90px; bottom: -70px; background: rgba(255,255,255,.07); }
.brand-panel .ring { position: absolute; right: 60px; bottom: 80px; width: 180px; height: 180px; border: 1.5px solid rgba(255,255,255,.14); border-radius: 50%; }
.brand-panel .ring::after { content: ""; position: absolute; inset: 34px; border: 1.5px solid rgba(255,255,255,.10); border-radius: 50%; }
.bp-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -.2px; position: relative; z-index: 1; }
.bp-logo .logo { width: 32px; height: 32px; border-radius: 9px; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); display: grid; place-items: center; font-size: 17px; font-weight: 800; }
.bp-body { margin-top: auto; position: relative; z-index: 1; }
.bp-head { font-size: 34px; font-weight: 700; line-height: 1.32; letter-spacing: -.8px; text-wrap: balance; }
.bp-sub { margin-top: 18px; font-size: 15px; line-height: 1.7; color: rgba(255,255,255,.82); max-width: 400px; }
.bp-points { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
.bp-point { display: flex; align-items: center; gap: 11px; font-size: 14px; color: rgba(255,255,255,.92); }
.bp-point .ic { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; flex-shrink: 0; }
.bp-point .ic svg { width: 13px; height: 13px; }
.bp-foot { margin-top: 40px; font-size: 12.5px; color: rgba(255,255,255,.6); position: relative; z-index: 1; }
/* form panel */
.form-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
.auth-card { width: 100%; max-width: 384px; }
.ac-logo { display: none; }
.ac-title { font-size: 24px; font-weight: 700; letter-spacing: -.5px; }
.ac-sub { color: var(--text-2); font-size: 14px; margin-top: 7px; }
.socials { display: flex; flex-direction: column; gap: 10px; margin-top: 26px; }
.sbtn { height: 46px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff; display: flex; align-items: center; justify-content: center; gap: 10px; cursor: pointer; font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text); text-decoration: none; position: relative; transition: background .12s; }
.sbtn:hover { background: #f7f8fa; }
.sbtn .sico { position: absolute; left: 16px; display: grid; place-items: center; }
.sbtn.kakao { background: var(--kakao); border-color: var(--kakao); color: var(--kakao-text); }
.sbtn.kakao:hover { background: #f2d900; }
.divider { display: flex; align-items: center; gap: 14px; margin: 20px 0; color: var(--text-3); font-size: 12.5px; }
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
.field { margin-bottom: 13px; }
.flabel { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 7px; display: block; }
.tinput { width: 100%; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 13px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
.tinput::placeholder { color: var(--text-3); }
.pw-wrap { position: relative; }
.pw-wrap .tinput { padding-right: 44px; }
.pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; border-radius: 6px; }
.pw-toggle:hover { background: #f1f2f4; color: var(--text-2); }
.pw-toggle svg { width: 18px; height: 18px; }
.field-hint { font-size: 12px; color: var(--text-3); margin-top: 7px; display: flex; align-items: center; gap: 5px; }
.field-hint svg { width: 13px; height: 13px; flex-shrink: 0; }
.terms { display: flex; align-items: flex-start; gap: 9px; margin: 6px 0 18px; font-size: 13px; color: var(--text-2); cursor: pointer; user-select: none; line-height: 1.5; }
.terms .box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; flex-shrink: 0; margin-top: 1px; }
.terms .box.on { background: var(--accent); border-color: var(--accent); }
.terms .box svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
.terms .box.on svg { opacity: 1; }
.terms a { color: var(--accent); font-weight: 600; text-decoration: none; }
.terms a:hover { text-decoration: underline; }
.submit { width: 100%; height: 46px; border-radius: var(--radius); border: none; background: var(--accent); color: #fff; font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(79,70,229,.4); display: flex; align-items: center; justify-content: center; gap: 7px; }
.submit:hover { background: var(--accent-hover); }
.submit svg { width: 17px; height: 17px; }
.alt { margin-top: 22px; text-align: center; font-size: 13.5px; color: var(--text-2); }
.alt a { color: var(--accent); font-weight: 600; text-decoration: none; }
.alt a:hover { text-decoration: underline; }
/* verification sent state */
.verify { text-align: center; display: none; }
.verify .vicon { width: 66px; height: 66px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; margin: 0 auto 22px; }
.verify .vicon svg { width: 30px; height: 30px; }
.verify h1 { font-size: 23px; font-weight: 700; letter-spacing: -.4px; }
.verify .vtext { color: var(--text-2); font-size: 14px; line-height: 1.7; margin-top: 12px; }
.verify .vmail { display: inline-flex; align-items: center; gap: 6px; margin-top: 16px; padding: 9px 14px; background: #f7f8fa; border: 1px solid var(--border); border-radius: var(--radius); font-size: 14px; font-weight: 600; color: var(--text); }
.verify .vmail svg { width: 15px; height: 15px; color: var(--text-3); }
.verify .vspam { font-size: 12.5px; color: var(--text-3); margin-top: 18px; line-height: 1.6; }
.verify .vactions { margin-top: 26px; display: flex; flex-direction: column; gap: 10px; }
.btn-ghost { height: 44px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff; color: var(--text); font-family: inherit; font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }
.btn-ghost:hover { background: #f7f8fa; }
.btn-ghost svg { width: 16px; height: 16px; }
.vchange { background: none; border: none; font-family: inherit; font-size: 13px; color: var(--text-2); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.vchange:hover { color: var(--text); }
@media (max-width: 880px) {
.brand-panel { display: none; }
.ac-logo { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; margin-bottom: 24px; }
.ac-logo .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; }
}
</style>
</head>
<body>
<div class="auth">
<aside class="brand-panel">
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="ring"></div>
<div class="bp-logo"><span class="logo">R</span> Relay</div>
<div class="bp-body">
<div class="bp-head">팀에 합류하고<br>업무 지시를 시작하세요</div>
<div class="bp-sub">계정을 만들면 저장소에 초대되어, 받은 업무를 확인하고 직접 업무를 지시할 수 있습니다.</div>
<div class="bp-points">
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 이메일로 1분이면 가입 완료</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 이메일 인증으로 안전한 가입</div>
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 가입 후 바로 업무 흐름에 참여</div>
</div>
</div>
<div class="bp-foot">© 2026 Relay · 사내 업무 지시 플랫폼</div>
</aside>
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo"><span class="logo">R</span> Relay</div>
<!-- ===== 상태 1: 가입 폼 ===== -->
<div class="signup-form">
<h1 class="ac-title">이메일로 회원가입</h1>
<p class="ac-sub">구글·카카오 계정은 <a href="로그인.html" style="color:var(--accent);font-weight:600;text-decoration:none">로그인 화면</a>에서 바로 시작할 수 있어요.</p>
<div class="field" style="margin-top:26px">
<label class="flabel">이름</label>
<input class="tinput" type="text" placeholder="홍길동" value="">
</div>
<div class="field">
<label class="flabel">이메일</label>
<input class="tinput" id="email" type="email" placeholder="name@acme.co" value="">
<div class="field-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg> 가입 후 이 주소로 인증 메일이 발송됩니다.</div>
</div>
<div class="field">
<label class="flabel">비밀번호</label>
<div class="pw-wrap">
<input class="tinput" type="password" placeholder="비밀번호 입력" value="">
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
<div class="field-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg> 8자 이상, 영문과 숫자를 포함해주세요.</div>
</div>
<div class="field">
<label class="flabel">비밀번호 확인</label>
<div class="pw-wrap">
<input class="tinput" type="password" placeholder="비밀번호 다시 입력" value="">
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<label class="terms" id="terms">
<span class="box on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<span><a href="#">이용약관</a><a href="#">개인정보 처리방침</a>에 동의합니다. (필수)</span>
</label>
<button class="submit" type="button" id="submitBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg>
인증 메일 받고 가입하기
</button>
<div class="alt">이미 계정이 있으신가요? <a href="로그인.html">로그인</a></div>
</div>
<!-- ===== 상태 2: 인증 메일 전송됨 ===== -->
<div class="verify" id="verifyView">
<div class="vicon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg></div>
<h1>이메일을 확인해주세요</h1>
<div class="vtext">아래 주소로 인증 메일을 보냈습니다.<br>메일 속 링크를 클릭하면 가입이 완료됩니다.</div>
<div class="vmail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg><span id="verifyEmail">name@acme.co</span></div>
<div class="vspam">메일이 보이지 않나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요.</div>
<div class="vactions">
<button class="btn-ghost" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> 인증 메일 다시 보내기</button>
<button class="vchange" type="button" id="backBtn">다른 이메일로 가입하기</button>
</div>
</div>
</div>
</main>
</div>
<script>
// 비밀번호 표시 토글
document.querySelectorAll('.pw-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = this.parentElement.querySelector('input');
input.type = input.type === 'password' ? 'text' : 'password';
});
});
// 약관 동의 토글
document.getElementById('terms').addEventListener('click', function (e) {
if (e.target.closest('a')) return;
e.preventDefault();
this.querySelector('.box').classList.toggle('on');
});
// 가입하기 → 인증 메일 전송 상태로 전환
document.getElementById('submitBtn').addEventListener('click', function () {
var email = document.getElementById('email').value.trim() || 'name@acme.co';
document.getElementById('verifyEmail').textContent = email;
document.querySelector('.signup-form').style.display = 'none';
document.getElementById('verifyView').style.display = 'block';
});
// 다른 이메일로 가입하기 → 폼으로 복귀
document.getElementById('backBtn').addEventListener('click', function () {
document.getElementById('verifyView').style.display = 'none';
document.querySelector('.signup-form').style.display = 'block';
});
</script>
</body>
</html>
+36 -23
View File
@@ -1,31 +1,44 @@
# ==========================================
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.development 에서 주입한다.
# .example 을 복사해 .env.development 를 만들고 실제 값으로 채운다.
# 백엔드 고유 설정 (development)
# 루트 .env 에서 주입하지 않는 값만 여기에 둔다.
# DB/Redis/CORS/포트/AI_TIMEOUT_MS 는 docker-compose 가 루트 .env.development 에서 주입.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# ==========================================
# JWT / 인증 토큰
# ==========================================
# 시크릿은 강력한 랜덤 문자열로 교체:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# 토큰 만료 시간
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
GITEA_BASE_URL=https://gitea.example.com
# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token)
GITEA_TOKEN=change-me-gitea-token
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
GITEA_ORG=marketing-team
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
GITEA_TEMPLATE_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
# ==========================================
# 앱 URL (리다이렉트)
# ==========================================
# APP_API_URL : 백엔드 공개 주소(/api 포함). 인증 메일 링크 생성용. 미설정 시 localhost:3100/api 폴백.
# FRONTEND_URL: 프론트 공개 주소. 인증 완료·소셜 로그인 후 리다이렉트. 미설정 시 CORS_ORIGIN→localhost:5273 폴백.
APP_API_URL=http://localhost:3100/api
FRONTEND_URL=http://localhost:5273
# ==========================================
# 소셜 로그인 (OAuth)
# ==========================================
# 각 제공자 콘솔 발급 키. CLIENT_ID 가 비면 해당 소셜 로그인 비활성.
# CALLBACK_URL 은 콘솔 등록값과 정확히 일치해야 함(/api 포함).
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
KAKAO_CALLBACK_URL=http://localhost:3100/api/auth/kakao/callback
GOOGLE_CLIENT_ID=change-me-google-client-id
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
# ==========================================
# AI 에이전트 (Claude)
# ==========================================
# CLAUDE_API_KEY: Anthropic API 키. 미설정 시 AI 기능 비활성.
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
CLAUDE_API_KEY=change-me-claude-api-key
CLAUDE_MODEL=claude-sonnet-4-6
+36 -23
View File
@@ -1,31 +1,44 @@
# ==========================================
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.production 에서 주입한다.
# .example 을 복사해 .env.production 를 만들고 실제 값으로 채운다.
# 백엔드 고유 설정 (production)
# 루트 .env 에서 주입하지 않는 값만 여기에 둔다.
# DB/Redis/CORS/포트/AI_TIMEOUT_MS 는 docker-compose 가 루트 .env.production 에서 주입.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# ==========================================
# JWT / 인증 토큰
# ==========================================
# 시크릿은 강력한 랜덤 문자열로 교체:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# 토큰 만료 시간
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
GITEA_BASE_URL=https://gitea.example.com
# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token)
GITEA_TOKEN=change-me-gitea-token
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
GITEA_ORG=marketing-team
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
GITEA_TEMPLATE_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
# ==========================================
# 앱 URL (리다이렉트) — 운영 도메인으로 교체
# ==========================================
# APP_API_URL : 백엔드 공개 주소(/api 포함). 인증 메일 링크 생성용.
# FRONTEND_URL: 프론트 공개 주소. 인증 완료·소셜 로그인 후 리다이렉트.
APP_API_URL=https://your-domain.com/api
FRONTEND_URL=https://your-domain.com
# ==========================================
# 소셜 로그인 (OAuth) — 운영 키/도메인으로 교체
# ==========================================
# 각 제공자 콘솔 발급 키. CLIENT_ID 가 비면 해당 소셜 로그인 비활성.
# CALLBACK_URL 은 콘솔 등록값과 정확히 일치해야 함(/api 포함).
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
KAKAO_CALLBACK_URL=https://your-domain.com/api/auth/kakao/callback
GOOGLE_CLIENT_ID=change-me-google-client-id
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
GOOGLE_CALLBACK_URL=https://your-domain.com/api/auth/google/callback
# ==========================================
# AI 에이전트 (Claude)
# ==========================================
# CLAUDE_API_KEY: Anthropic API 키. 미설정 시 AI 기능 비활성.
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
CLAUDE_API_KEY=change-me-claude-api-key
CLAUDE_MODEL=claude-sonnet-4-6
+125 -11
View File
@@ -22,6 +22,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
@@ -32,7 +33,9 @@
"ioredis": "^5.11.1",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -3897,7 +3900,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -3908,7 +3910,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -3965,7 +3966,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3978,7 +3978,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -3998,7 +3997,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
@@ -4085,16 +4083,35 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/oauth": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-google-oauth20": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-oauth2": "*"
}
},
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
@@ -4106,6 +4123,17 @@
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
@@ -4121,21 +4149,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -4145,7 +4170,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -5589,6 +5613,15 @@
],
"license": "MIT"
},
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
@@ -8511,7 +8544,6 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "1.10.0",
"cluster-key-slot": "1.1.1",
@@ -10496,6 +10528,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -10778,6 +10816,18 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
@@ -10788,6 +10838,55 @@
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-kakao": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/passport-kakao/-/passport-kakao-1.0.1.tgz",
"integrity": "sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "~1.1.2",
"pkginfo": "~0.3.0"
}
},
"node_modules/passport-kakao/node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/passport-kakao/node_modules/passport-oauth2": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.1.2.tgz",
"integrity": "sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==",
"dependencies": {
"oauth": "0.9.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@@ -11083,6 +11182,15 @@
"node": ">=8"
}
},
"node_modules/pkginfo": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz",
"integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -13129,6 +13237,12 @@
"node": ">=8"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+3
View File
@@ -33,6 +33,7 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
@@ -43,7 +44,9 @@
"ioredis": "^5.11.1",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
+6 -3
View File
@@ -13,10 +13,11 @@ import { AppService } from './app.service';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { RepoModule } from './modules/repo/repo.module';
import { TaskModule } from './modules/task/task.module';
import { ActivityModule } from './modules/activity/activity.module';
import { NotificationModule } from './modules/notification/notification.module';
import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
@Module({
imports: [
@@ -55,10 +56,11 @@ import { NotificationModule } from './modules/notification/notification.module';
},
}),
// 전역 Rate Limiter — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌릴 것.
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
limit: 1000,
},
]),
TypeOrmModule.forRootAsync({
@@ -91,10 +93,11 @@ import { NotificationModule } from './modules/notification/notification.module';
HealthModule,
UserModule,
AuthModule,
RepoModule,
TaskModule,
ActivityModule,
NotificationModule,
AiModule,
ProjectModule,
],
controllers: [AppController],
providers: [
@@ -22,6 +22,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
let message =
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
// multer 업로드 오류(크기 초과 등)는 HttpException 이 아니므로 별도 400(VAL_001) 매핑
if (this.isMulterError(exception)) {
const muMessage =
exception.code === 'LIMIT_FILE_SIZE'
? '파일 크기가 허용 한도(25MB)를 초과했습니다.'
: '파일 업로드에 실패했습니다. 파일을 확인해 주세요.';
response.status(HttpStatus.BAD_REQUEST).json({
success: false,
error: { code: 'VAL_001', message: muMessage },
});
return;
}
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
@@ -83,6 +96,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
});
}
// multer 업로드 오류 판별 (name === 'MulterError', code 보유)
private isMulterError(e: unknown): e is { name: string; code?: string } {
return (
typeof e === 'object' &&
e !== null &&
(e as { name?: unknown }).name === 'MulterError'
);
}
// 예외 응답에서 명시적 { code, message } 추출 (둘 다 문자열일 때만)
private extractExplicitError(
res: string | object,
+24 -15
View File
@@ -43,8 +43,15 @@ async function bootstrap() {
app.use(helmet());
// 2. 화이트리스트 기반 CORS 정책
const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
// 운영에서는 로컬 개발 오리진(localhost:3000)을 허용 목록에서 제외
const corsWhitelist = [
frontendUrl,
...(isProduction ? [] : ['http://localhost:3000']),
...(corsOriginEnv ? [corsOriginEnv] : []),
];
app.enableCors({
origin: (
@@ -52,10 +59,7 @@ async function bootstrap() {
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
if (!origin || corsWhitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
// 클라이언트에는 403 금지 에러로 리턴됨
@@ -66,10 +70,11 @@ async function bootstrap() {
});
// 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방)
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌리거나 env 화 필요.
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15
max: 150, // 15분 IP당 최대 150개 요청
windowMs: 60 * 1000, // 1분
max: 1000, // 1분 IP당 최대 1000개 요청
message: {
success: false,
error: {
@@ -94,15 +99,19 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// 6. Swagger API 문서 설정
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 6. Swagger API 문서 설정 — 운영 환경에서는 노출하지 않는다(공격 표면 축소)
if (!isProduction) {
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription(
'The API documentation conforming to the global guidelines',
)
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
}
// 종료 시그널(SIGTERM/SIGINT) 에 lifecycle 훅 실행 — onModuleDestroy 로 Redis pub/sub 등
// 외부 연결을 정상 종료(graceful shutdown). 미설정 시 정리 훅이 호출되지 않는다.
@@ -1,20 +1,27 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Param,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ActivityService, type ActivityResponse } from './activity.service';
// 활동 피드 컨트롤러 — 읽기 전용. 조직 모델상 인증 사용자면 열람 가능
@ApiTags('Activity')
@Controller('repos/:repoId/activity')
@Controller('projects/:projectId/activity')
@UseGuards(JwtAuthGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get()
@ApiOperation({ summary: '저장소 활동 피드 (최신순)' })
@ApiOperation({ summary: '프로젝트 활동 피드 (최신순)' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(@Param('repoId') repoId: string): Promise<ActivityResponse[]> {
return this.activityService.listForRepo(repoId);
list(
@Param('projectId', ParseUUIDPipe) projectId: string,
): Promise<ActivityResponse[]> {
return this.activityService.listForProject(projectId);
}
}
@@ -1,14 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Activity } from './entities/activity.entity';
import { Repo } from '../repo/entities/repo.entity';
import { ActivityService } from './activity.service';
import { ActivityController } from './activity.controller';
// 활동 피드 모듈 — 다른 도메인 모듈(repo/task)이 import 해 ActivityService 로 이벤트를 적재한다.
// (Activity 는 taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
// 활동 피드 모듈 — 다른 도메인 모듈(project/task)이 import 해 ActivityService 로 이벤트를 적재한다.
// (Activity 는 projectId + taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
@Module({
imports: [TypeOrmModule.forFeature([Activity, Repo])],
imports: [TypeOrmModule.forFeature([Activity])],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
@@ -1,8 +1,8 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { Repo } from '../repo/entities/repo.entity';
import { Project } from '../project/entities/project.entity';
import { Activity, type ActivityType } from './entities/activity.entity';
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
@@ -17,7 +17,7 @@ export interface ActivityResponse {
// 활동 적재 파라미터
export interface RecordActivityParams {
repoId: string; // Repo uuid
projectId: string; // Project uuid
actorId: string | null;
type: ActivityType;
payload?: Record<string, unknown>;
@@ -25,7 +25,7 @@ export interface RecordActivityParams {
}
// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고,
// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
// 프로젝트/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
@@ -33,15 +33,13 @@ export class ActivityService {
constructor(
@InjectRepository(Activity)
private readonly activityRepo: Repository<Activity>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
) {}
// 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
async record(params: RecordActivityParams): Promise<void> {
try {
const activity = this.activityRepo.create({
repo: { id: params.repoId } as Repo,
project: { id: params.projectId } as Project,
actor: params.actorId ? { id: params.actorId } : null,
type: params.type,
payload: params.payload ?? {},
@@ -56,14 +54,10 @@ export class ActivityService {
}
}
// 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
async listForRepo(slugName: string): Promise<ActivityResponse[]> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
async listForProject(projectId: string): Promise<ActivityResponse[]> {
const rows = await this.activityRepo.find({
where: { repo: { id: repo.id } },
where: { project: { id: projectId } },
relations: ['actor'],
order: { createdAt: 'DESC' },
take: 200,
@@ -71,34 +65,19 @@ export class ActivityService {
return rows.map((a) => this.toResponse(a));
}
// 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
// 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
async listForTask(
repoId: string,
projectId: string,
taskSeq: number,
): Promise<ActivityResponse[]> {
const rows = await this.activityRepo.find({
where: { repo: { id: repoId }, taskSeq },
where: { project: { id: projectId }, taskSeq },
relations: ['actor'],
order: { createdAt: 'DESC' },
});
return rows.map((a) => this.toResponse(a));
}
// 저장소 단위(업무 무관) 활동만 — 필요 시 사용. taskSeq IS NULL
async listRepoOnly(slugName: string): Promise<ActivityResponse[]> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
const rows = await this.activityRepo.find({
where: { repo: { id: repo.id }, taskSeq: IsNull() },
relations: ['actor'],
order: { createdAt: 'DESC' },
take: 200,
});
return rows.map((a) => this.toResponse(a));
}
// 엔티티 → 응답 매핑
private toResponse(a: Activity): ActivityResponse {
return {
@@ -9,14 +9,13 @@ import {
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
import { Project } from '../../project/entities/project.entity';
// 활동 이벤트 타입 — 2~5단계 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
// 활동 이벤트 타입 — 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
export type ActivityType =
// 저장소(설정)
| 'repo.created'
| 'repo.renamed'
| 'repo.visibility_changed'
// 프로젝트(설정)
| 'project.created'
| 'project.renamed'
// 멤버
| 'member.invited'
| 'member.role_changed'
@@ -39,14 +38,14 @@ export class Activity {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'repo_id' })
repo!: Relation<Repo>;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 프로젝트 활동이면 null
// (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지)
@Index()
@Column({ name: 'task_seq', type: 'int', nullable: true })
@@ -0,0 +1,40 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
@ApiTags('AI')
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiSuggestController {
constructor(private readonly aiService: AiService) {}
@Post('suggest-checklist')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '할 일(체크리스트) 추천 — 프로젝트·업무 기반' })
@ApiResponse({ status: 200, description: '카테고리별 추천 항목' })
@ApiResponse({ status: 404, description: '프로젝트 없음 (RES_001)' })
suggestChecklist(
@CurrentUser() user: PublicUser,
@Body() dto: SuggestChecklistDto,
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
return this.aiService.suggestChecklist(user.id, {
projectId: dto.projectId,
title: dto.title,
content: dto.content,
});
}
}
+97
View File
@@ -0,0 +1,97 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import {
AiService,
type ConversationDetail,
type ConversationSummary,
type SendResult,
} from './ai.service';
import { SendMessageDto } from './dto/send-message.dto';
// AI 채팅 컨트롤러 — 인증 필요. 대화는 본인 소유만 접근(서비스에서 스코프).
@ApiTags('AI')
@Controller('ai/conversations')
@UseGuards(JwtAuthGuard)
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get()
@ApiOperation({ summary: '내 대화 목록 (최근순, 페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
list(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<ConversationSummary>> {
return this.aiService.listConversations(user.id, page, size);
}
@Get(':id')
@ApiOperation({ summary: '대화 상세 (메시지 포함)' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
get(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<ConversationDetail> {
return this.aiService.getConversation(user.id, id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '새 대화 시작 (첫 메시지)' })
@ApiResponse({ status: 201, description: '생성 + AI 응답' })
start(
@CurrentUser() user: PublicUser,
@Body() dto: SendMessageDto,
): Promise<SendResult> {
return this.aiService.startConversation(user.id, dto.content);
}
@Post(':id/messages')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '대화에 메시지 추가' })
@ApiResponse({ status: 200, description: 'AI 응답' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
send(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SendMessageDto,
): Promise<SendResult> {
return this.aiService.addMessage(user.id, id, dto.content);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '대화 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
async remove(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<null> {
await this.aiService.deleteConversation(user.id, id);
return null;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskModule } from '../task/task.module';
import { ProjectModule } from '../project/project.module';
import { AiController } from './ai.controller';
import { AiSuggestController } from './ai-suggest.controller';
import { AiService } from './ai.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속.
// Task/ProjectModule 을 가져와 사용자 업무·프로젝트 컨텍스트를 시스템 프롬프트에 주입한다.
@Module({
imports: [
TypeOrmModule.forFeature([AiConversation, AiMessage]),
TaskModule,
ProjectModule,
],
controllers: [AiController, AiSuggestController],
providers: [AiService],
})
export class AiModule {}
+487
View File
@@ -0,0 +1,487 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { TaskService } from '../task/task.service';
import type { TaskStatus } from '../task/entities/task.entity';
import { ProjectService } from '../project/project.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// Anthropic Messages API 응답(필요 필드만)
interface AnthropicResponse {
content?: { type: string; text?: string }[];
}
// 외부 노출 형태
export interface ConversationSummary {
id: string;
title: string;
updatedAt: Date;
}
export interface ConversationDetail {
id: string;
title: string;
messages: { role: 'user' | 'assistant'; content: string }[];
}
export interface SendResult {
conversationId: string;
title: string;
reply: string;
}
// 할 일 추천 — 카테고리별 항목
export interface ChecklistSuggestionGroup {
category: string;
items: string[];
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
// AI 요청 타임아웃 기본값(ms) — env AI_TIMEOUT_MS 로 재정의. 프론트와 동일 값을 사용한다.
const DEFAULT_AI_TIMEOUT_MS = 60_000;
const MAX_TOKENS = 1024;
const TITLE_MAX = 60;
// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약)
const CONTEXT_TASK_LIMIT = 30;
// 컨텍스트로 주입할 프로젝트 최대 개수
const CONTEXT_PROJECT_LIMIT = 30;
// 주제 한정(가드레일) + 데이터 근거 지시
const SYSTEM_PROMPT = [
'당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다.',
'',
'[역할]',
"- 업무 관리, 일정·마감, 협업, Relay 사용법, 그리고 아래 '사용자 데이터' 에 관한 질문에 답합니다.",
'- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.',
'',
'[규칙]',
'- 한국어로 간결하고 명확하게 답하세요. 불필요한 인사말·사과·이모지 남발을 피하고, 과도한 존칭("회원님" 등) 대신 자연스러운 존댓말을 쓰세요.',
'- 목록이나 강조가 필요하면 마크다운(굵게 **텍스트**, 목록 - 또는 1.)을 사용하세요. 표·복잡한 서식은 피하세요.',
"- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.",
"- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.",
'- 프로젝트는 한글 표시 제목으로 설명하세요.',
].join('\n');
// 업무 상태 → 한국어 라벨(컨텍스트 표기용)
const STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '검토 대기',
changes: '수정 요청',
done: '완료',
};
// 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제
const SUGGEST_SYSTEM_PROMPT = [
'당신은 소프트웨어/업무 기획 보조자입니다.',
"주어진 프로젝트와 업무 정보를 바탕으로, 이 업무(기능)를 완성하기 위해 필요한 '할 일(체크리스트)' 항목을 제안합니다.",
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
'- 업무·프로젝트 맥락에 구체적으로 맞추고 일반론은 피하세요.',
'',
'[형식]',
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
].join('\n');
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly apiKey: string;
private readonly model: string;
private readonly timeoutMs: number;
constructor(
config: ConfigService,
private readonly taskService: TaskService,
private readonly projectService: ProjectService,
@InjectRepository(AiConversation)
private readonly convRepo: Repository<AiConversation>,
@InjectRepository(AiMessage)
private readonly msgRepo: Repository<AiMessage>,
) {
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
config.get<string>('CLAUDE_MODEL') ?? 'claude-sonnet-4-6'
).trim();
// AI 타임아웃 — env AI_TIMEOUT_MS(ms), 미설정/비정상이면 기본값
const t = Number(config.get<string>('AI_TIMEOUT_MS'));
this.timeoutMs = Number.isFinite(t) && t > 0 ? t : DEFAULT_AI_TIMEOUT_MS;
if (!this.apiKey) {
this.logger.warn('CLAUDE_API_KEY 미설정 — AI 채팅 비활성화');
}
}
get enabled(): boolean {
return this.apiKey.length > 0;
}
// 내 대화 목록(최근순, 페이지네이션)
async listConversations(
userId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<ConversationSummary>> {
const params = resolvePage(page, size);
const [rows, total] = await this.convRepo.findAndCount({
where: { user: { id: userId } },
order: { updatedAt: 'DESC' },
skip: params.skip,
take: params.take,
});
return paginated(
rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })),
total,
params,
);
}
// 대화 상세(본인 소유만) — 메시지 시간순
async getConversation(
userId: string,
conversationId: string,
): Promise<ConversationDetail> {
const conv = await this.getOwnedConversation(userId, conversationId);
const messages = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
return {
id: conv.id,
title: conv.title,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
};
}
// 새 대화 시작 — 첫 메시지로 대화 생성 후 응답
async startConversation(
userId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.convRepo.save(
this.convRepo.create({
user: { id: userId },
title: this.makeTitle(content),
}),
);
return this.appendAndReply(conv, content, userId);
}
// 기존 대화에 메시지 추가 후 응답(본인 소유만)
async addMessage(
userId: string,
conversationId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.getOwnedConversation(userId, conversationId);
return this.appendAndReply(conv, content, userId);
}
// 할 일(체크리스트) 추천 — 프로젝트 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안
async suggestChecklist(
userId: string,
input: { projectId: string; title: string; content?: string[] },
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
this.ensureEnabled();
// 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404.
const project = await this.projectService.findOne(input.projectId, userId);
const content = (input.content ?? []).join('\n').trim() || '(없음)';
const userMsg = [
`[프로젝트] 표시 제목: ${project.name}`,
`설명: ${project.description ?? '(없음)'}`,
'',
`[업무] 제목: ${input.title}`,
'내용:',
content,
].join('\n');
// 4개 카테고리 × 항목 수 무제한의 구조화 JSON 이라 토큰 여유를 넉넉히 준다
// (부족하면 응답이 중간에 잘려 JSON 파싱이 실패할 수 있음)
const raw = await this.callClaude(
[{ role: 'user', content: userMsg }],
SUGGEST_SYSTEM_PROMPT,
4096,
);
return { groups: this.parseSuggestions(raw) };
}
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
let text = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/```$/, '')
.trim();
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) text = text.slice(start, end + 1);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
this.logger.warn(
`추천 JSON 파싱 실패(응답 잘림 가능) — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
);
throw this.suggestFailed();
}
const rawGroups = (parsed as { groups?: unknown }).groups;
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
const result: ChecklistSuggestionGroup[] = [];
for (const g of groups.slice(0, 8)) {
const obj = g as { category?: unknown; items?: unknown };
const category =
typeof obj.category === 'string' ? obj.category.trim() : '';
if (!category || !Array.isArray(obj.items)) continue;
// 카테고리당 항목 수 제한 없음 — 응답 길이는 max_tokens 가 자연 제한
const items = obj.items
.filter((x): x is string => typeof x === 'string')
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (items.length > 0) result.push({ category, items });
}
if (result.length === 0) {
this.logger.warn(
`추천 결과가 비어 있음 — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
);
throw this.suggestFailed();
}
return result;
}
private suggestFailed(): BusinessException {
return new BusinessException(
'BIZ_001',
'추천을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
// 대화 삭제(본인 소유만) — 메시지 CASCADE
async deleteConversation(
userId: string,
conversationId: string,
): Promise<void> {
const conv = await this.getOwnedConversation(userId, conversationId);
await this.convRepo.remove(conv);
}
// 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신.
// Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지.
private async appendAndReply(
conv: AiConversation,
content: string,
userId: string,
): Promise<SendResult> {
const userMsg = await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'user',
content,
}),
);
const history = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
let reply: string;
try {
const system = await this.buildSystemPrompt(userId);
reply = await this.callClaude(
history.map((m) => ({ role: m.role, content: m.content })),
system,
);
} catch (e) {
// 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제
await this.msgRepo.delete(userMsg.id);
const remaining = await this.msgRepo.count({
where: { conversation: { id: conv.id } },
});
if (remaining === 0) {
await this.convRepo.delete(conv.id);
}
throw e;
}
await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'assistant',
content: reply,
}),
);
// 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn)
await this.convRepo.save(conv);
return { conversationId: conv.id, title: conv.title, reply };
}
// 소유권 확인 — 본인 대화가 아니면 404(존재 비노출)
private async getOwnedConversation(
userId: string,
conversationId: string,
): Promise<AiConversation> {
const conv = await this.convRepo.findOne({
where: { id: conversationId, user: { id: userId } },
});
if (!conv) {
throw new BusinessException(
'RES_001',
'대화를 찾을 수 없습니다.',
HttpStatus.NOT_FOUND,
);
}
return conv;
}
private makeTitle(content: string): string {
const firstLine = content.trim().split('\n')[0].trim();
if (!firstLine) return '새 대화';
return firstLine.length > TITLE_MAX
? `${firstLine.slice(0, TITLE_MAX)}`
: firstLine;
}
private ensureEnabled(): void {
if (!this.enabled) {
throw new BusinessException(
'BIZ_001',
'AI 기능이 설정되지 않았습니다. 관리자에게 문의해 주세요.',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
// 시스템 프롬프트 = 가드레일 + 현재 사용자 데이터 컨텍스트
private async buildSystemPrompt(userId: string): Promise<string> {
const context = await this.buildUserContext(userId);
return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`;
}
// 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행
private async buildUserContext(userId: string): Promise<string> {
const today = new Date(Date.now() + 9 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10); // KST 기준 오늘
try {
const assigned = await this.taskService.listAssigned(
userId,
1,
CONTEXT_TASK_LIMIT,
);
const issued = await this.taskService.listIssued(userId, 1, 1);
const projects = await this.projectService.findAll(
1,
CONTEXT_PROJECT_LIMIT,
);
const lines = [`오늘 날짜: ${today} (KST)`, ''];
// 담당 업무
lines.push(`내가 담당한 업무 (${assigned.total}건):`);
if (assigned.items.length === 0) {
lines.push('- (없음)');
} else {
for (const t of assigned.items) {
const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음';
const [done, totalC] = t.checklist;
lines.push(
`- [${t.projectName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`,
);
}
if (assigned.total > assigned.items.length) {
lines.push(`- … 외 ${assigned.total - assigned.items.length}`);
}
}
lines.push('', `내가 지시한 업무: ${issued.total}`);
// 프로젝트 목록(인증 사용자는 전체 열람 가능)
lines.push('', `프로젝트 목록 (${projects.total}개):`);
if (projects.items.length === 0) {
lines.push('- (없음)');
} else {
for (const p of projects.items) {
lines.push(`- ${p.name} — 업무 ${p.doneCount}/${p.totalCount}`);
}
if (projects.total > projects.items.length) {
lines.push(`- … 외 ${projects.total - projects.items.length}`);
}
}
return lines.join('\n');
} catch (e) {
this.logger.warn(
`사용자 컨텍스트 조회 실패: ${e instanceof Error ? e.message : String(e)}`,
);
return `오늘 날짜: ${today} (KST)\n(사용자 업무 데이터를 불러오지 못했습니다.)`;
}
}
// Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구
private async callClaude(
messages: { role: 'user' | 'assistant'; content: string }[],
system: string,
maxTokens: number = MAX_TOKENS,
): Promise<string> {
let response: Response;
try {
response = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: this.model,
max_tokens: maxTokens,
system,
messages,
}),
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (e) {
this.logger.error(
`Anthropic 요청 실패: ${e instanceof Error ? e.message : String(e)}`,
);
throw new BusinessException(
'BIZ_001',
'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
if (!response.ok) {
const detail = await response.text().catch(() => '');
this.logger.error(
`Anthropic API 오류 ${response.status}: ${detail.slice(0, 500)}`,
);
throw new BusinessException(
'BIZ_001',
'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
const data = (await response.json()) as AnthropicResponse;
const text = (data.content ?? [])
.filter((b) => b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('')
.trim();
return text || '(빈 응답을 받았습니다.)';
}
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다.
export class SendMessageDto {
@ApiProperty({ description: '사용자 메시지 내용' })
@IsString()
@IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' })
@MaxLength(8000, { message: '메시지가 너무 깁니다.' })
content!: string;
}
@@ -0,0 +1,35 @@
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 할 일(체크리스트) 추천 요청 — 프로젝트·업무 정보를 바탕으로 AI 가 항목 제안
export class SuggestChecklistDto {
@ApiProperty({ description: '프로젝트 식별자(slugName)' })
@IsString()
@IsNotEmpty({ message: '프로젝트가 필요합니다.' })
projectId!: string;
@ApiProperty({ description: '업무 제목' })
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title!: string;
@ApiProperty({
description: '업무 내용(문단 배열)',
required: false,
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(5000, { each: true })
content?: string[];
}
@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N).
@Entity('ai_conversations')
export class AiConversation {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user!: Relation<User>;
// 첫 사용자 메시지로 자동 생성되는 제목
@Column({ type: 'varchar', length: 120 })
title!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// 새 메시지 추가 시 갱신(최근 대화 정렬용)
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,30 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { AiConversation } from './ai-conversation.entity';
// AI 대화 메시지 1건 — 대화에 종속(CASCADE).
@Entity('ai_messages')
export class AiMessage {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'conversation_id' })
conversation!: Relation<AiConversation>;
@Column({ type: 'varchar' })
role!: 'user' | 'assistant';
@Column({ type: 'text' })
content!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+153 -25
View File
@@ -5,18 +5,25 @@ import {
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import type { CookieOptions, Response } from 'express';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import type { CookieOptions, Request, Response } from 'express';
import { AuthService, type SignupResult } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import { ResendVerificationDto } from './dto/resend-verification.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
import type { PublicUser } from '../user/user.service';
// 쿠키 만료 시간(ms)
@@ -34,6 +41,15 @@ export class AuthController {
private readonly config: ConfigService,
) {}
// 프론트엔드(웹) 기준 URL — 소셜 로그인/이메일 인증 후 리다이렉트 대상
private webUrl(): string {
const url =
this.config.get<string>('FRONTEND_URL') ||
this.config.get<string>('CORS_ORIGIN') ||
'http://localhost:5273';
return url.split(',')[0].trim().replace(/\/$/, '');
}
// HttpOnly·Secure 쿠키 공통 옵션
private cookieBase(): CookieOptions {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
@@ -71,23 +87,79 @@ export class AuthController {
});
}
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
private async loginWith(res: Response, user: PublicUser): Promise<void> {
const tokens = await this.authService.issueTokensForNewSession(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
}
@Post('signup')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '회원가입 (가입 후 자동 로그인)' })
@ApiResponse({ status: 201, description: '가입 성공, 인증 쿠키 발급' })
// 가입 남용/메일 폭탄 방지 — 운영 권장 5회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '회원가입 (인증 메일 발송, 자동 로그인 안 함)' })
@ApiResponse({ status: 201, description: '가입 접수 — 인증 메일 발송됨' })
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
async signup(
@Body() dto: SignupDto,
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.signup(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return user;
signup(@Body() dto: SignupDto): Promise<SignupResult> {
// 이메일 인증 완료 전까지 로그인 불가 — 쿠키를 발급하지 않는다
return this.authService.signup(dto);
}
@Post('resend-verification')
@HttpCode(HttpStatus.OK)
// 메일 폭탄 방지 — 운영 권장 3회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '인증 메일 재발송' })
@ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' })
async resend(@Body() dto: ResendVerificationDto): Promise<null> {
await this.authService.resendVerification(dto.email);
return null;
}
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
// 메일 폭탄/계정 탐색 방지 — 운영 권장 3회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '비밀번호 재설정 요청 (재설정 메일 발송)' })
@ApiResponse({ status: 200, description: '요청 접수(존재 여부 비노출)' })
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<null> {
await this.authService.requestPasswordReset(dto.email);
return null;
}
@Post('reset-password')
@HttpCode(HttpStatus.OK)
// 토큰 추측 방지 — 운영 권장 5회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '비밀번호 재설정 (토큰 + 새 비밀번호)' })
@ApiResponse({ status: 200, description: '비밀번호 변경 성공' })
@ApiResponse({ status: 400, description: '토큰 만료/무효 (BIZ_001)' })
async resetPassword(@Body() dto: ResetPasswordDto): Promise<null> {
await this.authService.resetPassword(dto.token, dto.password);
return null;
}
// 이메일 인증 링크 — 메일의 버튼에서 직접 GET 진입(보통 새 탭).
// 검증 후 결과 페이지로 리다이렉트하며, 그 페이지는 완료를 표시하고 창을 닫는다.
@Get('verify-email')
@SkipTransform()
@ApiOperation({ summary: '이메일 인증 (메일 링크)' })
async verifyEmail(
@Query('token') token: string,
@Res() res: Response,
): Promise<void> {
try {
await this.authService.verifyEmail(token);
res.redirect(`${this.webUrl()}/email-verified?ok=1`);
} catch {
res.redirect(`${this.webUrl()}/email-verified?ok=0`);
}
}
@Post('login')
@HttpCode(HttpStatus.OK)
// 무차별 대입 방지 — 운영 권장 10회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '로그인' })
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
@ApiResponse({
@@ -99,36 +171,92 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.validateUser(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
await this.loginWith(res, user);
return user;
}
// ── 구글 OAuth ──
@Get('google')
@SkipTransform()
@UseGuards(AuthGuard('google'))
@ApiOperation({ summary: '구글 로그인 시작 (구글 동의 화면으로 이동)' })
googleAuth(): void {
// 가드(AuthGuard)가 구글 인증 페이지로 리다이렉트 — 본문 없음
}
@Get('google/callback')
@SkipTransform()
@UseGuards(AuthGuard('google'))
@ApiOperation({ summary: '구글 로그인 콜백' })
async googleCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/projects`);
}
// ── 카카오 OAuth ──
@Get('kakao')
@SkipTransform()
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: '카카오 로그인 시작 (카카오 동의 화면으로 이동)' })
kakaoAuth(): void {
// 가드(AuthGuard)가 카카오 인증 페이지로 리다이렉트 — 본문 없음
}
@Get('kakao/callback')
@SkipTransform()
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: '카카오 로그인 콜백' })
async kakaoCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/projects`);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
@ApiOperation({ summary: '로그아웃 (세션 폐기 + 인증 쿠키 제거)' })
@ApiResponse({ status: 200, description: '로그아웃 성공' })
logout(@Res({ passthrough: true }) res: Response): null {
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
// 이 세션(패밀리)을 서버측에서 폐기 — 이후 해당 refresh 토큰 재사용 차단
const cookies = req.cookies as Record<string, string> | undefined;
await this.authService.revokeSession(cookies?.refresh_token);
this.clearAuthCookies(res);
return null;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtRefreshGuard)
@ApiOperation({ summary: 'access 토큰 재발급 (refresh 쿠키 필요)' })
@ApiResponse({ status: 200, description: '토큰 재발급 성공' })
@ApiOperation({ summary: 'access/refresh 토큰 회전 (refresh 쿠키 필요)' })
@ApiResponse({ status: 200, description: '토큰 회전 성공' })
@ApiResponse({
status: 401,
description: 'refresh 토큰 만료/누락 (AUTH_001)',
description: 'refresh 토큰 만료/누락/재사용 (AUTH_001)',
})
async refresh(
@CurrentUser() user: PublicUser,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return null;
const cookies = req.cookies as Record<string, string> | undefined;
try {
// 회전형 refresh — 재사용 탐지 시 401(AUTH_001), 정상 시 새 토큰 쌍으로 교체
const tokens = await this.authService.rotateTokens(
cookies?.refresh_token,
);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return null;
} catch (e) {
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
this.clearAuthCookies(res);
throw e;
}
}
@Get('me')
+46 -4
View File
@@ -1,22 +1,64 @@
import { Module } from '@nestjs/common';
import { Logger, Module, type Provider } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { UserModule } from '../user/user.module';
import { MailModule } from '../mail/mail.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RefreshSession } from './entities/refresh-session.entity';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
import { GoogleStrategy } from './strategies/google.strategy';
import { KakaoStrategy } from './strategies/kakao.strategy';
// 인증 모듈 — JWT(access/refresh) + Passport 전략
// 구글 OAuth 전략 — 키가 설정된 경우에만 등록(미설정 시 null → /auth/google 비활성).
const googleStrategyProvider: Provider = {
provide: GoogleStrategy,
useFactory: (config: ConfigService, authService: AuthService) => {
if (!config.get<string>('GOOGLE_CLIENT_ID')) {
new Logger('AuthModule').warn(
'GOOGLE_CLIENT_ID 미설정 — 구글 로그인 비활성화',
);
return null;
}
return new GoogleStrategy(config, authService);
},
inject: [ConfigService, AuthService],
};
// 카카오 OAuth 전략 — 키가 설정된 경우에만 등록.
const kakaoStrategyProvider: Provider = {
provide: KakaoStrategy,
useFactory: (config: ConfigService, authService: AuthService) => {
if (!config.get<string>('KAKAO_CLIENT_ID')) {
new Logger('AuthModule').warn(
'KAKAO_CLIENT_ID 미설정 — 카카오 로그인 비활성화',
);
return null;
}
return new KakaoStrategy(config, authService);
},
inject: [ConfigService, AuthService],
};
// 인증 모듈 — JWT(access/refresh) + Passport 전략(JWT/구글/카카오) + 메일(인증)
@Module({
imports: [
UserModule,
MailModule,
PassportModule,
TypeOrmModule.forFeature([RefreshSession]),
// 토큰 서명 옵션은 AuthService 에서 호출 단위로 지정하므로 기본 등록만 한다
JwtModule.register({}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
providers: [
AuthService,
JwtStrategy,
googleStrategyProvider,
kakaoStrategyProvider,
],
exports: [AuthService],
})
export class AuthModule {}
+325 -16
View File
@@ -1,14 +1,29 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService, type JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash, randomBytes, randomUUID } from 'crypto';
import * as bcrypt from 'bcryptjs';
import { UserService, type PublicUser } from '../user/user.service';
import type { User } from '../user/entities/user.entity';
import { MailService } from '../mail/mail.service';
import { RefreshSession } from './entities/refresh-session.entity';
import { BusinessException } from '../../common/exceptions/business.exception';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import type { JwtPayload } from './types/jwt-payload';
// bcrypt 해시 라운드
const BCRYPT_ROUNDS = 10;
const BCRYPT_ROUNDS = 12;
// 이메일 인증 토큰 유효 시간 (24시간)
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
// 비밀번호 재설정 토큰 유효 시간 (1시간 — 보안상 짧게)
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
// refresh 세션 만료(=refresh TTL, 정리용). JWT_REFRESH_TTL(기본 14d)과 맞춘다.
const REFRESH_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
// 동시 갱신(재시도) 유예 — 직전 jti 가 이 시간 내 재제출이면 재사용으로 보지 않음
const REFRESH_GRACE_MS = 15 * 1000;
// 발급된 토큰 쌍
export interface TokenPair {
@@ -16,17 +31,36 @@ export interface TokenPair {
refreshToken: string;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string;
verificationPending: true;
}
// 소셜 로그인 프로필 — 각 전략(google/kakao)이 공통 형태로 정규화해 전달
export interface OAuthProfile {
provider: string;
email: string | null;
// 제공자가 이메일 소유를 검증했는지 — 미검증이면 계정 매칭/생성을 거부(탈취/선점 방지)
emailVerified: boolean;
name: string;
avatarUrl?: string | null;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 이메일 인증, 소셜 로그인, 토큰 발급
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly mailService: MailService,
@InjectRepository(RefreshSession)
private readonly refreshRepo: Repository<RefreshSession>,
) {}
// 회원가입 — 이메일 중복 검사 후 비밀번호 해시하여 저장
async signup(dto: SignupDto): Promise<PublicUser> {
// 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X)
async signup(dto: SignupDto): Promise<SignupResult> {
const exists = await this.userService.findByEmail(dto.email);
if (exists) {
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
@@ -43,27 +77,256 @@ export class AuthService {
name: dto.name,
role: dto.role ?? null,
});
return UserService.toPublic(user);
await this.sendVerification(user);
return { email: user.email, verificationPending: true };
}
// 로그인 검증 — 이메일/비밀번호 일치 확인
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
// 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내
// 인증 메일 재발송 — 사용자 존재/상태를 노출하지 않기 위해 항상 동일하게 응답(미인증 로컬만 실제 발송)
async resendVerification(email: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (user && user.provider === 'local' && !user.emailVerified) {
await this.sendVerification(user);
}
}
// 이메일 인증 — 토큰(원문) 해시로 사용자 조회 후 만료 검사하여 인증 완료 처리
async verifyEmail(token: string): Promise<void> {
if (!token) {
throw new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
'잘못된 인증 링크입니다.',
HttpStatus.BAD_REQUEST,
);
}
const tokenHash = this.hashToken(token);
const user = await this.userService.findByEmailVerifyTokenHash(tokenHash);
if (
!user ||
!user.emailVerifyExpiresAt ||
user.emailVerifyExpiresAt.getTime() < Date.now()
) {
throw new BusinessException(
'BIZ_001',
'인증 링크가 만료되었거나 유효하지 않습니다. 인증 메일을 다시 받아 주세요.',
HttpStatus.BAD_REQUEST,
);
}
await this.userService.markEmailVerified(user.id);
}
// 비밀번호 재설정 요청 — 존재/상태를 노출하지 않기 위해 항상 동일하게 응답.
// 로컬(이메일) 계정만 실제 재설정 메일 발송(소셜 전용은 비밀번호가 없음).
async requestPasswordReset(email: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (!user || user.provider !== 'local') {
return;
}
const rawToken = randomBytes(32).toString('hex');
const tokenHash = this.hashToken(rawToken);
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
await this.userService.setPasswordResetToken(user.id, tokenHash, expiresAt);
const resetUrl = `${this.webUrl()}/reset-password?token=${rawToken}`;
this.mailService.sendPasswordResetEmail(user.email, user.name, resetUrl);
}
// 비밀번호 재설정 — 토큰(원문) 해시로 조회 + 만료 검사 후 새 비밀번호 저장.
// 보안상 비밀번호 변경 시 해당 사용자의 모든 세션을 폐기(전 기기 강제 로그아웃).
async resetPassword(token: string, newPassword: string): Promise<void> {
if (!token) {
throw this.invalidResetLink();
}
const tokenHash = this.hashToken(token);
const user = await this.userService.findByPasswordResetTokenHash(tokenHash);
if (
!user ||
!user.passwordResetExpiresAt ||
user.passwordResetExpiresAt.getTime() < Date.now()
) {
throw this.invalidResetLink();
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
await this.userService.updatePassword(user.id, passwordHash);
await this.revokeAllSessions(user.id);
}
// 로그인 검증 — 자격 확인 + 소셜전용/미인증 분기
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user) {
throw this.invalidCredentials();
}
// 비밀번호 미설정 = 소셜 전용 계정 → 소셜 로그인 안내
if (user.passwordHash == null) {
throw new BusinessException(
'BIZ_001',
'소셜(구글/카카오) 계정으로 가입된 이메일입니다. 소셜 로그인을 이용해 주세요.',
HttpStatus.UNAUTHORIZED,
);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
throw this.invalidCredentials();
}
// 이메일 미인증 → 가입 미완료로 로그인 차단
if (!user.emailVerified) {
throw new BusinessException(
'BIZ_001',
'이메일 인증이 완료되지 않았습니다. 받은 인증 메일의 링크를 클릭해 가입을 완료해 주세요.',
HttpStatus.FORBIDDEN,
);
}
return UserService.toPublic(user);
}
// access/refresh 토큰 발급
async issueTokens(user: PublicUser): Promise<TokenPair> {
// 소셜 로그인 — 이메일로 기존 계정 매칭(있으면 로그인/인증보정), 없으면 신규 생성
async validateOrCreateOAuthUser(profile: OAuthProfile): Promise<PublicUser> {
if (!profile.email) {
throw new BusinessException(
'BIZ_001',
'소셜 계정에서 이메일을 가져오지 못했습니다. 이메일 제공에 동의한 뒤 다시 시도해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 제공자가 이메일을 검증하지 않았으면 거부 — 미검증 이메일로 타인 계정 탈취/선점 방지
if (!profile.emailVerified) {
throw new BusinessException(
'BIZ_001',
'소셜 계정의 이메일이 인증되지 않았습니다. 제공자에서 이메일 인증을 완료한 뒤 다시 시도해 주세요.',
HttpStatus.FORBIDDEN,
);
}
let user = await this.userService.findByEmail(profile.email);
if (!user) {
user = await this.userService.createOAuthUser({
email: profile.email,
name: profile.name,
provider: profile.provider,
avatarUrl: profile.avatarUrl ?? null,
});
} else if (!user.emailVerified) {
// 기존(미인증 로컬) 계정과 이메일이 같으면 소셜 인증으로 인증 완료 처리하고 로그인
await this.userService.markEmailVerified(user.id);
user.emailVerified = true;
}
return UserService.toPublic(user);
}
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급
async issueTokensForNewSession(user: PublicUser): Promise<TokenPair> {
// 만료된 이 사용자의 세션 정리(누적 방지)
await this.refreshRepo
.createQueryBuilder()
.delete()
.where('user_id = :userId', { userId: user.id })
.andWhere('expires_at < :now', { now: new Date() })
.execute();
const jti = randomUUID();
const session = await this.refreshRepo.save(
this.refreshRepo.create({
user: { id: user.id },
currentJti: jti,
prevJti: null,
prevAt: null,
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
}),
);
return this.signTokens(user.id, user.email, session.id, jti);
}
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
async rotateTokens(refreshToken: string | undefined): Promise<TokenPair> {
if (!refreshToken) throw new UnauthorizedException();
let payload: JwtPayload;
try {
payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException();
}
if (!payload.sub || !payload.fid || !payload.jti) {
throw new UnauthorizedException();
}
const session = await this.refreshRepo.findOne({
where: { id: payload.fid },
relations: ['user'],
});
if (!session || session.user?.id !== payload.sub) {
throw new UnauthorizedException();
}
// 1) 정상 회전 — 현재 jti 일치 → 새 jti 발급, 직전 jti 기록
if (payload.jti === session.currentJti) {
const newJti = randomUUID();
session.prevJti = session.currentJti;
session.prevAt = new Date();
session.currentJti = newJti;
session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS);
await this.refreshRepo.save(session);
return this.signTokens(
session.user.id,
session.user.email,
session.id,
newJti,
);
}
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
if (
payload.jti === session.prevJti &&
session.prevAt &&
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
) {
return this.signTokens(
session.user.id,
session.user.email,
session.id,
session.currentJti,
);
}
// 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃)
await this.refreshRepo.delete({ id: session.id });
throw new UnauthorizedException();
}
// 로그아웃 — refresh 토큰의 세션(패밀리)만 폐기. 무효 토큰이면 조용히 무시.
async revokeSession(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(
refreshToken,
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') },
);
if (payload?.fid) {
await this.refreshRepo.delete({ id: payload.fid });
}
} catch {
// 무효/만료 refresh 토큰 — 무시
}
}
// 사용자의 모든 세션 폐기 — 비밀번호 변경/계정 보안 시 호출(전 기기 강제 로그아웃). 현재 미사용.
async revokeAllSessions(userId: string): Promise<void> {
await this.refreshRepo
.createQueryBuilder()
.delete()
.where('user_id = :userId', { userId })
.execute();
}
// access/refresh 토큰 서명 — refresh 에는 세션 fid + 현재 jti 를 담는다
private async signTokens(
sub: string,
email: string | undefined,
fid: string,
jti: string,
): Promise<TokenPair> {
const accessToken = await this.jwtService.signAsync(
{ sub: user.id, email: user.email },
{ sub, email },
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
@@ -71,7 +334,7 @@ export class AuthService {
},
);
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id },
{ sub, fid, jti },
{
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
@@ -80,4 +343,50 @@ export class AuthService {
);
return { accessToken, refreshToken };
}
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
private async sendVerification(user: User): Promise<void> {
const rawToken = randomBytes(32).toString('hex');
const tokenHash = this.hashToken(rawToken);
const expiresAt = new Date(Date.now() + VERIFY_TTL_MS);
await this.userService.setEmailVerifyToken(user.id, tokenHash, expiresAt);
const apiUrl = (
this.config.get<string>('APP_API_URL') ?? 'http://localhost:3100/api'
).replace(/\/$/, '');
const verifyUrl = `${apiUrl}/auth/verify-email?token=${rawToken}`;
this.mailService.sendVerificationEmail(user.email, user.name, verifyUrl);
}
// 토큰 원문 → SHA-256 해시(원문은 메일 링크에만, DB엔 해시만 저장)
private hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
// 프론트엔드(웹) 기준 URL — 비밀번호 재설정 폼 링크 대상(컨트롤러 webUrl 과 동일 정책)
private webUrl(): string {
const url =
this.config.get<string>('FRONTEND_URL') ||
this.config.get<string>('CORS_ORIGIN') ||
'http://localhost:5273';
return url.split(',')[0].trim().replace(/\/$/, '');
}
// 재설정 링크 무효/만료 공통 예외
private invalidResetLink(): BusinessException {
return new BusinessException(
'BIZ_001',
'비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다. 다시 요청해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 자격 불일치 공통 예외(이메일/비밀번호 구분 없이 동일 문구 — 계정 존재 노출 방지)
private invalidCredentials(): BusinessException {
return new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
HttpStatus.UNAUTHORIZED,
);
}
}
@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 비밀번호 재설정 요청 DTO — 대상 이메일
export class ForgotPasswordDto {
@ApiProperty({
description: '비밀번호 재설정 메일을 받을 이메일',
example: 'sykim@acme.co',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
}
@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 인증 메일 재발송 DTO — 대상 이메일
export class ResendVerificationDto {
@ApiProperty({
description: '인증 메일을 받을 이메일',
example: 'sykim@acme.co',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
}
@@ -0,0 +1,29 @@
import {
IsNotEmpty,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 비밀번호 재설정 DTO — 메일 링크의 토큰 + 새 비밀번호(가입과 동일 규칙)
export class ResetPasswordDto {
@ApiProperty({ description: '재설정 토큰(메일 링크의 token 값)' })
@IsString()
@IsNotEmpty({ message: '재설정 토큰이 필요합니다.' })
token!: string;
@ApiProperty({
description: '새 비밀번호 (8~72자, 영문·숫자 포함)',
example: 'newpass123',
})
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
})
password!: string;
}
+11 -1
View File
@@ -3,6 +3,8 @@ import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
@@ -14,9 +16,17 @@ export class SignupDto {
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@ApiProperty({
description: '비밀번호 (8~72자, 영문·숫자 포함)',
example: 'password123',
})
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
})
password!: string;
@ApiProperty({ description: '사용자 이름', example: '김서연' })
@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// refresh 토큰 세션(패밀리) — 회전형 refresh 토큰 + 재사용 탐지의 단위.
// 한 로그인(기기)당 1행. id=패밀리 식별자(fid), currentJti=현재 유효한 refresh 토큰 표식.
// 갱신 시 currentJti 를 새로 발급하고, 이미 회전된(옛) 토큰이 다시 제출되면 탈취로 간주해
// 행을 삭제(패밀리 폐기)한다. 동시 갱신(재시도) 오탐 방지를 위해 직전 jti 를 짧은 유예 동안 허용.
@Entity('refresh_sessions')
export class RefreshSession {
// 패밀리 식별자(fid) — refresh JWT payload 에 담긴다
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user!: Relation<User>;
// 현재 유효한 refresh 토큰의 jti
@Column({ name: 'current_jti', type: 'uuid' })
currentJti!: string;
// 직전 jti + 회전 시각 — 동시 갱신(재시도) 유예 판정용
@Column({ name: 'prev_jti', type: 'uuid', nullable: true })
prevJti!: string | null;
@Column({ name: 'prev_at', type: 'timestamptz', nullable: true })
prevAt!: Date | null;
// 세션 만료(refresh TTL) — 만료분 정리용
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,6 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
// refresh 토큰 기반 가드 — /auth/refresh 전용
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
@@ -0,0 +1,50 @@
import { randomBytes } from 'crypto';
import type { Request } from 'express';
// OAuth CSRF 방지용 state 저장소 — 세션을 쓰지 않는 무상태(JWT) 구조라
// passport-oauth2 의 기본 SessionStore 대신 httpOnly 쿠키에 1회용 난수 state 를 보관한다.
// 인가 시작 시 store() 로 쿠키 발급 + state 반환(인가 URL 에 부착), 콜백에서 verify() 로
// 쿠키와 제공자 반환 state 일치를 확인한다(불일치 시 인증 거부).
const STATE_COOKIE = 'oauth_state';
const STATE_TTL_MS = 10 * 60 * 1000; // 10분
type StoreCallback = (err: Error | null, state?: string) => void;
type VerifyCallback = (
err: Error | null,
ok?: boolean,
info?: { message: string },
) => void;
function cookieOptions() {
const isProd = process.env.NODE_ENV === 'production';
return {
httpOnly: true,
secure: isProd,
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: STATE_TTL_MS,
};
}
export class CookieStateStore {
// passport 버전에 따라 (req, cb) 또는 (req, meta, cb) 로 호출되므로 마지막 인자를 콜백으로 사용
store(req: Request, ...rest: unknown[]): void {
const cb = rest[rest.length - 1] as StoreCallback;
const state = randomBytes(16).toString('hex');
req.res?.cookie(STATE_COOKIE, state, cookieOptions());
cb(null, state);
}
verify(req: Request, providedState: string, ...rest: unknown[]): void {
const cb = rest[rest.length - 1] as VerifyCallback;
const cookies = req.cookies as Record<string, string> | undefined;
const expected = cookies?.[STATE_COOKIE];
// 1회용 — 검증 직후 쿠키 제거
req.res?.clearCookie(STATE_COOKIE, { path: '/api/auth' });
if (!expected || expected !== providedState) {
cb(null, false, { message: 'OAuth state 불일치' });
return;
}
cb(null, true);
}
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import {
Strategy,
type Profile,
type StrategyOptions,
} from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
import { CookieStateStore } from './cookie-state.store';
import type { PublicUser } from '../../user/user.service';
// 구글 OAuth 전략 — 콜백에서 프로필을 정규화해 AuthService 로 위임.
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
const options: StrategyOptions & { store: CookieStateStore } = {
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
store: new CookieStateStore(),
};
super(options);
}
// 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
): Promise<PublicUser> {
const email = profile.emails?.[0]?.value ?? null;
const name = profile.displayName || email?.split('@')[0] || '구글 사용자';
const avatarUrl = profile.photos?.[0]?.value ?? null;
// 구글이 이메일 소유를 검증했는지(email_verified)
const json = profile._json as { email_verified?: boolean } | undefined;
const emailVerified = json?.email_verified === true;
return this.authService.validateOrCreateOAuthUser({
provider: 'google',
email,
emailVerified,
name,
avatarUrl,
});
}
}
@@ -1,35 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { UserService, type PublicUser } from '../../user/user.service';
import type { JwtPayload } from '../types/jwt-payload';
// refresh 토큰 검증 전략 — refresh_token 쿠키에서 토큰 추출
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(
config: ConfigService,
private readonly userService: UserService,
) {
super({
jwtFromRequest: (req: Request): string | null =>
(req?.cookies as Record<string, string> | undefined)?.refresh_token ??
null,
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<PublicUser> {
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return UserService.toPublic(user);
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, type KakaoProfile } from 'passport-kakao';
import { AuthService } from '../auth.service';
import { CookieStateStore } from './cookie-state.store';
import type { PublicUser } from '../../user/user.service';
// 카카오 OAuth 전략 — 콜백 프로필을 정규화해 AuthService 로 위임.
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: config.getOrThrow<string>('KAKAO_CLIENT_ID'),
clientSecret: config.get<string>('KAKAO_CLIENT_SECRET') ?? '',
callbackURL: config.getOrThrow<string>('KAKAO_CALLBACK_URL'),
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
store: new CookieStateStore(),
});
}
// 카카오 프로필(_json)에서 이메일/닉네임/프로필이미지 정규화 후 위임
async validate(
_accessToken: string,
_refreshToken: string,
profile: KakaoProfile,
): Promise<PublicUser> {
const account = profile._json?.kakao_account;
const email = account?.email ?? null;
// 카카오가 이메일 보유+검증을 확인했는지(is_email_verified)
const emailVerified = account?.is_email_verified === true;
const name =
account?.profile?.nickname ||
profile._json?.properties?.nickname ||
profile.displayName ||
email?.split('@')[0] ||
'카카오 사용자';
const avatarUrl =
account?.profile?.profile_image_url ??
profile._json?.properties?.profile_image ??
null;
return this.authService.validateOrCreateOAuthUser({
provider: 'kakao',
email,
emailVerified,
name,
avatarUrl,
});
}
}
@@ -4,4 +4,8 @@ export interface JwtPayload {
sub: string;
// 이메일 (access 토큰에만 포함)
email?: string;
// 세션(패밀리) id (refresh 토큰에만 포함)
fid?: string;
// 현재 토큰 표식 jti (refresh 토큰에만 포함) — RefreshSession.currentJti 와 일치해야 유효
jti?: string;
}
+55
View File
@@ -0,0 +1,55 @@
// passport-kakao 공식 타입 미제공 — 사용 범위에 맞춘 최소 선언.
declare module 'passport-kakao' {
export interface KakaoProfile {
id: number | string;
username?: string;
displayName?: string;
_json?: {
id: number;
kakao_account?: {
email?: string;
// 카카오 이메일 보유/검증 플래그 — 미검증 이메일은 신뢰하지 않는다
has_email?: boolean;
is_email_verified?: boolean;
profile?: {
nickname?: string;
profile_image_url?: string;
thumbnail_image_url?: string;
};
};
properties?: {
nickname?: string;
profile_image?: string;
thumbnail_image?: string;
};
};
}
export interface StrategyOptions {
clientID: string;
clientSecret?: string;
callbackURL: string;
// passport-oauth2 호환 state 저장소(OAuth CSRF 방지)
store?: unknown;
}
export type VerifyCallback = (
error: unknown,
user?: unknown,
info?: unknown,
) => void;
export class Strategy {
constructor(
options: StrategyOptions,
verify: (
accessToken: string,
refreshToken: string,
profile: KakaoProfile,
done: VerifyCallback,
) => void,
);
name: string;
authenticate(req: unknown, options?: unknown): void;
}
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { GiteaService } from './gitea.service';
// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공
@Module({
providers: [GiteaService],
exports: [GiteaService],
})
export class GiteaModule {}
@@ -1,178 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { GiteaApiError, GiteaService } from './gitea.service';
// ConfigService 간이 스텁 — 주어진 맵에서만 값을 반환
function configStub(values: Record<string, string>): ConfigService {
return {
get: (key: string): string | undefined => values[key],
} as unknown as ConfigService;
}
// fetch 응답 간이 모킹
function mockResponse(status: number, body: unknown): Promise<Response> {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : ''),
} as Response);
}
describe('GiteaService', () => {
const fullConfig = {
GITEA_BASE_URL: 'https://gitea.example.com/', // 끝 슬래시 포함 — 제거되어야 함
GITEA_TOKEN: 'secret-token',
GITEA_ORG: 'marketing-team',
};
afterEach(() => {
jest.restoreAllMocks();
});
describe('enabled', () => {
it('필수 env 가 모두 있으면 활성화', () => {
const svc = new GiteaService(configStub(fullConfig));
expect(svc.enabled).toBe(true);
expect(svc.org).toBe('marketing-team');
});
it('하나라도 비면 비활성화', () => {
const svc = new GiteaService(
configStub({ GITEA_BASE_URL: 'https://gitea.example.com' }),
);
expect(svc.enabled).toBe(false);
});
});
describe('createRepo', () => {
it('조직 경로로 POST 하고 응답을 매핑한다(베이스 URL 끝 슬래시 제거)', async () => {
const res = mockResponse(201, {
id: 42,
name: 'q3-launch',
full_name: 'marketing-team/q3-launch',
description: 'q3 — 런칭',
private: true,
clone_url: 'https://gitea.example.com/marketing-team/q3-launch.git',
html_url: 'https://gitea.example.com/marketing-team/q3-launch',
default_branch: 'main',
});
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
const svc = new GiteaService(configStub(fullConfig));
const result = await svc.createRepo({
name: 'q3-launch',
description: '런칭',
private: true,
defaultBranch: 'main',
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(
'https://gitea.example.com/api/v1/orgs/marketing-team/repos',
);
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toMatchObject({
name: 'q3-launch',
description: '런칭',
private: true,
default_branch: 'main',
auto_init: true,
});
expect(result).toEqual({
id: 42,
name: 'q3-launch',
fullName: 'marketing-team/q3-launch',
description: 'q3 — 런칭',
private: true,
cloneUrl: 'https://gitea.example.com/marketing-team/q3-launch.git',
htmlUrl: 'https://gitea.example.com/marketing-team/q3-launch',
defaultBranch: 'main',
});
});
it('실패 응답은 상태코드를 보존한 GiteaApiError 로 던진다', async () => {
jest
.spyOn(global, 'fetch')
.mockReturnValue(mockResponse(409, 'conflict'));
const svc = new GiteaService(configStub(fullConfig));
const call = () =>
svc.createRepo({
name: 'dup',
description: '',
private: false,
defaultBranch: 'main',
});
await expect(call()).rejects.toBeInstanceOf(GiteaApiError);
await expect(call()).rejects.toMatchObject({ status: 409 });
});
});
describe('listOrgRepos', () => {
it('조직 저장소를 페이지네이션 순회하며 매핑한다', async () => {
const page1 = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `repo-${i + 1}`,
full_name: `marketing-team/repo-${i + 1}`,
description: '',
private: false,
clone_url: `https://gitea.example.com/marketing-team/repo-${i + 1}.git`,
html_url: `https://gitea.example.com/marketing-team/repo-${i + 1}`,
default_branch: 'main',
}));
const page2 = [
{
id: 51,
name: 'repo-51',
full_name: 'marketing-team/repo-51',
description: '표시명 — 설명',
private: true,
clone_url: 'https://gitea.example.com/marketing-team/repo-51.git',
html_url: 'https://gitea.example.com/marketing-team/repo-51',
default_branch: 'develop',
},
];
const fetchMock = jest
.spyOn(global, 'fetch')
.mockReturnValueOnce(mockResponse(200, page1))
.mockReturnValueOnce(mockResponse(200, page2));
const svc = new GiteaService(configStub(fullConfig));
const result = await svc.listOrgRepos();
// 첫 페이지가 한도(50)면 다음 페이지까지 요청
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(
'https://gitea.example.com/api/v1/orgs/marketing-team/repos?page=1&limit=50',
);
expect(result).toHaveLength(51);
expect(result[50]).toEqual({
id: 51,
name: 'repo-51',
fullName: 'marketing-team/repo-51',
description: '표시명 — 설명',
private: true,
cloneUrl: 'https://gitea.example.com/marketing-team/repo-51.git',
htmlUrl: 'https://gitea.example.com/marketing-team/repo-51',
defaultBranch: 'develop',
});
});
});
describe('deleteRepo', () => {
it('repo 경로로 DELETE 한다', async () => {
const res = mockResponse(204, '');
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
const svc = new GiteaService(configStub(fullConfig));
await svc.deleteRepo('q3-launch');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(
'https://gitea.example.com/api/v1/repos/marketing-team/q3-launch',
);
expect(init?.method).toBe('DELETE');
});
});
});
-220
View File
@@ -1,220 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// Gitea 저장소 생성/조회 결과(우리가 보관하는 핵심 필드만 추림)
export interface GiteaRepoResult {
id: number;
name: string; // 저장소 이름(= Relay slugName)
fullName: string; // 'org/slug'
description: string; // 설명(없으면 빈 문자열)
private: boolean; // 비공개 여부
cloneUrl: string; // HTTPS clone URL
htmlUrl: string; // 웹 UI URL
defaultBranch: string;
}
// Gitea API 호출 실패 — 상태 코드를 보존해 호출부에서 분기(예: 409 → 이름 충돌)
export class GiteaApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = 'GiteaApiError';
}
}
// Gitea REST API(/api/v1) 응답 중 사용하는 필드만 정의
interface GiteaRepoApi {
id: number;
name: string;
full_name: string;
description: string;
private: boolean;
clone_url: string;
html_url: string;
default_branch: string;
}
// 외부 Gitea 인스턴스와 통신하는 얇은 API 클라이언트.
// 조직(GITEA_ORG) 아래 저장소를 생성/수정/삭제한다.
// 필수 env(GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG)가 비어 있으면 비활성화 상태가 되어,
// 저장소 CRUD 는 Gitea 호출 없이 기존과 동일하게 동작한다(로컬/미연동 환경 대비).
@Injectable()
export class GiteaService {
private readonly logger = new Logger(GiteaService.name);
private readonly baseUrl: string; // 끝 슬래시 제거된 형태
private readonly token: string;
readonly org: string;
// 템플릿 저장소(이 저장소를 복제해 새 repo 생성). 기본값: TtiPo/project-base
readonly templateOwner: string;
readonly templateRepo: string;
constructor(config: ConfigService) {
this.baseUrl = (config.get<string>('GITEA_BASE_URL') ?? '').replace(
/\/+$/,
'',
);
this.token = config.get<string>('GITEA_TOKEN') ?? '';
this.org = config.get<string>('GITEA_ORG') ?? '';
this.templateOwner = config.get<string>('GITEA_TEMPLATE_OWNER') ?? 'TtiPo';
this.templateRepo =
config.get<string>('GITEA_TEMPLATE_REPO') ?? 'project-base';
if (this.enabled) {
this.logger.log(`Gitea 연동 활성화 — ${this.baseUrl} (org: ${this.org})`);
} else {
this.logger.warn(
'Gitea 연동 비활성화 — GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG 미설정. 저장소는 Gitea 동기화 없이 동작합니다.',
);
}
}
// 필수 설정이 모두 있으면 연동 활성화
get enabled(): boolean {
return Boolean(this.baseUrl && this.token && this.org);
}
// 템플릿 식별자 (owner/repo) — 프론트 표시·로깅용
get templateSlug(): string {
return `${this.templateOwner}/${this.templateRepo}`;
}
// 템플릿 복제 사용 가능 여부 — 연동 활성 + 템플릿 owner/repo 설정
get templateEnabled(): boolean {
return this.enabled && Boolean(this.templateOwner && this.templateRepo);
}
// 조직 아래 저장소 생성 (초기 커밋/기본 브랜치 포함)
async createRepo(input: {
name: string; // = Relay slugName
description: string;
private: boolean;
defaultBranch: string;
}): Promise<GiteaRepoResult> {
const data = await this.request<GiteaRepoApi>(
'POST',
`/orgs/${encodeURIComponent(this.org)}/repos`,
{
name: input.name,
description: input.description,
private: input.private,
default_branch: input.defaultBranch,
auto_init: true, // 초기 커밋 + 기본 브랜치 생성
},
);
return this.toResult(data);
}
// 템플릿 저장소를 복제해 조직 아래 새 저장소 생성 (git 콘텐츠 포함).
// 생성된 저장소의 기본 브랜치는 템플릿을 따른다.
async generateFromTemplate(input: {
name: string; // = Relay slugName
description: string;
private: boolean;
}): Promise<GiteaRepoResult> {
const data = await this.request<GiteaRepoApi>(
'POST',
`/repos/${encodeURIComponent(this.templateOwner)}/${encodeURIComponent(
this.templateRepo,
)}/generate`,
{
owner: this.org, // 대상 조직
name: input.name,
description: input.description,
private: input.private,
git_content: true, // 템플릿의 파일/폴더 구조 복제
},
);
return this.toResult(data);
}
// 저장소 메타데이터(설명/공개범위/기본 브랜치) 수정 — slug(name) 는 변경하지 않음.
// default_branch 는 Gitea 에 실제로 존재하는 브랜치여야 한다(없으면 Gitea 가 에러).
async updateRepo(
name: string,
patch: { description?: string; private?: boolean; defaultBranch?: string },
): Promise<void> {
await this.request<GiteaRepoApi>(
'PATCH',
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
{
description: patch.description,
private: patch.private,
default_branch: patch.defaultBranch,
},
);
}
// 저장소 삭제
async deleteRepo(name: string): Promise<void> {
await this.request<null>(
'DELETE',
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
);
}
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용
async listOrgRepos(): Promise<GiteaRepoResult[]> {
const limit = 50; // Gitea 기본 최대 페이지 크기
const all: GiteaRepoResult[] = [];
for (let page = 1; ; page++) {
const data = await this.request<GiteaRepoApi[]>(
'GET',
`/orgs/${encodeURIComponent(this.org)}/repos?page=${page}&limit=${limit}`,
);
if (!data || data.length === 0) break;
all.push(...data.map((d) => this.toResult(d)));
// 마지막 페이지(요청 한도 미만)면 종료
if (data.length < limit) break;
}
return all;
}
// --- 내부 헬퍼 ---
// Gitea API 공통 요청 — 토큰 헤더 부착, 실패 시 GiteaApiError
private async request<T>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
body?: Record<string, unknown>,
): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `token ${this.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new GiteaApiError(
res.status,
`Gitea ${method} ${path} 실패 → ${res.status} ${detail}`,
);
}
// 204 No Content(삭제 등) 또는 빈 본문 대응
if (res.status === 204) {
return null as T;
}
return (await res.json()) as T;
}
// API 응답 → 보관용 결과 매핑
private toResult(data: GiteaRepoApi): GiteaRepoResult {
return {
id: data.id,
name: data.name,
fullName: data.full_name,
description: data.description ?? '',
private: data.private,
cloneUrl: data.clone_url,
htmlUrl: data.html_url,
defaultBranch: data.default_branch,
};
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
// 메일 모듈 — 현재는 로그 기반 플레이스홀더(MailService) 제공.
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+61
View File
@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// 메일 발송 서비스 (플레이스홀더) —
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다.
// 추후 nodemailer 등 도입 시 이 서비스 내부 구현만 교체하면 호출부는 그대로 유지된다.
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private readonly config: ConfigService) {}
// 가입 인증 메일 — 개발 환경에서만 인증 링크(원문 토큰 포함)를 로그로 출력한다.
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
if (isProd) {
this.logger.log(
`[메일 발송 - 가입 인증] 수신자=${to} (인증 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
);
return;
}
this.logger.log(
[
'',
'──────────────── [메일 발송 - 가입 인증] ────────────────',
`받는 사람 : ${name} <${to}>`,
'제목 : [Relay] 이메일 인증을 완료해 주세요',
'본문 : 아래 링크를 클릭하면 회원가입이 완료됩니다.',
`인증 링크 : ${verifyUrl}`,
'──────────────────────────────────────────────────────',
'',
].join('\n'),
);
}
// 비밀번호 재설정 메일 — 개발 환경에서만 재설정 링크(원문 토큰 포함)를 로그로 출력한다.
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
sendPasswordResetEmail(to: string, name: string, resetUrl: string): void {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
if (isProd) {
this.logger.log(
`[메일 발송 - 비밀번호 재설정] 수신자=${to} (재설정 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
);
return;
}
this.logger.log(
[
'',
'──────────────── [메일 발송 - 비밀번호 재설정] ────────────────',
`받는 사람 : ${name} <${to}>`,
'제목 : [Relay] 비밀번호 재설정 안내',
'본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)',
`재설정 링크 : ${resetUrl}`,
'안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.',
'────────────────────────────────────────────────────────────',
'',
].join('\n'),
);
}
}
@@ -9,7 +9,7 @@ import {
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 알림 타입 — 수신자 관점의 이벤트(활동 피드와 별개: 활동은 저장소 전체 로그, 알림은 개인 수신함)
// 알림 타입 — 수신자 관점의 이벤트(활동 피드와 별개: 활동은 프로젝트 전체 로그, 알림은 개인 수신함)
export type NotificationType =
| 'task.assigned' // 나에게 업무가 지시됨
| 'task.unassigned' // 업무 담당에서 제외됨
@@ -20,9 +20,9 @@ export type NotificationType =
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
| 'task.removed' // 내 관련 업무가 삭제됨
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
| 'member.invited' // 저장소에 멤버로 초대됨
| 'member.role_changed' // 저장소 내 내 역할이 변경됨
| 'member.removed'; // 저장소에서 제외됨
| 'member.invited' // 프로젝트에 멤버로 초대됨
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
| 'member.removed'; // 프로젝트에서 제외됨
// 알림 — 사용자별 수신함. 표시 문구/아이콘은 프론트가 type+payload 로 조립한다.
@Entity('notifications')
@@ -0,0 +1,20 @@
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 프로젝트 생성 DTO
export class CreateProjectDto {
@ApiProperty({
description: '표시 제목',
example: '3분기 신제품 런칭 캠페인',
})
@IsString()
@IsNotEmpty({ message: '프로젝트 이름을 입력해 주세요.' })
@MaxLength(60)
name!: string;
@ApiProperty({ description: '프로젝트 설명', required: false })
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
}
@@ -1,9 +1,9 @@
import { IsEmail, IsIn, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { MemberRoleType } from '../entities/repo-member.entity';
import type { ProjectRoleType } from '../entities/project-member.entity';
// 멤버 초대 DTO — 가입된 사용자를 이메일로 초대(별도 초대 메일/계정 생성 없음)
export class InviteMemberDto {
// 프로젝트 멤버 초대 DTO — 가입된 사용자를 이메일로 초대
export class InviteProjectMemberDto {
@ApiProperty({
description: '초대할 사용자 이메일(이미 가입된 사용자)',
example: 'sykim@acme.co',
@@ -18,5 +18,5 @@ export class InviteMemberDto {
example: 'member',
})
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
roleType!: MemberRoleType;
roleType!: ProjectRoleType;
}
@@ -1,9 +1,9 @@
import { IsIn, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { MemberRoleType } from '../entities/repo-member.entity';
import type { ProjectRoleType } from '../entities/project-member.entity';
// 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가)
export class UpdateMemberDto {
// 프로젝트 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가)
export class UpdateProjectMemberDto {
@ApiProperty({
description: '권한 역할',
enum: ['admin', 'member'],
@@ -11,5 +11,5 @@ export class UpdateMemberDto {
})
@IsOptional()
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
roleType?: MemberRoleType;
roleType?: ProjectRoleType;
}
@@ -0,0 +1,17 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 프로젝트 수정 DTO — 제공된 필드만 갱신
export class UpdateProjectDto {
@ApiProperty({ description: '표시 제목', required: false })
@IsOptional()
@IsString()
@MaxLength(60)
name?: string;
@ApiProperty({ description: '프로젝트 설명', required: false })
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
}
@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Project } from './project.entity';
// 프로젝트 내 권한 역할
export type ProjectRoleType = 'admin' | 'member';
// 프로젝트 멤버 (Project ↔ User 조인 + 역할). 권한·담당자 풀의 기준 단위.
@Entity('project_members')
@Unique(['project', 'user'])
export class ProjectMember {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
@ManyToOne(() => Project, (project) => project.members, {
onDelete: 'CASCADE',
})
project!: Relation<Project>;
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user!: User;
// 권한 역할 (admin: 프로젝트 관리, member: 일반)
@Column({ name: 'role_type', type: 'varchar', default: 'member' })
roleType!: ProjectRoleType;
// 소유자 여부(프로젝트 생성자) — 역할 변경/제거 불가, 프로젝트 삭제 권한
@Column({ name: 'is_owner', default: false })
isOwner!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { ProjectMember } from './project-member.entity';
// 프로젝트 엔티티 — 작업의 기본 단위. 업무·멤버·활동이 프로젝트에 연동된다.
@Entity('projects')
export class Project {
// 내부 식별자 (UUID) — 라우팅에도 사용
@PrimaryGeneratedColumn('uuid')
id!: string;
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
@Column()
name!: string;
// 설명(선택)
@Column({ type: 'varchar', nullable: true })
description!: string | null;
// 멤버 목록
@OneToMany(() => ProjectMember, (member) => member.project)
members!: Relation<ProjectMember>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -8,6 +8,7 @@ import {
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
@@ -17,28 +18,30 @@ import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { MemberService, type RepoMemberResponse } from './member.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { InviteMemberDto } from './dto/invite-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
import {
ProjectMemberService,
type ProjectMemberResponse,
} from './project-member.service';
import { InviteProjectMemberDto } from './dto/invite-member.dto';
import { UpdateProjectMemberDto } from './dto/update-member.dto';
// 저장소 멤버 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName)
@ApiTags('Member')
@Controller('repos/:repoId/members')
// 프로젝트 멤버 컨트롤러 — 모든 엔드포인트 인증 필요
@ApiTags('Project')
@Controller('projects/:projectId/members')
@UseGuards(JwtAuthGuard)
export class MemberController {
constructor(private readonly memberService: MemberService) {}
export class ProjectMemberController {
constructor(private readonly memberService: ProjectMemberService) {}
@Get()
@ApiOperation({ summary: '저장소 멤버 목록 (페이지네이션)' })
@ApiOperation({ summary: '프로젝트 멤버 목록 (페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(
@Param('repoId') repoId: string,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<RepoMemberResponse>> {
return this.memberService.list(repoId, page, size);
): Promise<PaginatedResult<ProjectMemberResponse>> {
return this.memberService.list(projectId, page, size);
}
@Post()
@@ -46,30 +49,26 @@ export class MemberController {
@ApiOperation({ summary: '멤버 초대 (admin)' })
@ApiResponse({ status: 201, description: '초대 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 404,
description: '가입된 사용자를 찾을 수 없음 (BIZ_001)',
})
@ApiResponse({ status: 409, description: '이미 멤버 (BIZ_001)' })
invite(
@Param('repoId') repoId: string,
@Body() dto: InviteMemberDto,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: InviteProjectMemberDto,
@CurrentUser() user: PublicUser,
): Promise<RepoMemberResponse> {
return this.memberService.invite(repoId, user.id, dto);
): Promise<ProjectMemberResponse> {
return this.memberService.invite(projectId, user.id, dto);
}
@Patch(':userId')
@ApiOperation({ summary: '멤버 역할/직무 변경 (admin, owner 제외)' })
@ApiOperation({ summary: '멤버 역할 변경 (admin, owner 제외)' })
@ApiResponse({ status: 200, description: '변경 성공' })
@ApiResponse({ status: 403, description: '권한 없음 / 소유자 변경 불가' })
update(
@Param('repoId') repoId: string,
@Param('userId') userId: string,
@Body() dto: UpdateMemberDto,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('userId', ParseUUIDPipe) userId: string,
@Body() dto: UpdateProjectMemberDto,
@CurrentUser() user: PublicUser,
): Promise<RepoMemberResponse> {
return this.memberService.update(repoId, user.id, userId, dto);
): Promise<ProjectMemberResponse> {
return this.memberService.update(projectId, user.id, userId, dto);
}
@Delete(':userId')
@@ -78,11 +77,11 @@ export class MemberController {
@ApiResponse({ status: 200, description: '제거 성공' })
@ApiResponse({ status: 403, description: '권한 없음 / 소유자 제거 불가' })
async remove(
@Param('repoId') repoId: string,
@Param('userId') userId: string,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('userId', ParseUUIDPipe) userId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.memberService.remove(repoId, user.id, userId);
await this.memberService.remove(projectId, user.id, userId);
return null;
}
}
@@ -0,0 +1,280 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Project } from './entities/project.entity';
import {
ProjectMember,
type ProjectRoleType,
} from './entities/project-member.entity';
import { Task } from '../task/entities/task.entity';
import { ActivityService } from '../activity/activity.service';
import { NotificationService } from '../notification/notification.service';
import { InviteProjectMemberDto } from './dto/invite-member.dto';
import { UpdateProjectMemberDto } from './dto/update-member.dto';
// 프로젝트 멤버 응답 — isYou 는 프론트가 현재 사용자와 비교해 산출
export interface ProjectMemberResponse {
user: PublicUser;
email: string;
taskCount: number; // 프로젝트 내 담당 업무 수
roleType: ProjectRoleType;
owner: boolean;
}
// 프로젝트 멤버 관리 비즈니스 로직 — 초대/역할변경/제거 시 활동 적재·알림 발송
@Injectable()
export class ProjectMemberService {
constructor(
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
private readonly userService: UserService,
private readonly activity: ActivityService,
private readonly notification: NotificationService,
) {}
// 멤버 목록 — 인증 사용자면 열람. 쓰기는 admin 가드.
async list(
projectId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<ProjectMemberResponse>> {
const project = await this.getProjectOrThrow(projectId);
const pg = resolvePage(page, size);
const [members, total] = await this.memberRepo.findAndCount({
where: { project: { id: project.id } },
relations: ['user'],
order: { createdAt: 'ASC' },
skip: pg.skip,
take: pg.take,
});
const counts = await this.assigneeCounts(project.id);
return paginated(
members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)),
total,
pg,
);
}
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
async invite(
projectId: string,
actingUserId: string,
dto: InviteProjectMemberDto,
): Promise<ProjectMemberResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertAdmin(project.id, actingUserId);
const user = await this.userService.findByEmail(dto.email);
if (!user) {
throw new BusinessException(
'BIZ_001',
'해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.',
HttpStatus.NOT_FOUND,
);
}
const existing = await this.memberRepo.findOne({
where: { project: { id: project.id }, user: { id: user.id } },
});
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 프로젝트에 속한 멤버입니다.',
HttpStatus.CONFLICT,
);
}
const member = this.memberRepo.create({
project,
user,
roleType: dto.roleType,
isOwner: false,
});
const saved = await this.memberRepo.save(member);
saved.user = user;
// 활동 적재 + 초대 대상에게 알림
await this.activity.record({
projectId: project.id,
actorId: actingUserId,
type: 'member.invited',
payload: { targetName: user.name },
});
await this.notification.notify({
recipientIds: [user.id],
actorId: actingUserId,
type: 'member.invited',
payload: {
projectId: project.id,
projectName: project.name,
actorName: await this.actorName(actingUserId),
},
});
return this.toResponse(saved);
}
// 멤버 역할 변경 — admin 권한. 소유자는 변경 불가
async update(
projectId: string,
actingUserId: string,
targetUserId: string,
dto: UpdateProjectMemberDto,
): Promise<ProjectMemberResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertAdmin(project.id, actingUserId);
const member = await this.getMemberOrThrow(project.id, targetUserId);
if (member.isOwner) {
throw new BusinessException(
'BIZ_001',
'소유자의 권한은 변경할 수 없습니다.',
HttpStatus.FORBIDDEN,
);
}
if (dto.roleType !== undefined) member.roleType = dto.roleType;
await this.memberRepo.save(member);
// 활동 적재 + 역할 변경 대상에게 알림
await this.activity.record({
projectId: project.id,
actorId: actingUserId,
type: 'member.role_changed',
payload: { targetName: member.user.name, roleType: member.roleType },
});
await this.notification.notify({
recipientIds: [targetUserId],
actorId: actingUserId,
type: 'member.role_changed',
payload: {
projectId: project.id,
projectName: project.name,
roleType: member.roleType,
actorName: await this.actorName(actingUserId),
},
});
return this.toResponse(member);
}
// 멤버 제거 — admin 권한. 소유자는 제거 불가
async remove(
projectId: string,
actingUserId: string,
targetUserId: string,
): Promise<void> {
const project = await this.getProjectOrThrow(projectId);
await this.assertAdmin(project.id, actingUserId);
const member = await this.getMemberOrThrow(project.id, targetUserId);
if (member.isOwner) {
throw new BusinessException(
'BIZ_001',
'소유자는 프로젝트에서 제거할 수 없습니다.',
HttpStatus.FORBIDDEN,
);
}
await this.memberRepo.remove(member);
// 활동 적재 + 제외 대상에게 알림(remove 직후 메모리의 user 사용)
await this.activity.record({
projectId: project.id,
actorId: actingUserId,
type: 'member.removed',
payload: { targetName: member.user.name },
});
await this.notification.notify({
recipientIds: [targetUserId],
actorId: actingUserId,
type: 'member.removed',
payload: {
projectId: project.id,
projectName: project.name,
actorName: await this.actorName(actingUserId),
},
});
}
// --- 내부 헬퍼 ---
// 프로젝트 내 사용자별 담당 업무 수 — 멤버 목록 taskCount 집계용
private async assigneeCounts(
projectId: string,
): Promise<Map<string, number>> {
const rows = await this.taskRepo
.createQueryBuilder('task')
.innerJoin('task_assignees', 'ta', 'ta.task_id = task.id')
.select('ta.user_id', 'userId')
.addSelect('COUNT(*)', 'count')
.where('task.project_id = :projectId', { projectId })
.groupBy('ta.user_id')
.getRawMany<{ userId: string; count: string }>();
const map = new Map<string, number>();
for (const r of rows) map.set(r.userId, Number(r.count));
return map;
}
private async getProjectOrThrow(projectId: string): Promise<Project> {
const project = await this.projectRepo.findOne({
where: { id: projectId },
});
if (!project) {
throw new NotFoundException();
}
return project;
}
// 행위자 표시 이름 — 알림 payload 의 actorName 용. 없으면 '' (프론트가 '누군가'로 폴백)
private async actorName(userId: string): Promise<string> {
const user = await this.userService.findById(userId);
return user?.name ?? '';
}
private async getMemberOrThrow(
projectId: string,
userId: string,
): Promise<ProjectMember> {
const member = await this.memberRepo.findOne({
where: { project: { id: projectId }, user: { id: userId } },
relations: ['user'],
});
if (!member) {
throw new NotFoundException();
}
return member;
}
private async assertAdmin(projectId: string, userId: string): Promise<void> {
const membership = await this.memberRepo.findOne({
where: { project: { id: projectId }, user: { id: userId } },
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
}
private toResponse(
member: ProjectMember,
taskCount = 0,
): ProjectMemberResponse {
return {
user: UserService.toPublic(member.user),
email: member.user.email,
taskCount,
roleType: member.roleType,
owner: member.isOwner,
};
}
}
@@ -0,0 +1,92 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { ProjectService, type ProjectResponse } from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요
@ApiTags('Project')
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Get()
@ApiOperation({ summary: '프로젝트 전체 목록 (페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<ProjectResponse>> {
return this.projectService.findAll(page, size);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '프로젝트 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
create(
@Body() dto: CreateProjectDto,
@CurrentUser() user: PublicUser,
): Promise<ProjectResponse> {
return this.projectService.create(dto, user.id);
}
@Get(':projectId')
@ApiOperation({ summary: '프로젝트 단건 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({
status: 404,
description: '프로젝트를 찾을 수 없음 (RES_001)',
})
findOne(
@Param('projectId', ParseUUIDPipe) projectId: string,
@CurrentUser() user: PublicUser,
): Promise<ProjectResponse> {
return this.projectService.findOne(projectId, user.id);
}
@Patch(':projectId')
@ApiOperation({ summary: '프로젝트 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: UpdateProjectDto,
@CurrentUser() user: PublicUser,
): Promise<ProjectResponse> {
return this.projectService.update(projectId, user.id, dto);
}
@Delete(':projectId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('projectId', ParseUUIDPipe) projectId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.projectService.remove(projectId, user.id);
return null;
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { Task } from '../task/entities/task.entity';
import { ActivityModule } from '../activity/activity.module';
import { NotificationModule } from '../notification/notification.module';
import { ProjectService } from './project.service';
import { ProjectMemberService } from './project-member.service';
import { ProjectController } from './project.controller';
import { ProjectMemberController } from './project-member.controller';
// 프로젝트 모듈 — 작업의 기본 단위. 업무(Task)·활동(Activity)이 프로젝트에 연동된다.
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
@Module({
imports: [
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
UserModule,
ActivityModule,
NotificationModule,
],
controllers: [ProjectController, ProjectMemberController],
providers: [ProjectService, ProjectMemberService],
exports: [ProjectService],
})
export class ProjectModule {}
@@ -0,0 +1,214 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { Task } from '../task/entities/task.entity';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
export interface ProjectResponse {
id: string;
name: string;
description: string | null;
members: PublicUser[];
memberCount: number;
doneCount: number; // 완료 업무 수
totalCount: number; // 전체 업무 수
updatedAt: string;
// 단건(findOne)에서만 — 현재 사용자 권한
canManage?: boolean; // admin
isOwner?: boolean; // 소유자
}
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
@Injectable()
export class ProjectService {
constructor(
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
private readonly userService: UserService,
) {}
// 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션.
async findAll(
page?: number,
size?: number,
): Promise<PaginatedResult<ProjectResponse>> {
const pg = resolvePage(page, size);
const [projects, total] = await this.projectRepo.findAndCount({
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
skip: pg.skip,
take: pg.take,
});
const ids = projects.map((p) => p.id);
const taskMap = await this.taskCountsByProject(ids);
return paginated(
projects.map((p) => this.toResponse(p, taskMap)),
total,
pg,
);
}
// 프로젝트 단건 — 인증 사용자면 열람 가능. 현재 사용자 권한(canManage/isOwner) 포함.
async findOne(projectId: string, userId: string): Promise<ProjectResponse> {
const project = await this.projectRepo.findOne({
where: { id: projectId },
relations: ['members', 'members.user'],
});
if (!project) {
throw new NotFoundException();
}
const membership = await this.memberRepo.findOne({
where: { project: { id: project.id }, user: { id: userId } },
});
const taskMap = await this.taskCountsByProject([project.id]);
const base = this.toResponse(project, taskMap);
return {
...base,
canManage: membership?.roleType === 'admin',
isOwner: membership?.isOwner ?? false,
};
}
// 프로젝트 생성 — 생성자를 owner-admin 멤버로 등록
async create(
dto: CreateProjectDto,
userId: string,
): Promise<ProjectResponse> {
const project = await this.projectRepo.save(
this.projectRepo.create({
name: dto.name,
description: dto.description ?? null,
}),
);
const creator = await this.userService.findById(userId);
if (creator) {
await this.memberRepo.save(
this.memberRepo.create({
project,
user: creator,
roleType: 'admin',
isOwner: true,
}),
);
}
return this.findOne(project.id, userId);
}
// 프로젝트 수정 — admin 권한.
async update(
projectId: string,
userId: string,
dto: UpdateProjectDto,
): Promise<ProjectResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
if (dto.name !== undefined) project.name = dto.name;
if (dto.description !== undefined) project.description = dto.description;
await this.projectRepo.save(project);
return this.findOne(project.id, userId);
}
// 프로젝트 삭제 — 소유자 권한.
async remove(projectId: string, userId: string): Promise<void> {
const project = await this.getProjectOrThrow(projectId);
const membership = await this.memberRepo.findOne({
where: { project: { id: project.id }, user: { id: userId } },
});
if (!membership?.isOwner) {
throw new ForbiddenException();
}
await this.projectRepo.remove(project);
}
// --- 다른 모듈에서 쓰는 헬퍼 ---
// projectId 로 프로젝트 로딩, 없으면 404
async getProjectOrThrow(projectId: string): Promise<Project> {
const project = await this.projectRepo.findOne({
where: { id: projectId },
});
if (!project) {
throw new NotFoundException();
}
return project;
}
// 프로젝트 멤버 확인 — 아니면 403
async assertProjectMember(projectId: string, userId: string): Promise<void> {
const membership = await this.memberRepo.findOne({
where: { project: { id: projectId }, user: { id: userId } },
});
if (!membership) {
throw new ForbiddenException();
}
}
// 프로젝트 admin 확인 — 아니면 403
async assertProjectAdmin(projectId: string, userId: string): Promise<void> {
const membership = await this.memberRepo.findOne({
where: { project: { id: projectId }, user: { id: userId } },
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
}
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
private async taskCountsByProject(
ids: string[],
): Promise<Map<string, { done: number; total: number }>> {
const map = new Map<string, { done: number; total: number }>();
if (ids.length === 0) return map;
const rows = await this.taskRepo
.createQueryBuilder('task')
.select('task.project_id', 'projectId')
.addSelect('COUNT(*)', 'total')
.addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done')
.where('task.project_id IN (:...ids)', { ids })
.groupBy('task.project_id')
.getRawMany<{ projectId: string; total: string; done: string }>();
for (const r of rows) {
map.set(r.projectId, { done: Number(r.done), total: Number(r.total) });
}
return map;
}
// 엔티티 → 응답 매핑. 카운트 맵에서 프로젝트별 집계를 주입한다.
private toResponse(
project: Project,
taskMap: Map<string, { done: number; total: number }>,
): ProjectResponse {
const members = project.members ?? [];
const counts = taskMap.get(project.id);
return {
id: project.id,
name: project.name,
description: project.description,
members: members
.filter((m) => m.user)
.map((m) => UserService.toPublic(m.user)),
memberCount: members.length,
doneCount: counts?.done ?? 0,
totalCount: counts?.total ?? 0,
updatedAt: project.updatedAt.toISOString(),
};
}
}
@@ -1,68 +0,0 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { RepoVisibility } from '../entities/repo.entity';
// 저장소 생성 DTO
export class CreateRepoDto {
@ApiProperty({
description: '저장소 slug(영문 소문자·숫자·하이픈·밑줄)',
example: 'q3-launch-campaign',
})
@IsString()
@IsNotEmpty({ message: '저장소 이름은 필수 입력 항목입니다.' })
@Matches(/^[a-z0-9-_]+$/, {
message:
'저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.',
})
@MaxLength(60)
slug!: string;
@ApiProperty({
description: '표시 제목(한글)',
example: '3분기 신제품 런칭 캠페인',
})
@IsString()
@IsNotEmpty({ message: '표시 제목은 필수 입력 항목입니다.' })
@MaxLength(40)
name!: string;
@ApiProperty({
description: '공개 범위',
enum: ['private', 'public'],
example: 'private',
})
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility!: RepoVisibility;
@ApiProperty({
description: '프로젝트 설명(필수)',
example: '3분기 신제품 런칭을 위한 마케팅 캠페인 저장소',
})
@IsString()
@IsNotEmpty({ message: '프로젝트 설명은 필수 입력 항목입니다.' })
@MaxLength(300)
description!: string;
@ApiProperty({ description: '메인 브랜치', example: 'main', required: false })
@IsOptional()
@IsString()
branch?: string;
@ApiProperty({
description:
'템플릿 사용 여부 — true 면 설정된 템플릿 저장소(GITEA_TEMPLATE)를 복제해 생성',
example: true,
required: false,
})
@IsOptional()
@IsBoolean()
useTemplate?: boolean;
}
@@ -1,31 +0,0 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { RepoVisibility } from '../entities/repo.entity';
// 저장소 수정 DTO — 표시 제목/설명/공개범위/브랜치 (slug·owner 는 변경 불가)
export class UpdateRepoDto {
@ApiProperty({ description: '표시 제목(한글)', required: false })
@IsOptional()
@IsString()
@MaxLength(40)
name?: string;
@ApiProperty({ description: '프로젝트 설명', required: false })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({
description: '공개 범위',
enum: ['private', 'public'],
required: false,
})
@IsOptional()
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
visibility?: RepoVisibility;
@ApiProperty({ description: '메인 브랜치', required: false })
@IsOptional()
@IsString()
branch?: string;
}
@@ -1,42 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from './repo.entity';
// 저장소 내 권한 역할
export type MemberRoleType = 'admin' | 'member';
// 저장소 멤버 (Repo ↔ User 조인 + 역할 정보)
@Entity('repo_members')
@Unique(['repo', 'user'])
export class RepoMember {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
@ManyToOne(() => Repo, (repo) => repo.members, { onDelete: 'CASCADE' })
repo!: Relation<Repo>;
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user!: User;
// 권한 역할 (admin: 저장소 관리, member: 일반)
@Column({ name: 'role_type', type: 'varchar', default: 'member' })
roleType!: MemberRoleType;
// 소유자 여부(저장소 생성자) — 역할 변경/제거 불가 대상
@Column({ name: 'is_owner', default: false })
isOwner!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,78 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { RepoMember } from './repo-member.entity';
// 저장소 공개 범위
export type RepoVisibility = 'private' | 'public';
// 저장소 엔티티
@Entity('repos')
export class Repo {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — 전역 유일
@Column({ name: 'slug_name', unique: true })
slugName!: string;
// 소유 조직 slug (예: 'marketing-team') — 현재는 단일 조직 고정
@Column()
owner!: string;
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
@Column()
name!: string;
// 프로젝트 설명(선택)
@Column({ type: 'varchar', nullable: true })
description!: string | null;
// 공개 범위
@Column({ type: 'varchar', default: 'private' })
visibility!: RepoVisibility;
// 메인 브랜치
@Column({ default: 'main' })
branch!: string;
// --- Gitea 연동 정보 (연동 비활성/생성 실패 시 null) ---
// Gitea 저장소 내부 ID
@Column({ name: 'gitea_repo_id', type: 'int', nullable: true })
giteaRepoId!: number | null;
// Gitea 저장소 전체 이름 (예: 'marketing-team/q3-launch-campaign')
@Column({ name: 'gitea_full_name', type: 'varchar', nullable: true })
giteaFullName!: string | null;
// HTTPS clone URL
@Column({ name: 'clone_url', type: 'varchar', nullable: true })
cloneUrl!: string | null;
// Gitea 웹 UI URL
@Column({ name: 'html_url', type: 'varchar', nullable: true })
htmlUrl!: string | null;
// Gitea 조직에서 더 이상 발견되지 않는 저장소 표시(동기화로 갱신).
// 연동 origin(giteaRepoId 보유)이 아닌 저장소는 항상 false 유지.
@Column({ name: 'gitea_missing', default: false })
giteaMissing!: boolean;
// 멤버 목록
@OneToMany(() => RepoMember, (member) => member.repo)
members!: Relation<RepoMember>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -1,158 +0,0 @@
import { ForbiddenException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { MemberService } from './member.service';
import { UserService } from '../user/user.service';
import { TaskService } from '../task/task.service';
import { ActivityService } from '../activity/activity.service';
import { NotificationService } from '../notification/notification.service';
import { RepoCacheService } from './repo-cache.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹
function makeService() {
const repoRepo = { findOne: jest.fn() };
const memberRepo = {
findOne: jest.fn(),
find: jest.fn(),
findAndCount: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
};
const userService = {
findByEmail: jest.fn(),
findById: jest.fn().mockResolvedValue(null),
};
// 담당 업무 수 집계는 빈 Map(0건)으로 모킹
const taskService = {
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
};
// 활동 적재는 no-op 모킹
const activity = { record: jest.fn().mockResolvedValue(undefined) };
// 알림 적재는 no-op 모킹
const notification = { notify: jest.fn().mockResolvedValue(undefined) };
// 목록 캐시 무효화는 no-op 모킹
const repoCache = { invalidateList: jest.fn().mockResolvedValue(undefined) };
const svc = new MemberService(
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
userService as unknown as UserService,
taskService as unknown as TaskService,
activity as unknown as ActivityService,
notification as unknown as NotificationService,
repoCache as unknown as RepoCacheService,
);
return {
svc,
repoRepo,
memberRepo,
userService,
taskService,
activity,
notification,
repoCache,
};
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
const ADMIN = { roleType: 'admin' };
describe('MemberService', () => {
describe('list', () => {
it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
memberRepo.findAndCount.mockResolvedValue([
[
{
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
email: 'a@b.c',
roleType: 'member',
isOwner: false,
},
],
1,
]);
const result = await svc.list('q3-launch');
expect(result.total).toBe(1);
expect(result.items).toHaveLength(1);
expect(result.items[0].user.id).toBe('someone-else');
});
});
describe('invite', () => {
it('가입되지 않은 이메일이면 BusinessException', async () => {
const { svc, repoRepo, memberRepo, userService } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValueOnce(ADMIN); // assertAdmin 통과
userService.findByEmail.mockResolvedValue(null);
await expect(
svc.invite('q3-launch', 'admin-user', {
email: 'ghost@acme.co',
roleType: 'member',
}),
).rejects.toBeInstanceOf(BusinessException);
});
it('이미 멤버면 BusinessException', async () => {
const { svc, repoRepo, memberRepo, userService } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne
.mockResolvedValueOnce(ADMIN) // assertAdmin
.mockResolvedValueOnce({ id: 'existing-member' }); // 기존 멤버십 존재
userService.findByEmail.mockResolvedValue({ id: 'u1', email: 'u@b.c' });
await expect(
svc.invite('q3-launch', 'admin-user', {
email: 'u@b.c',
roleType: 'member',
}),
).rejects.toBeInstanceOf(BusinessException);
});
it('admin 이 아니면 ForbiddenException', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValueOnce({ roleType: 'member' }); // 일반 멤버
await expect(
svc.invite('q3-launch', 'member-user', {
email: 'u@b.c',
roleType: 'member',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('update / remove — 소유자 보호', () => {
it('소유자의 역할은 변경할 수 없다', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne
.mockResolvedValueOnce(ADMIN) // assertAdmin
.mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자
await expect(
svc.update('q3-launch', 'admin-user', 'owner', { roleType: 'member' }),
).rejects.toBeInstanceOf(BusinessException);
});
it('소유자는 제거할 수 없다', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne
.mockResolvedValueOnce(ADMIN) // assertAdmin
.mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자
await expect(
svc.remove('q3-launch', 'admin-user', 'owner'),
).rejects.toBeInstanceOf(BusinessException);
expect(memberRepo.remove).not.toHaveBeenCalled();
});
});
});
-282
View File
@@ -1,282 +0,0 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { TaskService } from '../task/task.service';
import { ActivityService } from '../activity/activity.service';
import { NotificationService } from '../notification/notification.service';
import { RepoCacheService } from './repo-cache.service';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Repo } from './entities/repo.entity';
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
import { InviteMemberDto } from './dto/invite-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
// 멤버 응답 형태 — isYou 는 프론트가 현재 사용자와 비교해 산출
export interface RepoMemberResponse {
user: PublicUser;
email: string;
taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계
roleType: MemberRoleType;
owner: boolean; // 소유자 여부(isOwner)
}
// 저장소 멤버 관리 비즈니스 로직
@Injectable()
export class MemberService {
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly taskService: TaskService,
private readonly activity: ActivityService,
private readonly notification: NotificationService,
private readonly repoCache: RepoCacheService,
) {}
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션.
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
async list(
slugName: string,
page?: number,
size?: number,
): Promise<PaginatedResult<RepoMemberResponse>> {
const repo = await this.getRepoOrThrow(slugName);
const pg = resolvePage(page, size);
const [members, total] = await this.memberRepo.findAndCount({
where: { repo: { id: repo.id } },
relations: ['user'],
order: { createdAt: 'ASC' },
skip: pg.skip,
take: pg.take,
});
// 사용자별 담당 업무 수 집계(taskCount)
const counts = await this.taskService.assigneeCounts(repo.id);
return paginated(
members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)),
total,
pg,
);
}
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
async invite(
slugName: string,
actingUserId: string,
dto: InviteMemberDto,
): Promise<RepoMemberResponse> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const user = await this.userService.findByEmail(dto.email);
if (!user) {
throw new BusinessException(
'BIZ_001',
'해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.',
HttpStatus.NOT_FOUND,
);
}
const existing = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: user.id } },
});
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 저장소에 속한 멤버입니다.',
HttpStatus.CONFLICT,
);
}
const member = this.memberRepo.create({
repo,
user,
roleType: dto.roleType,
isOwner: false,
});
const saved = await this.memberRepo.save(member);
saved.user = user; // 관계 보장(응답 매핑용)
// 활동 적재 — 멤버 초대
await this.activity.record({
repoId: repo.id,
actorId: actingUserId,
type: 'member.invited',
payload: { targetName: user.name, roleType: dto.roleType },
});
// 알림 — 초대된 사용자에게 '저장소 초대'
await this.notification.notify({
recipientIds: [user.id],
actorId: actingUserId,
type: 'member.invited',
payload: {
repoId: repo.slugName,
repoName: repo.name,
roleType: dto.roleType,
actorName: await this.actorName(actingUserId),
},
});
// 저장소 목록 캐시 무효화 — 멤버 추가로 목록 카드의 멤버 수/아바타가 바뀜
await this.repoCache.invalidateList();
return this.toResponse(saved);
}
// 멤버 역할/직무 변경 — admin 권한. 소유자(owner)는 변경 불가
async update(
slugName: string,
actingUserId: string,
targetUserId: string,
dto: UpdateMemberDto,
): Promise<RepoMemberResponse> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const member = await this.getMemberOrThrow(repo.id, targetUserId);
if (member.isOwner) {
throw new BusinessException(
'BIZ_001',
'소유자의 권한은 변경할 수 없습니다.',
HttpStatus.FORBIDDEN,
);
}
if (dto.roleType !== undefined) member.roleType = dto.roleType;
await this.memberRepo.save(member);
// 활동 적재 — 멤버 역할 변경
await this.activity.record({
repoId: repo.id,
actorId: actingUserId,
type: 'member.role_changed',
payload: { targetName: member.user.name, roleType: member.roleType },
});
// 알림 — 역할이 변경된 당사자에게(본인이 본인 역할을 바꾼 경우는 자동 제외)
await this.notification.notify({
recipientIds: [member.user.id],
actorId: actingUserId,
type: 'member.role_changed',
payload: {
repoId: repo.slugName,
repoName: repo.name,
roleType: member.roleType,
actorName: await this.actorName(actingUserId),
},
});
// (역할 변경은 저장소 목록 응답의 멤버 수/아바타를 바꾸지 않으므로 캐시 무효화 불필요)
return this.toResponse(member);
}
// 멤버 제거 — admin 권한. 소유자(owner)는 제거 불가
async remove(
slugName: string,
actingUserId: string,
targetUserId: string,
): Promise<void> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const member = await this.getMemberOrThrow(repo.id, targetUserId);
if (member.isOwner) {
throw new BusinessException(
'BIZ_001',
'소유자는 저장소에서 제거할 수 없습니다.',
HttpStatus.FORBIDDEN,
);
}
const targetName = member.user.name; // 제거 전 이름 캡처
const removedUserId = member.user.id; // 제거 전 id 캡처(알림 수신자)
await this.memberRepo.remove(member);
// 활동 적재 — 멤버 제거
await this.activity.record({
repoId: repo.id,
actorId: actingUserId,
type: 'member.removed',
payload: { targetName },
});
// 알림 — 제거된 당사자에게(조직 모델상 저장소 열람은 여전히 가능)
await this.notification.notify({
recipientIds: [removedUserId],
actorId: actingUserId,
type: 'member.removed',
payload: {
repoId: repo.slugName,
repoName: repo.name,
actorName: await this.actorName(actingUserId),
},
});
// 저장소 목록 캐시 무효화 — 멤버 제거로 목록 카드의 멤버 수/아바타가 바뀜
await this.repoCache.invalidateList();
}
// --- 내부 헬퍼 ---
// 행위자 표시 이름(알림 payload 용) — 없으면 빈 문자열
private async actorName(userId: string): Promise<string> {
const actor = await this.userService.findById(userId);
return actor?.name ?? '';
}
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// 저장소-사용자 멤버십 로딩(+user), 없으면 404
private async getMemberOrThrow(
repoId: string,
userId: string,
): Promise<RepoMember> {
const member = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
relations: ['user'],
});
if (!member) {
throw new NotFoundException();
}
return member;
}
// admin 권한 확인 (아니면 403)
private async assertAdmin(repoId: string, userId: string): Promise<void> {
const membership = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
}
// 엔티티 → 응답 매핑 (taskCount 미제공 시 0)
private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse {
return {
user: UserService.toPublic(member.user),
email: member.user.email,
taskCount,
roleType: member.roleType,
owner: member.isOwner,
};
}
}
@@ -1,62 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import type { PaginatedResult } from '../../common/pagination/pagination';
import type { RepoResponse } from './repo.service';
// 저장소 목록 캐시 — 버전 네임스페이스 방식으로 O(1) 무효화.
// 키에 버전을 포함하고, 변경(생성/수정/삭제/동기화) 시 버전을 올려 기존 키를 통째로 폐기한다.
// (keyv 는 와일드카드 삭제를 지원하지 않으므로 버전 증가로 일괄 무효화한다)
// 모든 캐시 접근은 실패 시 무시하고 DB 조회로 폴백 — 캐시 장애가 요청을 깨지 않는다.
@Injectable()
export class RepoCacheService {
private static readonly VER_KEY = 'repos:list:ver';
private static readonly LIST_TTL = 60_000; // 60초(업무 카운트 staleness 백스톱)
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
// 현재 목록 캐시 버전(없으면 1로 초기화)
private async version(): Promise<number> {
try {
const v = await this.cache.get<number>(RepoCacheService.VER_KEY);
if (typeof v === 'number') return v;
await this.cache.set(RepoCacheService.VER_KEY, 1);
} catch {
// 캐시 장애 — 버전 1로 취급(매번 미스 → DB 조회)
}
return 1;
}
// 버전 증가 → 이전 버전의 모든 목록 캐시 키를 무효화(TTL 로 자연 소멸)
async invalidateList(): Promise<void> {
try {
const v = await this.version();
await this.cache.set(RepoCacheService.VER_KEY, v + 1);
} catch {
// 무시(다음 조회는 구버전 키를 보지만 TTL 60초 내 소멸)
}
}
// 목록 캐시 get-or-compute — 히트 시 캐시, 미스 시 compute() 결과를 적재 후 반환
async listOrCompute(
page: number,
size: number,
compute: () => Promise<PaginatedResult<RepoResponse>>,
): Promise<PaginatedResult<RepoResponse>> {
const ver = await this.version();
const key = `repos:list:v${ver}:p${page}:s${size}`;
try {
const hit = await this.cache.get<PaginatedResult<RepoResponse>>(key);
if (hit) return hit;
} catch {
// 캐시 장애 — 아래 DB 조회로 폴백
}
const value = await compute();
try {
await this.cache.set(key, value, RepoCacheService.LIST_TTL);
} catch {
// 적재 실패 무시
}
return value;
}
}
@@ -1,268 +0,0 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService } from '../user/user.service';
import type { User } from '../user/entities/user.entity';
import { GiteaService, type GiteaRepoResult } from '../gitea/gitea.service';
import { RepoCacheService } from './repo-cache.service';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
// Gitea description 에서 분리한 표시명/설명
interface ParsedDescription {
name: string;
desc: string | null;
}
// Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스.
// - 서버 기동 직후 1회 + 매시간 정각에 조직의 저장소 목록을 받아 Relay 와 맞춘다.
// - 추가형(additive): Gitea 에만 있는 저장소는 Relay 로 import, 양쪽에 있으면 연동정보 backfill.
// - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다.
// - 멤버십은 Relay 자체 관리(Gitea collaborator 미동기화) — import 저장소는 멤버 0명으로 들어온다.
@Injectable()
export class RepoSyncService implements OnApplicationBootstrap {
private readonly logger = new Logger(RepoSyncService.name);
// 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우)
private running = false;
// import(멤버 0명) 저장소에 기본 owner-admin 으로 지정할 사용자 이메일.
// 동기화는 시스템 작업이라 실행 주체가 없으므로 env 로 지정한다. 비어 있으면 멤버 0명으로 둔다.
private readonly importAdminEmail: string;
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly repoCache: RepoCacheService,
config: ConfigService,
) {
this.importAdminEmail = (
config.get<string>('REPO_IMPORT_ADMIN_EMAIL') ?? ''
).trim();
}
// 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행
onApplicationBootstrap(): void {
void this.syncFromGitea('부트스트랩');
}
// 10분마다 동기화
@Cron(CronExpression.EVERY_10_MINUTES)
async handleScheduledSync(): Promise<void> {
await this.syncFromGitea('스케줄(10분)');
}
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
async syncFromGitea(trigger: string): Promise<void> {
if (!this.gitea.enabled) {
this.logger.debug(`Gitea 미연동 — 동기화 건너뜀 (${trigger})`);
return;
}
if (this.running) {
this.logger.warn(`이전 동기화가 진행 중 — 건너뜀 (${trigger})`);
return;
}
this.running = true;
try {
const remote = await this.gitea.listOrgRepos();
const existing = await this.repoRepo.find();
// 매칭용 인덱스: Gitea ID 우선, 없으면 slugName 으로 매칭
const byGiteaId = new Map<number, Repo>();
const bySlug = new Map<string, Repo>();
for (const r of existing) {
if (r.giteaRepoId !== null) byGiteaId.set(r.giteaRepoId, r);
bySlug.set(r.slugName, r);
}
const seenGiteaIds = new Set<number>();
// 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지
const matchedRepoIds = new Set<string>();
let imported = 0;
let relinked = 0;
for (const gr of remote) {
seenGiteaIds.add(gr.id);
// Gitea ID 우선, 없으면 옛 slug 로 매칭. 단 이미 매칭된 저장소는 제외
// (Gitea 에서 rename 후 옛 이름으로 새 저장소를 만든 경우의 stale-slug 충돌 방지)
const candidate = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
const match =
candidate && !matchedRepoIds.has(candidate.id) ? candidate : null;
if (match) {
matchedRepoIds.add(match.id);
// Gitea 를 SSOT 로 — 이름(slug)/표시명/설명/공개범위/브랜치 + 연동정보를 Gitea 기준에 맞춘다.
// (Gitea 에서 직접 변경한 내용을 Relay 로 끌어온다. rename 시 slugName 도 따라가 라우팅·쓰기 동기화 일치)
if (this.reconcileFromGitea(match, gr)) {
await this.repoRepo.save(match);
relinked++;
}
} else {
// Gitea 에만 있는 저장소 — Relay 로 가져오기(멤버 없음)
const { name, desc } = this.parseDescription(gr.description, gr.name);
const repo = this.repoRepo.create({
slugName: gr.name,
owner: this.gitea.org,
name,
description: desc,
visibility: gr.private ? 'private' : 'public',
branch: gr.defaultBranch || 'main',
giteaRepoId: gr.id,
giteaFullName: gr.fullName,
cloneUrl: gr.cloneUrl,
htmlUrl: gr.htmlUrl,
giteaMissing: false,
});
await this.repoRepo.save(repo);
imported++;
}
}
// Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 표기만 한다.
let missing = 0;
for (const r of existing) {
if (
r.giteaRepoId !== null &&
!seenGiteaIds.has(r.giteaRepoId) &&
!r.giteaMissing
) {
r.giteaMissing = true;
await this.repoRepo.save(r);
missing++;
}
}
// import(멤버 0명) 저장소에 기본 owner-admin 지정 — 멤버 할당 데드락 방지.
// (env REPO_IMPORT_ADMIN_EMAIL 미설정/미가입 시 건너뜀)
const adminAssigned = await this.ensureImportAdmin();
// 동기화로 저장소 목록이 바뀌었으면 캐시 무효화(새 import/누락표시/admin 즉시 반영)
if (imported + relinked + missing + adminAssigned > 0) {
await this.repoCache.invalidateList();
}
this.logger.log(
`Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing} · admin지정 ${adminAssigned}`,
);
} catch (e) {
this.logger.error(
`Gitea 동기화 실패 (${trigger})`,
e instanceof Error ? e.stack : String(e),
);
} finally {
this.running = false;
}
}
// import(멤버 0명) Gitea 연동 저장소에 기본 owner-admin 을 지정한다.
// env REPO_IMPORT_ADMIN_EMAIL 의 가입 사용자를 owner-admin 으로 등록(신규 import + 기존 멤버 0명 모두 대상).
// 반환: 이번 실행에서 admin 을 새로 붙인 저장소 수.
private async ensureImportAdmin(): Promise<number> {
if (!this.importAdminEmail) return 0;
const admin = await this.userService.findByEmail(this.importAdminEmail);
if (!admin) {
this.logger.warn(
`REPO_IMPORT_ADMIN_EMAIL(${this.importAdminEmail}) 에 해당하는 가입 사용자가 없어 import 저장소 admin 지정을 건너뜁니다. (가입 후 다음 동기화에서 지정됨)`,
);
return 0;
}
// Gitea 연동(import) 저장소 중 멤버가 0명인 것만 대상.
// Relay 생성 저장소는 생성자(owner)가 항상 멤버이므로 제외된다.
const linked = await this.repoRepo.find({ relations: ['members'] });
let assigned = 0;
for (const repo of linked) {
if (repo.giteaRepoId === null) continue;
if ((repo.members?.length ?? 0) > 0) continue;
await this.assignOwnerAdmin(repo, admin);
assigned++;
this.logger.log(
`import 저장소 owner-admin 지정: ${repo.slugName}${admin.email}`,
);
}
return assigned;
}
// 저장소에 사용자를 owner-admin 멤버로 등록(Relay 생성 시 owner 등록과 동일 규약)
private async assignOwnerAdmin(repo: Repo, user: User): Promise<void> {
const member = this.memberRepo.create({
repo,
user,
roleType: 'admin',
isOwner: true,
});
await this.memberRepo.save(member);
}
// Gitea 를 기준으로 Relay 저장소 메타를 맞춘다 — Gitea 의 네이티브 필드와 깔끔히 1:1 대응되는
// 이름(slug)/공개범위/기본 브랜치 + 연동정보만 동기화한다. 변경이 있었으면 true.
// ※ 표시명/설명은 Gitea 단일 description 에 "표시제목 — 설명" 으로 합쳐 저장하므로,
// Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지한다.
private reconcileFromGitea(repo: Repo, gr: GiteaRepoResult): boolean {
let changed = false;
// 이름(slug) — Gitea rename 반영. Relay 라우팅 URL 과 이후 Gitea 쓰기 경로가 새 이름을 따른다
if (repo.slugName !== gr.name) {
repo.slugName = gr.name;
changed = true;
}
// 공개범위
const visibility = gr.private ? 'private' : 'public';
if (repo.visibility !== visibility) {
repo.visibility = visibility;
changed = true;
}
// 기본 브랜치
if (gr.defaultBranch && repo.branch !== gr.defaultBranch) {
repo.branch = gr.defaultBranch;
changed = true;
}
// 연동정보(linkage) + 누락표시 해제
if (repo.giteaRepoId !== gr.id) {
repo.giteaRepoId = gr.id;
changed = true;
}
if (repo.giteaFullName !== gr.fullName) {
repo.giteaFullName = gr.fullName;
changed = true;
}
if (repo.cloneUrl !== gr.cloneUrl) {
repo.cloneUrl = gr.cloneUrl;
changed = true;
}
if (repo.htmlUrl !== gr.htmlUrl) {
repo.htmlUrl = gr.htmlUrl;
changed = true;
}
if (repo.giteaMissing) {
repo.giteaMissing = false;
changed = true;
}
return changed;
}
// Gitea description("표시제목 — 설명") 에서 표시명/설명 분리.
// Relay 생성 저장소는 buildGiteaDescription 규칙을 따르고,
// 구분자가 없는 Gitea-native 저장소는 slug 를 표시명으로 사용한다.
private parseDescription(
description: string,
slug: string,
): ParsedDescription {
const sep = ' — ';
const d = (description ?? '').trim();
if (!d) return { name: slug, desc: null };
const idx = d.indexOf(sep);
if (idx > 0) {
return {
name: d.slice(0, idx).trim(),
desc: d.slice(idx + sep.length).trim() || null,
};
}
return { name: slug, desc: d };
}
}
-103
View File
@@ -1,103 +0,0 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { RepoService, type RepoResponse } from './repo.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
// 저장소 컨트롤러 — 모든 엔드포인트 인증 필요
@ApiTags('Repo')
@Controller('repos')
@UseGuards(JwtAuthGuard)
export class RepoController {
constructor(private readonly repoService: RepoService) {}
@Get()
@ApiOperation({ summary: '조직 저장소 전체 목록 (페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<RepoResponse>> {
return this.repoService.findAll(page, size);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '저장소 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
@ApiResponse({
status: 409,
description: '이미 사용 중인 저장소 이름 (BIZ_001)',
})
create(
@Body() dto: CreateRepoDto,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.create(dto, user.id);
}
// ':repoId' 보다 먼저 선언해야 'meta' 가 파라미터로 매칭되지 않음
@Get('meta')
@ApiOperation({ summary: '저장소 생성 폼 메타(소유자/템플릿) 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
getCreateMeta(): {
owner: string;
template: { available: boolean; slug: string };
} {
return this.repoService.getCreateMeta();
}
@Get(':repoId')
@ApiOperation({ summary: '저장소 단건 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.findOne(repoId, user.id);
}
@Patch(':repoId')
@ApiOperation({ summary: '저장소 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('repoId') repoId: string,
@Body() dto: UpdateRepoDto,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.update(repoId, user.id, dto);
}
@Delete(':repoId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '저장소 삭제 (소유자)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.repoService.remove(repoId, user.id);
return null;
}
}
-31
View File
@@ -1,31 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { GiteaModule } from '../gitea/gitea.module';
import { TaskModule } from '../task/task.module';
import { ActivityModule } from '../activity/activity.module';
import { NotificationModule } from '../notification/notification.module';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { RepoService } from './repo.service';
import { RepoController } from './repo.controller';
import { MemberService } from './member.service';
import { MemberController } from './member.controller';
import { RepoSyncService } from './repo-sync.service';
import { RepoCacheService } from './repo-cache.service';
// 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화)
@Module({
imports: [
TypeOrmModule.forFeature([Repo, RepoMember]),
UserModule,
GiteaModule,
TaskModule,
ActivityModule,
NotificationModule,
],
controllers: [RepoController, MemberController],
providers: [RepoService, MemberService, RepoSyncService, RepoCacheService],
exports: [RepoService, MemberService],
})
export class RepoModule {}
-462
View File
@@ -1,462 +0,0 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
GiteaApiError,
GiteaService,
type GiteaRepoResult,
} from '../gitea/gitea.service';
import { TaskService } from '../task/task.service';
import { ActivityService } from '../activity/activity.service';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { Repo, type RepoVisibility } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { RepoCacheService } from './repo-cache.service';
import { CreateRepoDto } from './dto/create-repo.dto';
import { UpdateRepoDto } from './dto/update-repo.dto';
// Gitea 연동 비활성 시 사용할 기본 소유자 (조직 모델 도입 시 교체)
const DEFAULT_OWNER = 'marketing-team';
// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태 (enum 비교 회피용 숫자 상수)
const HTTP_CONFLICT = 409;
// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다
export interface RepoResponse {
id: string; // slugName (라우팅 식별자)
name: string;
owner: string;
slug: string; // owner/slugName
desc: string | null;
visibility: RepoVisibility;
branch: string;
members: PublicUser[];
memberCount: number;
doneCount: number;
totalCount: number;
cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null)
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
updatedAt: string;
// 현재 사용자의 이 저장소 권한 — 단건(findOne)에서만 채워진다(목록은 캐시·사용자 무관이라 미포함).
canManage?: boolean; // admin (설정 변경/탭 노출)
isOwner?: boolean; // 소유자 (저장소 삭제)
}
// 저장소 비즈니스 로직
@Injectable()
export class RepoService {
private readonly logger = new Logger(RepoService.name);
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly taskService: TaskService,
private readonly activity: ActivityService,
private readonly repoCache: RepoCacheService,
) {}
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
// 저장소 생성 전 프론트가 미리 조회하여 표시한다.
getCreateMeta(): {
owner: string;
template: { available: boolean; slug: string };
} {
return {
// 연동 시 owner = GITEA_ORG, 아니면 기본 상수
owner: this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER,
template: {
available: this.gitea.templateEnabled,
slug: this.gitea.templateSlug,
},
};
}
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분. 오프셋 페이지네이션.
// 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델).
async findAll(
page?: number,
size?: number,
): Promise<PaginatedResult<RepoResponse>> {
const pg = resolvePage(page, size);
// 페이지 단위로 캐시(버전 네임스페이스). 구조 변경 시 버전 증가로 일괄 무효화하고,
// 업무 카운트 staleness 는 60초 TTL 로 자연 보정한다.
return this.repoCache.listOrCompute(pg.page, pg.size, async () => {
const [repos, total] = await this.repoRepo.findAndCount({
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
skip: pg.skip,
take: pg.take,
});
// 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영
const counts = await this.taskService.countsByRepo(
repos.map((r) => r.id),
);
return paginated(
repos.map((repo) => this.toResponse(repo, counts.get(repo.id))),
total,
pg,
);
});
}
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
// 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다.
async findOne(slugName: string, userId: string): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: userId } },
});
return this.toResponse(repo, await this.taskCountsOf(repo.id), {
canManage: membership?.roleType === 'admin',
isOwner: membership?.isOwner ?? false,
});
}
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
async create(dto: CreateRepoDto, userId: string): Promise<RepoResponse> {
const existing = await this.repoRepo.findOne({
where: { slugName: dto.slug },
});
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 사용 중인 저장소 이름입니다.',
HttpStatus.CONFLICT,
);
}
const user = await this.userService.findById(userId);
if (!user) {
throw new NotFoundException();
}
const description = dto.description;
const visibility = dto.visibility;
const requestedBranch = dto.branch?.trim() || 'main';
// 연동 시 소유자는 Gitea 조직명 — slug `owner/slugName` 이 Gitea full_name 과 일치
const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
// 1) Gitea 조직에 실제 저장소 생성 (연동 활성 시)
// - useTemplate: 템플릿 저장소를 복제해 생성(브랜치는 템플릿을 따름)
// - 아니면 빈 저장소(auto_init, 요청 브랜치)
let giteaRepo: GiteaRepoResult | null = null;
if (this.gitea.enabled) {
giteaRepo = await this.createGiteaRepo(dto.slug, {
name: dto.name,
description,
visibility,
branch: requestedBranch,
useTemplate: dto.useTemplate ?? false,
});
}
// 템플릿 복제 시 실제 기본 브랜치는 Gitea 응답값을 우선 사용
const branch = giteaRepo?.defaultBranch || requestedBranch;
// 2) Relay 메타데이터 저장 (Gitea 연동 정보 포함)
try {
const repo = this.repoRepo.create({
slugName: dto.slug,
owner,
name: dto.name,
description,
visibility,
branch,
giteaRepoId: giteaRepo?.id ?? null,
giteaFullName: giteaRepo?.fullName ?? null,
cloneUrl: giteaRepo?.cloneUrl ?? null,
htmlUrl: giteaRepo?.htmlUrl ?? null,
});
const saved = await this.repoRepo.save(repo);
const ownerMember = this.memberRepo.create({
repo: saved,
user,
roleType: 'admin',
isOwner: true,
});
await this.memberRepo.save(ownerMember);
// 활동 적재 — 저장소 생성
await this.activity.record({
repoId: saved.id,
actorId: userId,
type: 'repo.created',
payload: { repoName: saved.name },
});
// 목록 캐시 무효화(새 저장소 즉시 반영)
await this.repoCache.invalidateList();
// 갓 생성한 저장소는 업무가 없으므로 집계는 0/0
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
} catch (err) {
// Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트)
if (giteaRepo) {
await this.gitea.deleteRepo(dto.slug).catch((e: unknown) => {
this.logger.error(
`Gitea 고아 저장소 정리 실패: ${dto.slug}`,
e instanceof Error ? e.stack : String(e),
);
});
}
throw err;
}
}
// 저장소 수정 — admin 권한 필요
async update(
slugName: string,
userId: string,
dto: UpdateRepoDto,
): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
await this.assertAdmin(repo.id, userId);
// 변경 감지 — 폼이 모든 필드를 항상 보내므로, 실제로 바뀐 값만 동기화·활동·캐시에 반영한다
// (이전엔 필드 존재 여부로 판단해 저장할 때마다 허위 'renamed'/'visibility_changed' 활동이 쌓였음)
const prevName = repo.name;
const prevDescription = repo.description ?? '';
const prevVisibility = repo.visibility;
const prevBranch = repo.branch;
if (dto.name !== undefined) repo.name = dto.name;
if (dto.description !== undefined) repo.description = dto.description;
if (dto.visibility !== undefined) repo.visibility = dto.visibility;
if (dto.branch !== undefined && dto.branch.trim())
repo.branch = dto.branch.trim();
const nameChanged = repo.name !== prevName;
const descriptionChanged = (repo.description ?? '') !== prevDescription;
const visibilityChanged = repo.visibility !== prevVisibility;
const branchChanged = repo.branch !== prevBranch;
await this.repoRepo.save(repo);
// Gitea 메타데이터 동기화 (베스트 에포트) — 표시명/설명/공개범위/브랜치가 실제 바뀐 경우에만.
// (브랜치도 Gitea→Relay 로 역동기화되므로, Relay 변경분을 Gitea 에 밀어 양쪽을 일치시킨다)
const giteaDescChanged = nameChanged || descriptionChanged;
if (
this.gitea.enabled &&
repo.giteaRepoId !== null &&
(giteaDescChanged || visibilityChanged || branchChanged)
) {
await this.gitea
.updateRepo(repo.slugName, {
description: giteaDescChanged
? this.buildGiteaDescription(repo.name, repo.description)
: undefined,
private: visibilityChanged
? repo.visibility === 'private'
: undefined,
defaultBranch: branchChanged ? repo.branch : undefined,
})
.catch((e: unknown) => {
this.logger.error(
`Gitea 저장소 수정 동기화 실패: ${repo.slugName}`,
e instanceof Error ? e.stack : String(e),
);
});
}
// 활동 적재 — 실제로 바뀐 측면만(표시 제목 변경 / 공개범위 변경)
if (nameChanged) {
await this.activity.record({
repoId: repo.id,
actorId: userId,
type: 'repo.renamed',
payload: { newName: repo.name },
});
}
if (visibilityChanged) {
await this.activity.record({
repoId: repo.id,
actorId: userId,
type: 'repo.visibility_changed',
payload: { visibility: repo.visibility },
});
}
// 목록 캐시 무효화 — 목록 카드에 노출되는 값(표시명/설명/공개범위/브랜치)이 바뀐 경우에만
if (
nameChanged ||
descriptionChanged ||
visibilityChanged ||
branchChanged
) {
await this.repoCache.invalidateList();
}
// 응답에도 현재 사용자 권한을 포함(프론트가 저장 후에도 canManage/isOwner 를 유지하도록)
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: userId } },
});
return this.toResponse(
await this.getEntityOrThrow(slugName),
await this.taskCountsOf(repo.id),
{
canManage: membership?.roleType === 'admin',
isOwner: membership?.isOwner ?? false,
},
);
}
// 저장소 삭제 — 소유자만 가능
async remove(slugName: string, userId: string): Promise<void> {
const repo = await this.getEntityOrThrow(slugName);
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: userId } },
});
if (!membership?.isOwner) {
throw new ForbiddenException();
}
await this.repoRepo.remove(repo);
// 목록 캐시 무효화(삭제 즉시 반영)
await this.repoCache.invalidateList();
// Gitea 저장소도 삭제 (베스트 에포트 — 실패 시 로그만 남기고 Relay 삭제는 유지)
if (this.gitea.enabled && repo.giteaRepoId !== null) {
await this.gitea.deleteRepo(repo.slugName).catch((e: unknown) => {
this.logger.error(
`Gitea 저장소 삭제 동기화 실패: ${repo.slugName}`,
e instanceof Error ? e.stack : String(e),
);
});
}
}
// --- 내부 헬퍼 ---
// Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환.
// useTemplate 이면 템플릿 저장소를 복제(generate), 아니면 빈 저장소(auto_init).
private async createGiteaRepo(
slug: string,
meta: {
name: string;
description: string | null;
visibility: RepoVisibility;
branch: string;
useTemplate: boolean;
},
): Promise<GiteaRepoResult> {
const description = this.buildGiteaDescription(meta.name, meta.description);
const isPrivate = meta.visibility === 'private';
try {
if (meta.useTemplate) {
return await this.gitea.generateFromTemplate({
name: slug,
description,
private: isPrivate,
});
}
return await this.gitea.createRepo({
name: slug,
description,
private: isPrivate,
defaultBranch: meta.branch,
});
} catch (err) {
// Gitea 측 이름 충돌 → Relay 와 동일한 중복 문구로 안내
if (err instanceof GiteaApiError && err.status === HTTP_CONFLICT) {
throw new BusinessException(
'BIZ_001',
'이미 사용 중인 저장소 이름입니다.',
HttpStatus.CONFLICT,
);
}
this.logger.error(
`Gitea 저장소 생성 실패: ${slug}`,
err instanceof Error ? err.stack : String(err),
);
throw new BusinessException(
'BIZ_001',
'Gitea 저장소 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
}
// Gitea 저장소 설명 문자열 구성 — Gitea 에 별도 표시명 필드가 없어 '표시제목 — 설명' 으로 합친다
private buildGiteaDescription(
name: string,
description: string | null,
): string {
return description ? `${name}${description}` : name;
}
// slugName 으로 저장소(+멤버) 로딩, 없으면 404
private async getEntityOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({
where: { slugName },
relations: ['members', 'members.user'],
});
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// admin 권한 확인 (아니면 403)
private async assertAdmin(repoId: string, userId: string): Promise<void> {
const membership = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
}
// 단일 저장소의 업무 완료/전체 수 집계
private async taskCountsOf(
repoId: string,
): Promise<{ done: number; total: number } | undefined> {
return (await this.taskService.countsByRepo([repoId])).get(repoId);
}
// 엔티티 → 응답 매핑 (counts 미제공 시 0/0). perms 는 단건 조회에서만 채운다.
private toResponse(
repo: Repo,
counts?: { done: number; total: number },
perms?: { canManage: boolean; isOwner: boolean },
): RepoResponse {
const members = (repo.members ?? []).map((m) =>
UserService.toPublic(m.user),
);
return {
id: repo.slugName,
name: repo.name,
owner: repo.owner,
slug: `${repo.owner}/${repo.slugName}`,
desc: repo.description,
visibility: repo.visibility,
branch: repo.branch,
members,
memberCount: members.length,
// 업무 모듈 집계(없으면 0/0)
doneCount: counts?.done ?? 0,
totalCount: counts?.total ?? 0,
cloneUrl: repo.cloneUrl,
htmlUrl: repo.htmlUrl,
giteaMissing: repo.giteaMissing,
updatedAt: repo.updatedAt.toISOString(),
canManage: perms?.canManage,
isOwner: perms?.isOwner,
};
}
}
+48 -5
View File
@@ -1,4 +1,14 @@
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 승인 요청(담당자 → 지시자) — 메시지 선택
@@ -14,14 +24,47 @@ export class ApprovalRequestDto {
note?: string;
}
// 수정 요청(지시자 → 담당자) — 보완 내용 필수
// 수정 요청 시 항목별 완료 검토 결과(지시자가 체크/X 표시 + 항목별 보완 내용)
export class ChecklistReviewDto {
@ApiProperty({ description: '체크리스트 항목 ID' })
@IsUUID('4')
id!: string;
@ApiProperty({ description: '완료 여부 — true=완성(체크) / false=미완성(X)' })
@IsBoolean()
done!: boolean;
@ApiProperty({
description: '항목별 보완 내용(미완성 항목에만 적용). 생략 시 보완 내용 없음',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}
// 수정 요청(지시자 → 담당자) — 보완 메시지(선택) + 항목별 완료 체크(선택)
export class ApprovalChangesDto {
@ApiProperty({
description: '수정 요청 메시지(보완이 필요한 내용)',
description: '수정 요청 메시지(선택)',
required: false,
example: '세로형 카피 가독성이 낮으니 자간과 대비를 조정해 주세요.',
})
@IsOptional()
@IsString()
@IsNotEmpty({ message: '수정 요청 내용을 입력해 주세요.' })
@MaxLength(2000)
note!: string;
note?: string;
@ApiProperty({
description: '항목별 완료 검토 결과(체크/X). 생략 시 체크리스트 변경 없음',
type: [ChecklistReviewDto],
required: false,
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
@ValidateNested({ each: true })
@Type(() => ChecklistReviewDto)
checklist?: ChecklistReviewDto[];
}
@@ -1,6 +1,8 @@
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsISO8601,
IsNotEmpty,
IsOptional,
@@ -11,6 +13,10 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import {
CHECKLIST_CATEGORIES,
type ChecklistCategory,
} from '../entities/checklist-item.entity';
// 체크리스트 입력 항목 (생성 시 함께 등록)
export class ChecklistInputDto {
@@ -22,6 +28,15 @@ export class ChecklistInputDto {
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
@MaxLength(200)
text!: string;
@ApiProperty({
description: '카테고리(탭)',
enum: CHECKLIST_CATEGORIES,
required: false,
})
@IsOptional()
@IsIn(CHECKLIST_CATEGORIES)
category?: ChecklistCategory;
}
// 업무 생성 요청
@@ -36,23 +51,25 @@ export class CreateTaskDto {
title!: string;
@ApiProperty({
description: '본문 문단 배열(선택)',
description: '본문 문단 배열(필수)',
type: [String],
required: false,
example: ['9월 신제품 캠페인 메인 배너를 제작합니다.'],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty({ message: '업무 내용을 입력해 주세요.' })
@ArrayMaxSize(200, { message: '본문 문단이 너무 많습니다.' })
@IsString({ each: true })
content?: string[];
@MaxLength(5000, { each: true })
content!: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
description: '담당자 사용자 ID 배열(프로젝트 멤버만 가능)',
type: [String],
example: ['a1b2c3d4-...'],
})
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@ArrayMaxSize(50, { message: '담당자가 너무 많습니다.' })
@IsUUID('4', { each: true })
assigneeIds!: string[];
@@ -72,6 +89,7 @@ export class CreateTaskDto {
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
@ValidateNested({ each: true })
@Type(() => ChecklistInputDto)
checklist?: ChecklistInputDto[];
@@ -1,6 +1,8 @@
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsISO8601,
IsNotEmpty,
IsOptional,
@@ -11,6 +13,10 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import {
CHECKLIST_CATEGORIES,
type ChecklistCategory,
} from '../entities/checklist-item.entity';
// 수정 시 체크리스트 항목 — id 있으면 기존 항목 갱신(완료 상태 보존), 없으면 신규 추가
export class UpdateChecklistItemInput {
@@ -30,6 +36,15 @@ export class UpdateChecklistItemInput {
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
@MaxLength(200)
text!: string;
@ApiProperty({
description: '카테고리(탭)',
enum: CHECKLIST_CATEGORIES,
required: false,
})
@IsOptional()
@IsIn(CHECKLIST_CATEGORIES)
category?: ChecklistCategory;
}
// 업무 수정 요청 — 제공된 필드만 갱신
@@ -48,17 +63,20 @@ export class UpdateTaskDto {
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200, { message: '본문 문단이 너무 많습니다.' })
@IsString({ each: true })
@MaxLength(5000, { each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
description: '담당자 사용자 ID 배열(프로젝트 멤버만 가능)',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@ArrayMaxSize(50, { message: '담당자가 너무 많습니다.' })
@IsUUID('4', { each: true })
assigneeIds?: string[];
@@ -79,6 +97,7 @@ export class UpdateTaskDto {
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
@ValidateNested({ each: true })
@Type(() => UpdateChecklistItemInput)
checklist?: UpdateChecklistItemInput[];
@@ -8,6 +8,10 @@ import {
} from 'typeorm';
import { Task } from './task.entity';
// 체크리스트 카테고리(탭) — AI 추천 4개 영역과 동일
export const CHECKLIST_CATEGORIES = ['필수', '관리', '보안', '부가'] as const;
export type ChecklistCategory = (typeof CHECKLIST_CATEGORIES)[number];
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@@ -23,10 +27,18 @@ export class ChecklistItem {
@Column()
text!: string;
// 카테고리(탭) — 필수/관리/보안/부가
@Column({ type: 'varchar', default: '필수' })
category!: ChecklistCategory;
// 완료 여부
@Column({ default: false })
done!: boolean;
// 보완 내용(수정 요청 시 항목별로 입력) — 완료 처리 시 비워짐
@Column({ name: 'review_note', type: 'text', nullable: true })
reviewNote!: string | null;
// 표시 순서(생성 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@@ -14,31 +14,31 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
import { Project } from '../../project/entities/project.entity';
import { ChecklistItem } from './checklist-item.entity';
// 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review)
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes';
// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9)
// 업무 엔티티 — 프로젝트에 종속, 프로젝트 내 순번(seq)으로 식별(#9)
@Entity('tasks')
@Unique(['repo', 'seq'])
@Unique(['project', 'seq'])
export class Task {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 저장소 내 표시 순번(#9) — 라우팅/표시 식별자. 저장소별로 1부터 증가
// 프로젝트 내 표시 순번(#9) — 라우팅/표시 식별자. 프로젝트별로 1부터 증가
@Index()
@Column({ type: 'int' })
seq!: number;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.repo_id 와 일치)
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'repo_id' })
repo!: Relation<Repo>;
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피.
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.project_id 와 일치)
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업무 제목
@Column()
@@ -60,7 +60,7 @@ export class Task {
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
issuer!: User | null;
// 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증)
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
@ManyToMany(() => User)
@JoinTable({
name: 'task_assignees',
@@ -13,7 +13,7 @@ import type { PublicUser } from '../user/user.service';
import { TaskService, type MyTaskRowResponse } from './task.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
// 내 업무(저장소 횡단) — 담당/지시 집계. 그룹핑·라벨은 프론트가 파생
// 내 업무(프로젝트 횡단) — 담당/지시 집계. 그룹핑·라벨은 프론트가 파생
@ApiTags('MyTask')
@Controller('tasks')
@UseGuards(JwtAuthGuard)
@@ -21,7 +21,7 @@ export class MyTaskController {
constructor(private readonly taskService: TaskService) {}
@Get('assigned')
@ApiOperation({ summary: '내가 담당인 업무 (저장소 횡단, 페이지네이션)' })
@ApiOperation({ summary: '내가 담당인 업무 (프로젝트 횡단, 페이지네이션)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listAssigned(
@CurrentUser() user: PublicUser,
@@ -32,7 +32,7 @@ export class MyTaskController {
}
@Get('issued')
@ApiOperation({ summary: '내가 지시한 업무 (저장소 횡단, 페이지네이션)' })
@ApiOperation({ summary: '내가 지시한 업무 (프로젝트 횡단, 페이지네이션)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listIssued(
@CurrentUser() user: PublicUser,
+73 -36
View File
@@ -33,7 +33,7 @@ import {
type AttachmentResponse,
type CommentReplyResponse,
type CommentResponse,
type RepoTaskListResult,
type ProjectTaskListResult,
type TaskDetailResponse,
} from './task.service';
import { CreateTaskDto } from './dto/create-task.dto';
@@ -44,12 +44,14 @@ import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
import {
MAX_FILE_SIZE,
UPLOAD_DIR,
fileMimeFilter,
type UploadedDiskFile,
} from './upload.config';
import { BusinessException } from '../../common/exceptions/business.exception';
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (projectId = slugName, taskId = repo 내 순번)
@ApiTags('Task')
@Controller('repos/:repoId/tasks')
@Controller('projects/:projectId/tasks')
@UseGuards(JwtAuthGuard)
export class TaskController {
constructor(private readonly taskService: TaskService) {}
@@ -59,16 +61,24 @@ export class TaskController {
summary: '업무 목록 (세그먼트/검색 + 페이지네이션 + 세그먼트 카운트)',
})
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
@ApiResponse({
status: 404,
description: '프로젝트를 찾을 수 없음 (RES_001)',
})
list(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
@Query('segment') segment?: string,
@Query('q') q?: string,
@Query('assignee') assignee?: string,
): Promise<RepoTaskListResult> {
return this.taskService.list(repoId, { segment, q, assignee }, page, size);
): Promise<ProjectTaskListResult> {
return this.taskService.list(
projectId,
{ segment, q, assignee },
page,
size,
);
}
@Post()
@@ -78,14 +88,14 @@ export class TaskController {
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 400,
description: '담당자가 저장소 멤버가 아님 (BIZ_001)',
description: '담당자가 프로젝트 멤버가 아님 (BIZ_001)',
})
create(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Body() dto: CreateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.create(repoId, user.id, dto);
return this.taskService.create(projectId, user.id, dto);
}
@Get(':taskId')
@@ -93,10 +103,10 @@ export class TaskController {
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
): Promise<TaskDetailResponse> {
return this.taskService.findOne(repoId, taskId);
return this.taskService.findOne(projectId, taskId);
}
@Patch(':taskId')
@@ -104,12 +114,12 @@ export class TaskController {
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: UpdateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.update(repoId, user.id, taskId, dto);
return this.taskService.update(projectId, user.id, taskId, dto);
}
@Delete(':taskId')
@@ -118,11 +128,11 @@ export class TaskController {
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.taskService.remove(repoId, user.id, taskId);
await this.taskService.remove(projectId, user.id, taskId);
return null;
}
@@ -136,12 +146,17 @@ export class TaskController {
description: '허용되지 않는 상태 전이 (BIZ_001)',
})
changeStatus(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ChangeStatusDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
return this.taskService.changeStatus(
projectId,
user.id,
taskId,
dto.status,
);
}
/* ===================== 댓글 / 답글 (5단계) ===================== */
@@ -151,13 +166,13 @@ export class TaskController {
@ApiOperation({ summary: '댓글 작성 (멤버)' })
@ApiResponse({ status: 201, description: '작성 성공' })
addComment(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: CreateCommentDto,
@CurrentUser() user: PublicUser,
): Promise<CommentResponse> {
return this.taskService.addComment(
repoId,
projectId,
user.id,
taskId,
dto.text,
@@ -170,14 +185,14 @@ export class TaskController {
@ApiOperation({ summary: '답글 작성 (멤버)' })
@ApiResponse({ status: 201, description: '작성 성공' })
addReply(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('commentId', ParseUUIDPipe) commentId: string,
@Body() dto: CreateReplyDto,
@CurrentUser() user: PublicUser,
): Promise<CommentReplyResponse> {
return this.taskService.addReply(
repoId,
projectId,
user.id,
taskId,
commentId,
@@ -193,18 +208,27 @@ export class TaskController {
FileInterceptor('file', {
dest: UPLOAD_DIR,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: fileMimeFilter,
}),
)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: '파일 업로드 (멤버)' })
@ApiResponse({ status: 201, description: '업로드 성공' })
addFile(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@UploadedFile() file: UploadedDiskFile,
@CurrentUser() user: PublicUser,
): Promise<AttachmentResponse> {
return this.taskService.addFile(repoId, user.id, taskId, file);
// 파일 누락 또는 허용되지 않는 형식(fileFilter 가 걸러 file 미존재)
if (!file) {
throw new BusinessException(
'BIZ_001',
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
return this.taskService.addFile(projectId, user.id, taskId, file);
}
@Delete(':taskId/files/:fileId')
@@ -212,12 +236,12 @@ export class TaskController {
@ApiOperation({ summary: '파일 삭제 (멤버)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async removeFile(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('fileId', ParseUUIDPipe) fileId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.taskService.removeFile(repoId, user.id, taskId, fileId);
await this.taskService.removeFile(projectId, user.id, taskId, fileId);
return null;
}
@@ -225,7 +249,7 @@ export class TaskController {
@ApiOperation({ summary: '파일 다운로드 (멤버)' })
@ApiResponse({ status: 200, description: '파일 스트림' })
async downloadFile(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('fileId', ParseUUIDPipe) fileId: string,
@CurrentUser() user: PublicUser,
@@ -233,16 +257,18 @@ export class TaskController {
): Promise<void> {
const { attachment, absolutePath } =
await this.taskService.getFileForDownload(
repoId,
projectId,
user.id,
taskId,
fileId,
);
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res)
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res).
// attachment(다운로드 강제) + nosniff 로 브라우저 인라인 렌더링/스니핑 차단(저장형 XSS 방지)
res.setHeader('Content-Type', attachment.mime);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Disposition',
`inline; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.sendFile(absolutePath);
}
@@ -255,12 +281,17 @@ export class TaskController {
@ApiResponse({ status: 200, description: '요청 성공' })
@ApiResponse({ status: 409, description: '허용되지 않는 전이 (BIZ_001)' })
requestApproval(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ApprovalRequestDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.requestApproval(repoId, user.id, taskId, dto.note);
return this.taskService.requestApproval(
projectId,
user.id,
taskId,
dto.note,
);
}
@Post(':taskId/approval/approve')
@@ -269,11 +300,11 @@ export class TaskController {
@ApiResponse({ status: 200, description: '승인 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
approve(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.approve(repoId, user.id, taskId);
return this.taskService.approve(projectId, user.id, taskId);
}
@Post(':taskId/approval/changes')
@@ -282,11 +313,17 @@ export class TaskController {
@ApiResponse({ status: 200, description: '요청 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
requestChanges(
@Param('repoId') repoId: string,
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ApprovalChangesDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.requestChanges(repoId, user.id, taskId, dto.note);
return this.taskService.requestChanges(
projectId,
user.id,
taskId,
dto.note,
dto.checklist,
);
}
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Project } from '../project/entities/project.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { Comment } from './entities/comment.entity';
@@ -12,7 +12,7 @@ import { TaskService } from './task.service';
import { TaskController } from './task.controller';
import { MyTaskController } from './my-task.controller';
// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인)
// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인) — 프로젝트 종속
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -20,8 +20,8 @@ import { MyTaskController } from './my-task.controller';
ChecklistItem,
Comment,
Attachment,
Repo,
RepoMember,
Project,
ProjectMember,
]),
ActivityModule,
NotificationModule,
+67 -21
View File
@@ -2,8 +2,8 @@ import { ForbiddenException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { TaskService } from './task.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Project } from '../project/entities/project.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { Comment } from './entities/comment.entity';
@@ -21,7 +21,7 @@ function makeService() {
createQueryBuilder: jest.fn(),
};
const checklistRepo = { create: jest.fn(), save: jest.fn() };
const repoRepo = { findOne: jest.fn() };
const projectRepo = { findOne: jest.fn() };
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
@@ -36,8 +36,8 @@ function makeService() {
const svc = new TaskService(
taskRepo as unknown as Repository<Task>,
checklistRepo as unknown as Repository<ChecklistItem>,
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
projectRepo as unknown as Repository<Project>,
memberRepo as unknown as Repository<ProjectMember>,
commentRepo as unknown as Repository<Comment>,
attachmentRepo as unknown as Repository<Attachment>,
activity as unknown as ActivityService,
@@ -47,7 +47,7 @@ function makeService() {
svc,
taskRepo,
checklistRepo,
repoRepo,
projectRepo,
memberRepo,
commentRepo,
attachmentRepo,
@@ -60,8 +60,8 @@ const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
describe('TaskService', () => {
describe('changeStatus — 상태 머신', () => {
it('허용되지 않는 전이(todo→done)는 BusinessException', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
const { svc, taskRepo, projectRepo, memberRepo } = makeService();
projectRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' });
taskRepo.findOne.mockResolvedValue({
seq: 1,
@@ -77,8 +77,8 @@ describe('TaskService', () => {
});
it('승인(review→done)은 지시자/admin 이 아니면 Forbidden', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
const { svc, taskRepo, projectRepo, memberRepo } = makeService();
projectRepo.findOne.mockResolvedValue(REPO);
// 요청자는 일반 멤버(담당자)
memberRepo.findOne.mockResolvedValue({ roleType: 'member' });
taskRepo.findOne.mockResolvedValue({
@@ -95,8 +95,8 @@ describe('TaskService', () => {
});
it('비멤버는 상태 변경 불가(Forbidden)', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
const { svc, projectRepo, memberRepo } = makeService();
projectRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue(null); // 멤버십 없음
await expect(
@@ -105,9 +105,9 @@ describe('TaskService', () => {
});
it('승인(review→done) 시 체크리스트를 모두 완료 처리한다', async () => {
const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } =
const { svc, taskRepo, checklistRepo, projectRepo, memberRepo } =
makeService();
repoRepo.findOne.mockResolvedValue(REPO);
projectRepo.findOne.mockResolvedValue(REPO);
// 지시자/admin — assertMember 는 user 관계까지 로드(알림 actorName 용)
memberRepo.findOne.mockResolvedValue({
roleType: 'admin',
@@ -142,8 +142,8 @@ describe('TaskService', () => {
describe('승인 워크플로우 (5단계)', () => {
it('수정 요청(review→changes)은 지시자/admin 이 아니면 Forbidden', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
const { svc, taskRepo, projectRepo, memberRepo } = makeService();
projectRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({
roleType: 'member',
user: { id: 'assignee' },
@@ -161,16 +161,62 @@ describe('TaskService', () => {
).rejects.toBeInstanceOf(ForbiddenException);
});
it('수정 요청 시 전달된 항목만 완료 체크(체크/X)를 반영한다', async () => {
const { svc, taskRepo, checklistRepo, projectRepo, memberRepo } =
makeService();
projectRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({
roleType: 'admin',
user: { id: 'admin', name: 'A' },
});
const task = {
seq: 9,
status: 'review',
title: '배너',
content: [],
issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null },
assignees: [],
checklist: [
{ id: 'c1', text: '가로형', done: true, sortOrder: 0 },
{ id: 'c2', text: '세로형', done: false, sortOrder: 1 },
{ id: 'c3', text: '모바일', done: true, sortOrder: 2 },
],
dueDate: null,
createdAt: new Date('2026-06-08T00:00:00Z'),
};
taskRepo.findOne.mockResolvedValue(task);
taskRepo.save.mockResolvedValue(task);
checklistRepo.save.mockResolvedValue(task.checklist);
const result = await svc.requestChanges('q3-launch', 'admin', 9, '보완 바랍니다', [
{ id: 'c1', done: false, note: '여백을 더 주세요' }, // 완성 → 미완성(X) + 보완 내용
{ id: 'c2', done: true, note: '무시됨' }, // 미완성 → 완성(체크): 보완 내용 비움
// c3 은 미전달 → 기존값(true) 유지
]);
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
const c1 = task.checklist.find((c) => c.id === 'c1');
const c2 = task.checklist.find((c) => c.id === 'c2');
const c3 = task.checklist.find((c) => c.id === 'c3');
expect(c1?.done).toBe(false);
expect(c1?.reviewNote).toBe('여백을 더 주세요'); // 미완성 항목은 보완 내용 보관
expect(c2?.done).toBe(true);
expect(c2?.reviewNote).toBeNull(); // 완료 항목은 보완 내용 비움
expect(c3?.done).toBe(true);
expect(result.status).toBe('changes');
expect(result.changesNote).toBe('보완 바랍니다');
});
it('승인 요청(prog→review)은 담당자가 가능', async () => {
const {
svc,
taskRepo,
repoRepo,
projectRepo,
memberRepo,
commentRepo,
attachmentRepo,
} = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
projectRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({
roleType: 'member',
user: { id: 'assignee', email: 'a@b.c', name: 'A', role: null },
@@ -212,9 +258,9 @@ describe('TaskService', () => {
});
describe('create — 담당자 멤버 검증', () => {
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
it('담당자가 프로젝트 멤버가 아니면 BusinessException', async () => {
const { svc, projectRepo, memberRepo } = makeService();
projectRepo.findOne.mockResolvedValue(REPO);
// assertAdmin 통과
memberRepo.findOne.mockResolvedValue({
roleType: 'admin',
File diff suppressed because it is too large Load Diff
+34
View File
@@ -13,6 +13,40 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
// 첨부 1건당 최대 크기(25MB)
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
// 허용 MIME 화이트리스트 — 실행 가능/스크립트(예: text/html, image/svg+xml) 차단.
// 다운로드는 attachment + nosniff 로 제공하지만, 업로드 단계에서도 1차로 막는다(심층 방어).
export const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
// 이미지
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
// 문서
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// 압축
'application/zip',
'application/x-zip-compressed',
'application/x-7z-compressed',
'application/x-rar-compressed',
]);
// multer fileFilter — 허용 MIME 만 통과. 거부 시 파일이 저장되지 않아 컨트롤러가 400 처리.
export function fileMimeFilter(
_req: unknown,
file: { mimetype: string },
cb: (error: Error | null, acceptFile: boolean) => void,
): void {
cb(null, ALLOWED_MIME_TYPES.has(file.mimetype));
}
// multer(diskStorage) 가 채워주는 업로드 파일 메타 — @types/multer 의존 없이 필요한 필드만 정의
export interface UploadedDiskFile {
originalname: string; // 원본 파일명(비ASCII 는 latin1 인코딩으로 들어옴)
@@ -17,9 +17,60 @@ export class User {
@Column({ unique: true })
email!: string;
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false)
@Column({ name: 'password_hash', select: false })
passwordHash!: string;
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false).
// 소셜 전용 계정은 비밀번호가 없으므로 null.
@Column({
name: 'password_hash',
type: 'varchar',
nullable: true,
select: false,
})
passwordHash!: string | null;
// 가입 경로 — 'local'(이메일) | 'google' | 'kakao'. 로그인 안내·계정 연동 판별에 사용.
@Column({ type: 'varchar', default: 'local' })
provider!: string;
// 이메일 인증 완료 여부 — 로컬 가입은 인증 메일 클릭 후 true, 소셜 가입은 즉시 true.
// 미인증 로컬 계정은 로그인이 차단된다.
@Column({ name: 'email_verified', type: 'boolean', default: false })
emailVerified!: boolean;
// 이메일 인증 토큰 해시(SHA-256) — 원문은 메일 링크에만 담고 DB엔 해시만 저장. 조회 기본 제외.
@Column({
name: 'email_verify_token_hash',
type: 'varchar',
nullable: true,
select: false,
})
emailVerifyTokenHash!: string | null;
// 이메일 인증 토큰 만료 시각 — 만료 후 클릭은 무효. 조회 기본 제외.
@Column({
name: 'email_verify_expires_at',
type: 'timestamptz',
nullable: true,
select: false,
})
emailVerifyExpiresAt!: Date | null;
// 비밀번호 재설정 토큰 해시(SHA-256) — 원문은 메일 링크에만, DB엔 해시만. 조회 기본 제외.
@Column({
name: 'password_reset_token_hash',
type: 'varchar',
nullable: true,
select: false,
})
passwordResetTokenHash!: string | null;
// 비밀번호 재설정 토큰 만료 시각 — 보안상 짧게(1시간). 조회 기본 제외.
@Column({
name: 'password_reset_expires_at',
type: 'timestamptz',
nullable: true,
select: false,
})
passwordResetExpiresAt!: Date | null;
// 표시 이름
@Column()
+117 -4
View File
@@ -1,8 +1,16 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
Logger,
type OnApplicationBootstrap,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { ILike, IsNull, LessThan, Repository } from 'typeorm';
import { User } from './entities/user.entity';
// 이메일 인증 도입 컷오프 — 이 시각 이전에 생성된 로컬 계정만 그랜드페더링 대상.
// 이후 가입은 항상 정상 인증 절차를 거치므로 토큰 상태와 무관하게 자동 인증되지 않는다.
const LEGACY_VERIFY_CUTOFF = new Date('2026-06-20T00:00:00Z');
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
export interface PublicUser {
id: string;
@@ -14,12 +22,35 @@ export interface PublicUser {
// 사용자 데이터 접근 계층 — 인증 모듈이 주입하여 사용
@Injectable()
export class UserService {
export class UserService implements OnApplicationBootstrap {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
// 이메일 인증 도입 이전에 만들어진 로컬 계정 그랜드페더링 —
// 컷오프 이전 생성 + 인증 토큰이 없는 로컬 계정만 인증 완료로 간주.
// 컷오프 이후 가입은 토큰 상태와 무관하게 대상에서 제외되어, 정상 인증 절차를 항상 거친다.
// 멱등(idempotent): 최초 1회만 실제로 갱신되고 이후에는 대상이 없다.
async onApplicationBootstrap(): Promise<void> {
const result = await this.userRepository.update(
{
provider: 'local',
emailVerified: false,
emailVerifyTokenHash: IsNull(),
createdAt: LessThan(LEGACY_VERIFY_CUTOFF),
},
{ emailVerified: true },
);
if (result.affected && result.affected > 0) {
this.logger.log(
`이메일 인증 도입 전 기존 로컬 계정 ${result.affected}건을 인증 완료로 보정`,
);
}
}
// id 단건 조회 (없으면 null)
findById(id: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id } });
@@ -54,7 +85,17 @@ export class UserService {
.getOne();
}
// 신규 사용자 생성 (비밀번호는 해시된 상태로 전달받음)
// 이메일 인증 토큰 해시로 조회 — 인증 토큰 컬럼은 select:false 라 명시적으로 포함.
findByEmailVerifyTokenHash(tokenHash: string): Promise<User | null> {
return this.userRepository
.createQueryBuilder('user')
.addSelect('user.emailVerifyTokenHash')
.addSelect('user.emailVerifyExpiresAt')
.where('user.emailVerifyTokenHash = :tokenHash', { tokenHash })
.getOne();
}
// 신규 로컬 사용자 생성 (비밀번호는 해시된 상태로 전달받음, 기본 미인증)
create(payload: {
email: string;
passwordHash: string;
@@ -66,10 +107,82 @@ export class UserService {
passwordHash: payload.passwordHash,
name: payload.name,
role: payload.role ?? null,
provider: 'local',
emailVerified: false,
});
return this.userRepository.save(user);
}
// 소셜(OAuth) 사용자 생성 — 비밀번호 없음, 제공자가 이메일을 검증하므로 인증 완료 상태.
createOAuthUser(payload: {
email: string;
name: string;
provider: string;
avatarUrl?: string | null;
}): Promise<User> {
const user = this.userRepository.create({
email: payload.email,
passwordHash: null,
name: payload.name,
provider: payload.provider,
avatarUrl: payload.avatarUrl ?? null,
emailVerified: true,
});
return this.userRepository.save(user);
}
// 이메일 인증 토큰(해시)·만료 시각 저장
async setEmailVerifyToken(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<void> {
await this.userRepository.update(userId, {
emailVerifyTokenHash: tokenHash,
emailVerifyExpiresAt: expiresAt,
});
}
// 이메일 인증 완료 처리 — 인증 플래그 true, 토큰 폐기
async markEmailVerified(userId: string): Promise<void> {
await this.userRepository.update(userId, {
emailVerified: true,
emailVerifyTokenHash: null,
emailVerifyExpiresAt: null,
});
}
// 비밀번호 재설정 토큰(해시)·만료 시각 저장
async setPasswordResetToken(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<void> {
await this.userRepository.update(userId, {
passwordResetTokenHash: tokenHash,
passwordResetExpiresAt: expiresAt,
});
}
// 비밀번호 재설정 토큰 해시로 조회 — 토큰 컬럼은 select:false 라 명시적으로 포함.
findByPasswordResetTokenHash(tokenHash: string): Promise<User | null> {
return this.userRepository
.createQueryBuilder('user')
.addSelect('user.passwordResetTokenHash')
.addSelect('user.passwordResetExpiresAt')
.where('user.passwordResetTokenHash = :tokenHash', { tokenHash })
.getOne();
}
// 비밀번호 변경 — 새 해시 저장 + 재설정 토큰 폐기
async updatePassword(userId: string, passwordHash: string): Promise<void> {
await this.userRepository.update(userId, {
passwordHash,
passwordResetTokenHash: null,
passwordResetExpiresAt: null,
});
}
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
static toPublic(user: User): PublicUser {
return {
+8 -2
View File
@@ -10,6 +10,7 @@ services:
args:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
expose:
- "80"
environment:
@@ -21,7 +22,8 @@ services:
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
# localhost 는 일부 이미지에서 IPv6(::1) 로 먼저 풀려 IPv4 전용 바인딩에 연결 실패 → 127.0.0.1 고정
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
@@ -58,6 +60,8 @@ services:
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
# AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급
- AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
restart: unless-stopped
networks:
- app-network
@@ -68,7 +72,9 @@ services:
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:${BACKEND_PORT}/ || exit 1"]
# API 는 전역 프리픽스 /api 하위에 마운트됨 — 루트(/)는 404 라 /api/health 를 프로브.
# localhost→IPv6(::1) 회피를 위해 127.0.0.1 고정.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
+43 -7
View File
@@ -59,10 +59,14 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, name }` | `{ user }` | 가입 후 자동 로그인(쿠키 set) |
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set |
| POST | `/auth/signup` | `{ email, password, name }` | `{ email, verificationPending }` | **자동 로그인 X** — 인증 메일 발송, 쿠키 미발급 |
| POST | `/auth/resend-verification` | `{ email }` | `null` | 인증 메일 재발송(존재 여부 비노출) |
| GET | `/auth/verify-email` | `?token` | 302 redirect | 메일 링크. 검증 후 `FRONTEND_URL/login?verified=1\|0` 로 |
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set. 미인증/소셜전용 계정은 차단 |
| GET | `/auth/google` · `/auth/kakao` | — | 302 redirect | 소셜 동의 화면으로(전체 페이지 이동) |
| GET | `/auth/google/callback` · `/auth/kakao/callback` | — | 302 redirect | 쿠키 set 후 `FRONTEND_URL/repos` 로 |
| POST | `/auth/logout` | — | `null` | 쿠키 clear |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | **회전형** — access/refresh 모두 교체. 재사용/만료/누락 시 401(쿠키 제거) |
| GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 |
`User` 응답 형태:
@@ -72,12 +76,19 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
"id": "uuid",
"email": "sykim@acme.co",
"name": "김서연",
"role": "콘텐츠 기획" // 전사 직무(선택)
"role": "콘텐츠 기획", // 전사 직무(선택)
"avatarUrl": null // 프로필 이미지 URL (소셜 가입 시 제공자 이미지)
}
```
> 프론트 매핑: `auth.store.ts`(신규) + `useAuth` composable. 라우터 가드 TODO 를
> `me` 부트스트랩 + 401→`/login` 리다이렉트로 교체. `LoginPage`/`SignupPage` 의 `router.push` 를 실제 호출로.
### 회원가입 · 이메일 인증 · 소셜 로그인 흐름
- **이메일 가입**: `signup` → 미인증 계정 생성 + 인증 메일 발송(현재는 **백엔드 로그로 링크 출력**`MailService` 플레이스홀더). 사용자가 메일의 `verify-email` 링크 클릭 → 인증 완료 → `/login?verified=1` 로 리다이렉트. **인증 전에는 로그인 차단**(`BIZ_001` 403).
- **토큰**: 원문 토큰은 메일 링크에만, DB엔 SHA-256 해시 + 만료(24h)만 저장. 재발송은 사용자 존재/상태를 노출하지 않는다.
- **소셜 로그인**(Google/Kakao): 프론트가 `/auth/{provider}` 로 전체 페이지 이동 → 콜백에서 이메일로 계정 매칭(없으면 생성, `emailVerified=true`) → 쿠키 발급 후 `/repos` 로. 키(`*_CLIENT_ID`) 미설정 시 해당 제공자 비활성.
- **계정 모델**: `provider`('local'|'google'|'kakao'), `emailVerified`. 소셜 전용 계정은 비밀번호가 없어(`passwordHash=null`) 비밀번호 로그인 시 소셜 안내. 인증 도입 이전 로컬 계정은 부팅 시 그랜드페더링(토큰 없는 로컬 계정 → 인증 완료).
- **세션/토큰 회전**: refresh 는 **회전형 + 재사용 탐지**. `refresh_sessions`(패밀리 단위, 한 로그인=1행) 에 현재 jti 를 두고, `/auth/refresh` 마다 새 jti 로 회전. 이미 회전된 옛 토큰이 다시 오면 탈취로 간주해 패밀리 폐기(전 세션 강제 로그아웃). 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight(`useApi`)로 방지. logout=해당 패밀리만 폐기. (access 토큰은 15분 만료까지 유효 — 즉시 폐기 denylist 미적용.)
- **환경변수**: `APP_API_URL`(메일 링크용 백엔드 주소), `FRONTEND_URL`(리다이렉트 대상), `GOOGLE_*`/`KAKAO_*`(OAuth 키·콜백). `.env.development.example` 참조.
---
@@ -87,6 +98,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|---|---|---|---|
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) |
| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) |
| GET | `/repos/name-available` | `?slug` | `{ available: boolean }` — 이름(slug) 중복 체크(`:repoId` 보다 먼저 매칭) |
| POST | `/repos` | `CreateRepoDto` | `Repo` |
| GET | `/repos/:repoId` | — | `Repo`(상세) |
| PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` |
@@ -95,6 +107,9 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
`GET /repos/meta` 응답: `{ owner: string, template: { available: boolean, slug: string } }`
— 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용.
`GET /repos/name-available?slug=` 응답: `{ available }` — 생성 폼의 **실시간 이름 가용성 체크**용(Relay slug 중복만 검사).
프론트는 형식(`^[a-z0-9-_]+$`)을 즉시 검사하고, 통과 시 디바운스로 이 API 를 호출한다. Gitea 이름 충돌은 생성(`POST /repos`) 시 최종 확인(`BIZ_001`).
`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description, branch?, useTemplate? }`
(**description 필수**, `useTemplate=true` 면 템플릿 저장소를 복제해 생성)
`Repo` 응답(원시값 중심):
@@ -317,9 +332,30 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
전역 CacheModule 을 Redis(keyv)로 전환(`REDIS_*` 미설정 시 인메모리 폴백, 기본 TTL 60초). **`GET /repos`(저장소 목록)**을 버전 네임스페이스로 캐시 — 키 `repos:list:v{ver}:p{page}:s{size}`, **저장소 생성/수정/삭제·Gitea 동기화·멤버 초대/제거** 시 버전 증가로 일괄 무효화(멤버 역할 변경은 목록 응답 불변이라 제외). 버전 카운터가 공유 Redis 에 있어 **무효화가 다중 인스턴스에서도 정합**. 업무 카운트(`doneCount/totalCount`)는 ≤60초 지연 허용. 응답 계약 불변(캐시는 투명) — 프론트 영향 없음. `GET /repos/meta` 는 DB 미접근 인메모리 getter 라 캐싱 제외.
### 소셜 로그인 · 회원가입 · 이메일 인증 — **✅ 완료**
Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조.
### AI 채팅 (`/api/ai/conversations`) — **✅ 완료 (DB 영속, 다중 대화)**
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| GET | `/ai/conversations` | `?page&size` | `{items:[{id,title,updatedAt}],total,...}` | 내 대화 목록(최근순) |
| GET | `/ai/conversations/:id` | — | `{id,title,messages:[{role,content}]}` | 대화 상세(본인 소유만) |
| POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 |
| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 |
| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) |
| POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 |
- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기).
- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지).
- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트).
- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크).
- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵).
### 그 외(미착수)
소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.)
메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전·AI 채팅은 완료.)
---
+57 -3
View File
@@ -254,7 +254,7 @@
**스택**: `@nestjs/cache-manager@3` + `cache-manager@6`(Keyv 기반) → Redis 연결은 신규 의존성 **`@keyv/redis`**. docker-compose 가 이미 redis 컨테이너 + `REDIS_HOST/PORT/PASSWORD` 주입.
**백엔드**
- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(단독 실행/테스트). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(REDIS_HOST 미설정 시). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
- **저장소 목록 캐싱**(`GET /repos`): 신규 `RepoCacheService`(repo 모듈) — **버전 네임스페이스 무효화**. 키 `repos:list:v{ver}:p{page}:s{size}`, 변경 시 `repos:list:ver` 증가로 기존 키 일괄 폐기(keyv 와일드카드 삭제 미지원 회피). 모든 캐시 접근은 try/catch 로 실패 시 DB 조회 폴백.
- **무효화 지점**: RepoService 생성/수정/삭제 + RepoSyncService 동기화 + **MemberService 초대/제거**(목록 카드 멤버 수·아바타 반영, 역할 변경은 목록 불변이라 제외 — *2026-06-19 보강*). `RepoCacheService` 공유.
- **보강(2026-06-19, 검토 [`redis-cache-review.md`](./redis-cache-review.md))**: ① C1 멤버 변경 무효화, ② 기동 시 캐시 모드 로그(Redis/인메모리 가시화), ③ `main.ts` `enableShutdownHooks()` — 종료 시 알림 Redis pub/sub 연결 graceful close(이전엔 훅 미설정으로 `onModuleDestroy` 가 死코드였음).
@@ -265,8 +265,62 @@
> **알려진 한계**: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ **의존성 추가(`@keyv/redis`)라 dev 컨테이너는 `up -d --build -V`(익명 node_modules 볼륨 갱신) 필요.**
#### 8-4. 그 외(미착수)
- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널 실연동.
#### 8-4. 소셜 로그인 + 회원가입 이메일 인증 ✅ (2026-06-19)
**지시(2026-06-19)**: 소셜 키는 백엔드 env 에 등록됨 → 바로 연동. 회원가입은 인증 메일의 버튼 클릭 시 완료. **메일 전송은 우선 백엔드 로그로 대체.**
**백엔드**
- **User 엔티티 확장**(`modules/user/entities/user.entity.ts`): `passwordHash` **nullable**(소셜 전용 계정=null), `provider`('local'|'google'|'kakao', default 'local'), `emailVerified`(bool, default false), `emailVerifyTokenHash`/`emailVerifyExpiresAt`(둘 다 select:false). 원문 인증 토큰은 메일 링크에만 담고 DB엔 **SHA-256 해시 + 만료(24h)** 만 저장.
- **그랜드페더링**(`UserService.onApplicationBootstrap`): 인증 도입 이전 로컬 계정 락아웃 방지 — `provider='local' & 토큰 null & 미인증` 행을 인증완료로 보정(멱등 — 진행 중 미인증 계정은 토큰을 보유해 제외).
- **메일 모듈**(신규 `modules/mail/`): `MailService.sendVerificationEmail()` 플레이스홀더 — **인증 링크를 백엔드 Logger 로 출력**(추후 SMTP 도입 시 이 서비스만 교체).
- **AuthService**: `signup` **자동 로그인 폐지** → 미인증 계정 생성 + 인증 메일 발송, 응답 `{email, verificationPending}`. `verifyEmail(token)`(해시 매칭 + 만료 검사 → 인증 완료, 토큰 폐기), `resendVerification`(존재/상태 비노출), `validateUser`**로그인 게이트**(미인증 로컬=`BIZ_001` 403, 비밀번호 없는 소셜 전용=소셜 안내), `validateOrCreateOAuthUser`(이메일로 매칭→없으면 생성 emailVerified=true, 미인증 로컬과 이메일 같으면 인증 보정).
- **소셜 전략**(passport-google-oauth20·passport-kakao 신규 의존성, kakao 무타입→`auth/types/passport-kakao.d.ts` 앰비언트 선언): `GoogleStrategy`/`KakaoStrategy` 가 프로필 정규화 후 위임. **키(`*_CLIENT_ID`) 미설정 시 전략 미등록**(AuthModule `useFactory` 가드 — null 반환).
- **AuthController**: `signup`(쿠키 미발급)·`resend-verification`·**`GET verify-email`**(`@SkipTransform`+`@Res` 302 → `FRONTEND_URL/login?verified=1|0`)·**`GET google|kakao` + `/callback`**(`@SkipTransform`, 콜백서 쿠키 발급 → `FRONTEND_URL/repos`). `loginWith` 헬퍼로 토큰 발급+쿠키 공통화.
- **env**: `APP_API_URL`(메일 링크용 백엔드 주소, 폴백 `localhost:3100/api``FRONTEND_URL`(리다이렉트, 폴백 CORS_ORIGIN→5273) + `GOOGLE_*`/`KAKAO_*`(콜백 URL 은 백엔드 포트 **3100**, `/api/auth/{provider}/callback`) → `backend/.env.development.example` 문서화.
**프론트**
- `types/auth.ts` `SignupResult`, `useAuth`/`auth.store`: signup→대기(user 미갱신) + `resendVerification`.
- **SignupPage**: 가입 성공 시 자동 로그인 대신 **인증 메일 안내(verify 단계)** 표시 + 재발송 버튼 연동.
- **LoginPage**: 소셜 버튼 실연동(`window.location.href = ${VITE_API_BASE_URL}/auth/{provider}` 전체 페이지 이동) + `?verified=1|0` 결과 배너.
**검증**: 백엔드 build/lint 0 err·20 tests pass(MailService 가 AuthService 4번째 생성자 인자 — 영향 spec 없음), 프론트 type-check/lint/build 0 err.
> **알려진 한계**: ① **런타임 미검증** — DB + 소셜 콘솔에 콜백 URL 등록 필요(코드 레벨까지). ② 메일은 **로그 출력**(실발송 SMTP 미구현). ③ **의존성 추가(passport-google-oauth20/passport-kakao)라 dev 컨테이너 `up -d --build -V` 필요.** ④ 소셜 계정은 이메일 기준 매칭(별도 providerId 컬럼 없음) — 동일 이메일이면 동일인으로 연동.
#### 8-5. 보안 점검 + 보완 (배치 A+B) ✅ (2026-06-19)
전 작업분 대상 보안 감사(4영역 병렬) → `docs/security-review.md`. 서버측 기반은 견고(IDOR/권한상승/SQL인젝션/에러누출 없음), 최약점은 신규 소셜 로그인. 사용자 결정: **HIGH+MEDIUM 적용**, 조직 전체 읽기 모델은 의도된 설계로 유지.
- **H1** OAuth 제공자 이메일 검증 미확인(계정 탈취/선점) → 전략이 `email_verified`/`is_email_verified` 를 읽어 전달, 미검증 시 403 거부.
- **H2** OAuth `state` 누락(로그인 CSRF) → 세션 없는 구조라 `CookieStateStore`(httpOnly 1회용 state 쿠키) 신설, 두 전략에 주입(새 의존성 0).
- **H3** refresh 회전/폐기 부재 → (초기) `User.tokenVersion` 폐기 → **(강화) 세션 기반 완전 회전 + 재사용 탐지로 교체**. `RefreshSession`(패밀리, 한 로그인=1행: id/currentJti/prevJti+prevAt/expiresAt) 도입. `/auth/refresh` 가 현재 jti면 새 jti로 회전, 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 `useApi` single-flight 로 방지. logout=패밀리 폐기, `revokeAllSessions`(전 기기) 준비. `tokenVersion`·`jwt-refresh.strategy`·`JwtRefreshGuard` 제거(refresh 는 가드 없이 서비스 직접 처리).
- **M1** 업로드 MIME 화이트리스트(`fileFilter`, html/svg 차단)+거부 400, 다운로드 `attachment`+`nosniff`(저장형 XSS 차단).
- **M2** `@Throttle` 라우트별(login 10·signup 5·resend 3 /분).
- **M3** 첨부 삭제 업로더/admin 한정.
- **M4** 인증 토큰 로그 prod 게이트(원문 토큰 운영 로그 미기록).
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. **런타임 미검증**(OAuth 제공자 콜백+DB 필요).
**배치 C (LOW 하드닝, L2~L6) ✅** — L1(에이전트 v-html)은 에이전트 패널 미연동이라 사용자 요청으로 보류.
- **L2** DTO 길이 바운드(task `content`/`assigneeIds`/`checklist` `@ArrayMaxSize`+요소 `@MaxLength`, `UpdateRepoDto.description` `@MaxLength(300)`).
- **L3** 비번 정책(`@MaxLength(72)`+영문·숫자 `@Matches`) + bcrypt 10→12.
- **L4** 그랜드페더링 컷오프(`2026-06-20` 이전 생성 한정, `createdAt LessThan`).
- **L5** multer 오류 → 400(VAL_001) 매핑(전역 필터).
- **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정.
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음.
#### 8-6. AI 채팅 패널 ✅ (2026-06-19)
**지시**: 기존 목업 UI(가짜 "relay order draft" 터미널)는 버리고, **Claude API로 채팅만** 되도록. 키는 백엔드 env(`CLAUDE_API_KEY`)에 등록됨.
- **백엔드** 신규 `modules/ai/`: `AiService`(Anthropic Messages API 프록시 — 글로벌 `fetch` + `AbortSignal.timeout(30s)`, 키는 서버 env 만, 미설정 503·외부오류 502·내부정보 비노출), `AiController` `POST /ai/chat`(JwtAuthGuard + `@Throttle` 20/분), `ChatDto`(role enum + content MaxLength 8000 + 메시지 ArrayMaxSize 50). 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). `AiModule` → app.module.
- **프론트** 신규 `components/AgentChatPanel.vue`(우측 슬라이드 채팅 패널, 환영문구·말풍선·타이핑 인디케이터·에러·새대화, plain text + `white-space:pre-wrap`로 XSS 안전), `types/ai.ts`·`composables/useAi.ts`(대화 이력 전체 전송, 서버 무상태). **TaskDetailPage 의 기존 목업 에이전트 패널(스크립트·템플릿·전역 스타일 전부) 제거**. 이로써 보안 L1(에이전트 v-html)도 해소.
- **버튼은 AppShell 헤더로 이동 — 모든 화면에서 상시 노출/열림**(`agentOpen` 은 AppShell 가 보유, `<AgentChatPanel>` 도 AppShell 에서 렌더).
- **DB 영속 + 다중 대화로 확장(2026-06-20)**: 사용자 요청 "어디서든 본인 기록 조회 → DB 필요". 무상태 `/ai/chat`**대화 스코프 API**(`AiConversation`·`AiMessage` 엔티티, user CASCADE). 엔드포인트 `GET /ai/conversations`(목록)·`GET/:id`(상세)·`POST /ai/conversations`(새 대화)·`POST /:id/messages`(추가)·`DELETE /:id` — 전부 **본인 소유 스코프**(타인 대화 404=IDOR 차단). **클라는 새 메시지 1건만 보내고 서버가 DB 이력으로 Claude 호출**(보안·페이로드 개선). **Claude 실패 시 사용자 메시지·빈 대화 롤백**. 프론트 `AgentChatPanel`**채팅↔지난 대화 목록 뷰**(열기/삭제), `ai.store` 가 목록·현재 대화 관리. **이제 다른 기기·재로그인·새로고침에도 기록 유지.** SendMessageDto(content 8000자). 검증 백엔드 build/lint 0·20 tests, 프론트 0. 런타임 미검증(키·DB 필요).
- 검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0. **런타임 미검증**(실제 API 키 호출 필요). 모델 ID는 콘솔 가용 모델로 `CLAUDE_MODEL` 교체 가능.
#### 8-7. 그 외(미착수)
- 메일 실발송(SMTP), 비번 재설정 플로우, 보안 후속(계정잠금/CAPTCHA).
---
+72
View File
@@ -0,0 +1,72 @@
# 프로젝트 우선 구조 리팩터 계획 (Project-First)
> 작성 2026-06-20. 현재 **저장소(Repo)=프로젝트 1:1** 구조를 **프로젝트 우선 + 프로젝트 1:N 저장소** 로 전환한다.
> 이 문서가 리팩터의 단일 진실 공급원(SSOT). 단계별로 갱신한다.
## 1. 목표 구조
```
Project (이름/설명/공개범위) ← 작업의 기본 단위(우선)
├─ ProjectMember (admin/member, isOwner) ← 멤버십을 프로젝트로 이동(RepoMember 폐지)
├─ Task (project, seq[프로젝트 내 순번], …) ← 업무는 프로젝트 소속
│ └─ checklist / comment / attachment (기존 그대로, Task 종속)
├─ Activity (project, taskSeq?, …) ← 활동도 프로젝트 단위
└─ Repo[] (project FK, gitea 필드…) ← 0~N개 저장소 연동(선택, 코드 연동 전용)
```
- **일반 프로젝트** = 연동 저장소 0개. 업무·멤버·활동 정상 사용.
- **저장소 연동 프로젝트** = 저장소 1~N개 링크. 추가로 Gitea/코드참조.
## 2. 확정된 설계 결정 (사용자 합의 2026-06-20)
1. **업무·멤버·활동 = 프로젝트 단위.** `Task→Repo` 종속을 `Task→Project` 로 이동. `RepoMember` 폐지 → `ProjectMember`.
2. **Gitea 자동 가져오기(역방향 동기화)** → import 저장소는 **"미분류" 기본 프로젝트**에 연결. 관리자가 나중에 적절한 프로젝트로 이동.
3. **dev DB 초기화**(volume 재생성). 마이그레이션 스크립트 불필요.
## 3. 추가 설계 결정 (구현자 판단 — 변경 가능)
- **라우팅**: 업무는 프로젝트 직속, 저장소는 형제 부속.
- `/projects` · `/projects/:projectId` (상세=업무목록) · `/projects/:projectId/{members,activity,settings}`
- `/projects/:projectId/tasks/:seq` (업무 상세) · `/projects/:projectId/tasks/new`
- `/projects/:projectId/repos` (연동 저장소 목록·링크) · `/projects/:projectId/repos/:repoSlug` (저장소 상세/설정)
- `/tasks` (내 업무, 프로젝트 횡단 — 경로 유지)
- **저장소 식별자**: `slugName` 은 Gitea 조직 내 유일이므로 **전역 유일 유지**(프로젝트 내 유일로 바꾸지 않음). Repo 에 `project_id` FK만 추가.
- **Task.seq**: `@Unique(['repo','seq'])``@Unique(['project','seq'])` (프로젝트 내 순번 #N).
- **권한**: `assertMember/assertAdmin(repo.id, …)``assertProjectMember/assertProjectAdmin(project.id, …)`. 저장소 단위 세분 권한은 두지 않음(프로젝트 멤버면 연동 저장소 전체 접근). 향후 옵션.
- **저장소 자체엔 멤버 없음.** 저장소는 순수 Gitea 연동 메타. 멤버십/권한은 소유 프로젝트가 결정.
- **"미분류" 기본 프로젝트**: 시스템 예약 프로젝트 1개(부팅 시 보장 생성). Gitea import 대상.
## 4. 단계별 실행 계획
### Phase 1 — 백엔드 데이터 모델 + Project 모듈 (핵심)
- [ ] `Project`·`ProjectMember` 엔티티 + `ProjectModule`/`ProjectService`/`ProjectController`(CRUD + 멤버 초대/역할/제거 + 권한 가드)
- [ ] "미분류" 기본 프로젝트 부팅 보장(OnApplicationBootstrap)
- [ ] `Repo``project` FK 추가. `RepoMember` 폐지. Repo 생성/수정/삭제가 프로젝트 권한으로 게이트.
- [ ] `Task``project` 종속으로 변경(`@Unique(['project','seq'])`), 권한 체인 프로젝트로 교체, 담당자=프로젝트 멤버.
- [ ] `Activity``project` 종속으로. (taskSeq 유지)
- [ ] `RepoSyncService`: import → "미분류" 프로젝트 연결.
- [ ] `AiService.suggestChecklist`: 프로젝트/저장소 컨텍스트 정합.
- [ ] 컨트롤러 라우트 재배치(`/projects/...`). 기존 `/repos/...` 제거 또는 이전.
- [ ] 검증: build/lint/test.
### Phase 2 — 프론트 프로젝트 중심 재구성
- [ ] `project.store`·`useProject`·`types/project.ts`
- [ ] 신규 페이지: ProjectListPage(진입점)·ProjectDetailPage(업무목록)·ProjectSettingsPage·ProjectMembersPage·ProjectActivityPage
- [ ] 저장소: 프로젝트 내 "연동 저장소" 섹션 + RepoLinkPage(기존 RepoCreate 재구성)
- [ ] task/member/activity 스토어를 projectId 키로 재설계
- [ ] 라우팅 전면 교체, AppShell 네비(프로젝트/내 업무)
- [ ] 검증: type-check/lint/build.
### Phase 3 — 마무리
- [ ] 문서(api-contract.md)·메모리 갱신, 잔여 UI 정리, 런타임 점검.
## 5. 진행 로그
- Phase 0: 계획 수립 ✅
- Phase 1-a: Project/ProjectMember 모듈 기반(엔티티·서비스·컨트롤러·"미분류" 부팅) ✅ (커밋 2f66cdd)
- Phase 1-b: 백엔드 이전 ✅ — Activity(project FK·listForProject)·Repo(project FK, RepoMember 폐지)·Task(project 종속, seq 프로젝트 내 순번, 권한=ProjectMember) 전환. RepoService 프로젝트 종속(`/projects/:projectId/repos`, 멤버·업무카운트·캐시 제거). Task/Activity 컨트롤러 `/projects/...`. RepoSync→"미분류". AiService 프로젝트 컨텍스트. member.service/controller·repo-member·repo-cache 삭제. 검증 build/lint 0·14 tests.
- **Phase 1 잔여(소소, 후속)**: ProjectResponse 카운트(repoCount/doneCount/totalCount 현재 0 stub) 집계 배선, ProjectMemberService 활동·알림 배선(TODO), TaskService.assigneeCounts→ProjectMemberService taskCount 연결.
- Phase 2-a: 프론트 프로젝트 중심 재구성 ✅ — `types/project`·`useProject`·`project.store` 신설. `useTask`/`useMember`/`useActivity``/projects/:projectId/...` 로, task/my-task/activity 타입·스토어를 projectId/projectName 으로 전환. 알림 store 라우팅/문구 프로젝트화. 라우팅 `/projects/...` 전면 교체. 페이지 rename(Repo*→Project*): List/Create(간단 폼)/Detail/Settings/Members/Activity. `RepoHeader``ProjectHeader`, `RepoTabs``ProjectTabs`. AppShell 네비 "프로젝트". 죽은 파일(repo.store/useRepo/GiteaRepoBar) 삭제. 검증 type-check/lint/build 0.
- Phase 1 카운트 배선 ✅ — ProjectService 가 프로젝트별 연동 저장소 수·업무 완료/전체 수를 그룹 쿼리로 집계(Repo/Task forFeature 등록). ProjectMemberService 멤버 taskCount 집계 + 초대/역할변경/제거 시 활동 적재·대상자 알림 발송(Activity/Notification 모듈 주입). TaskService 미사용 집계 메서드 제거. 검증 build/lint 0·14 tests.
- Phase 2b 저장소 연동 UI ✅ — `useRepo`(프로젝트 종속) 복구, `types/repo` ApiRepo 를 새 RepoResponse(projectId 추가·members/counts 제거)로 정합. `ProjectTabs` 에 "저장소" 탭(repoCount) 추가, 신규 `ProjectReposPage`(`/projects/:id/repos`) — 연동 저장소 목록 + slug 가용성 체크 포함 연동(생성) 폼 + 연동 해제(admin). 라우트 추가. 화면 잔여 "저장소" 문구를 "프로젝트"로 정리(crumb·설정 문구·멤버 모달·주석). 검증 type-check/lint/build 0.
- Phase 3 마무리 ✅ — **런타임 점검 완료(2026-06-20)**. dev DB 초기화(데이터 디렉터리 비우고 `up -d --build`) 후 새 스키마 정상 생성, 부팅 시 "미분류" 시스템 프로젝트 생성 + Gitea 동기화가 원격 저장소 1개를 "미분류"에 import(repoCount=1) 확인. 스모크: signup→이메일 인증(로그 링크)→login→프로젝트 생성(소유자 owner/admin 등록)→프로젝트 종속 업무 생성(담당자=멤버)→목록/카운트→프로젝트 상세 totalCount·멤버 taskCount·repoCount 집계 반영→내 업무/활동 피드→연동 저장소 목록/meta/name-available 전부 정상. 프론트 200. **부수 발견·수정**: 백엔드/프론트 헬스체크 오탐(루트 프로브 404·localhost→IPv6) 교정 → 4개 컨테이너 모두 healthy(커밋 9aa22f0).
- **리팩터 완료.** 후속(별건): Phase 1 잔여 소소 항목은 카운트 배선으로 해소됨. 신규 저장소 연동의 실제 Gitea 생성 경로는 런타임 미검증(외부 부작용 회피 — 역방향 import 로 간접 검증됨).
+113
View File
@@ -0,0 +1,113 @@
# 보안 점검 보고서 (2026-06-19)
지금까지 작업분(인증/저장소/멤버/업무/활동/알림 + 소셜로그인·이메일 인증)을 대상으로 한 보안 점검 결과와 보완 계획. 4개 영역 병렬 감사(권한·IDOR / 인증·세션·OAuth / 입력검증·실패처리 / 프론트·시크릿).
## 총평
서버측 기반은 **대체로 견고**하다. 전 엔드포인트 `JwtAuthGuard`, 전역 `ValidationPipe`(whitelist+forbidNonWhitelisted+transform), 파라미터 바인딩(SQL 인젝션 없음), 에러 누출 차단(catch-all→SYS_001), 쿠키 HttpOnly·env별 secure/sameSite, 토큰 해시 저장·select:false, 페이지네이션 상한(100), 경로 traversal 안전. **IDOR/권한 상승은 발견되지 않음**(업무·멤버·알림 전부 서버측 멤버십/역할 게이트, 교차 저장소 위조 불가).
가장 약한 표면은 **신규 소셜 로그인 플로우**다. 아래 HIGH 3건은 운영 노출 전 우선 처리 권장.
## 심각도 요약
| # | 심각도 | 항목 | 위치 |
|---|---|---|---|
| H1 | HIGH | OAuth 제공자 이메일 검증 미확인 → 계정 탈취/선점 | `auth.service.ts` validateOrCreateOAuthUser, google/kakao.strategy |
| H2 | HIGH | OAuth `state` 누락 → 로그인 CSRF(세션 고정) | google/kakao.strategy |
| H3 | HIGH | refresh 토큰 회전/폐기 부재 → 탈취 시 14일 재사용, 로그아웃 후도 유효 | auth.service/controller, jwt-refresh.strategy |
| M1 | MEDIUM | 파일 업로드 MIME 미제한 + 다운로드 `inline` → 저장형 XSS(SVG/HTML) | task.controller 업로드/다운로드 |
| M2 | MEDIUM | 인증 라우트 throttling 부족 → 로그인 무차별·메일 폭탄 | main.ts/app.module, auth.controller |
| M3 | MEDIUM | 첨부 삭제에 업로더/관리자 확인 없음 → 타 멤버 첨부 삭제 | task.service removeFile |
| M4 | MEDIUM | 인증 토큰이 로그·GET 쿼리로 노출(플레이스홀더 메일러) | mail.service, auth.service |
| M5 | MEDIUM | 가입 이메일 열거(409 "이미 가입된 이메일") | auth.service signup |
| L1 | LOW | AI 에이전트 터미널 v-html 사용자 입력 미이스케이프(목업, self-XSS) | TaskDetailPage.vue:478 |
| L2 | LOW | DTO 배열/문자열 길이 무제한(content/assigneeIds/checklist, UpdateRepo.description) | task/repo DTO |
| L3 | LOW | 비밀번호 정책 약함(MinLength 8만), bcrypt rounds 10 | signup.dto, auth.service |
| L4 | LOW | 그랜드페더링이 매 부팅 전수 검증(취약한 결합) | user.service onApplicationBootstrap |
| L5 | LOW | multer 크기초과→일반 500(400 아님) | upload.config / task.controller |
| L6 | LOW | CORS prod에 localhost:3000 고정, Swagger 항상 활성 | main.ts |
## 상세 + 보완 방안
### H1. OAuth 제공자 이메일 검증 미확인 (계정 탈취/선점)
`validateOrCreateOAuthUser`**이메일 문자열만으로** 기존 계정 매칭/신규 생성하며, 전략이 제공자의 이메일 검증 플래그(Google `email_verified`, Kakao `is_email_verified`)를 확인하지 않는다.
- (a) 공격자가 제공자에 피해자 이메일을 **미검증** 상태로 설정해 로그인 → 기존 계정 로그인. 특히 **미인증 로컬 계정**이면 코드가 `emailVerified=true` 로 승격까지 함(이메일 인증 우회 탈취).
- (b) 공격자가 소유하지 않은 이메일로 소셜 가입 → `emailVerified:true` 행 생성, 실소유자는 영영 가입 불가.
**보완**: 각 전략에서 제공자 검증 플래그를 읽어 `OAuthProfile.emailVerified` 로 전달, 미검증이면 거부. 검증된 이메일일 때만 매칭/자동연동 허용(미인증 로컬 승격도 검증 이메일 한정).
### H2. OAuth `state` 누락 (로그인 CSRF)
두 전략 모두 `state` 미사용 → 인가코드 플로우에 CSRF 보호 없음. 공격자가 자기 계정으로 시작한 콜백을 피해자에게 완성시키면 피해자 브라우저가 **공격자 계정**으로 로그인.
**보완**: 리다이렉트 직전 서명·짧은 수명 쿠키에 난수 state 저장 → 콜백에서 검증(또는 passport `state:true` + 세션 스토어). 가능하면 PKCE.
### H3. refresh 토큰 회전/폐기 부재
refresh는 무상태 JWT로 회전·서버측 폐기목록이 없다. `/auth/refresh` 가 새 쌍을 재발급해도 이전 토큰은 14일 내내 유효, `logout` 은 쿠키만 지움(서버 무효화 X). 탈취 시 14일 재생, 비번 변경으로도 기존 세션 강제 종료 불가.
**보완(실용안)**: User에 `tokenVersion`(int) 추가 → refresh payload에 포함, jwt-refresh.strategy에서 `payload.ver === user.tokenVersion` 검증, **logout·비밀번호 변경 시 증가**(전 세션 무효화). (완전 회전+재사용 탐지는 후속.)
### M1. 파일 업로드 MIME 미제한 + 다운로드 inline
`FileInterceptor` 에 크기 제한(25MB)만 있고 `fileFilter` 없음. 다운로드가 저장 `Content-Type` + `Content-Disposition: inline` 으로 스트리밍 → `text/html`·`image/svg+xml` 업로드를 직접 URL로 열면 **API 오리진에서 스크립트 실행(저장형 XSS)**.
**보완**: `fileFilter` 로 허용 MIME 화이트리스트(image/pdf/zip/office) + 다운로드를 `attachment` 로 강제 + 해당 라우트 `X-Content-Type-Options: nosniff`.
### M2. 인증 라우트 throttling 부족
전역 한도만 존재(express-rate-limit 150/15분, ThrottlerGuard 100/60초). `login`/`signup`/`resend-verification` 에 라우트별 강화 한도 없음 → 분당 ~100회 비번 추측, 메일 폭탄(signup/resend) 가능.
**보완**: `@Throttle` 로 login ~5/분, resend/signup ~3/분 + 이메일별 쿨다운. (계정 잠금/CAPTCHA는 후속.)
### M3. 첨부 삭제 권한 확인 없음
`removeFile``assertMember` + 첨부-업무 소속만 확인하고 **업로더/관리자 확인 없음** → 같은 저장소 멤버가 타인의 첨부(및 디스크 파일) 삭제 가능.
**보완**: `attachment.uploader.id === actingUserId || roleType==='admin'` 아니면 Forbidden.
### M4. 인증 토큰 로그/GET 쿼리 노출
`MailService` 가 **원문 토큰 포함 전체 링크**를 로거로 출력. 로그 접근자가 24h 내 인증 가로채기 가능. (현재는 dev 플레이스홀더라 **로그 출력이 의도된 동작**이나, 실 메일러 전환 전 처리 필요.)
**보완**: `NODE_ENV!=='production'` 일 때만 전체 링크 로그(dev 편의 유지), prod는 수신자만 로그. 실 메일러 도입 시 짧은 TTL.
### M5. 가입 이메일 열거
signup이 존재 이메일에 `409` 고유 메시지 → 계정 존재 열거 가능(login·resend는 비노출 — 일관성 차이).
**보완(트레이드오프)**: 메시지 유지하되 M2 throttle/CAPTCHA로 비용↑. (완전 비열거는 UX 충돌이라 보류 가능.)
### L1~L6 (하드닝)
- **L1**: `TaskDetailPage.vue:478` 에이전트 터미널 `tool.body` 에 사용자 입력 `req``escapeHtml()` 처리(현재 목업이라 self-XSS, 1줄 수정).
- **L2**: `assigneeIds`/`checklist`/`content``@ArrayMaxSize`, `content` 요소 `@MaxLength(_,{each:true})`, `UpdateRepoDto.description``@MaxLength(300)`.
- **L3**: 서버측 비밀번호 복잡도(영문+숫자) `@Matches` + 최대 길이, bcrypt rounds 10→12.
- **L4**: 그랜드페더링을 매부팅 전수 → 생성일 컷오프/일회성 플래그로 한정.
- **L5**: multer `LIMIT_FILE_SIZE` 를 400/BIZ로 매핑.
- **L6**: CORS의 `localhost:3000` 을 dev 한정, Swagger를 prod에서 비활성(또는 보호).
## 정상 확인(변경 불필요)
IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignment/역할상승 불가, SQL 인젝션 없음(파라미터 바인딩), 다운로드 경로 traversal 안전(UUID→DB 랜덤 storedName), 에러 스택 비노출(catch-all→SYS_001), 시크릿 비로그(Gitea 토큰/JWT/비번/쿠키), 프론트 번들 비밀 없음(VITE_*만), `.env` 미커밋(*.example만, 플레이스홀더), PublicUser/엔티티 select:false로 민감필드 차단, 알림/활동 v-html은 escapeHtml 적용, best-effort 부수효과 격리(활동/알림 실패가 본작업 안 깨뜨림), Redis/캐시 장애 크래시 안전, 페이지네이션 상한.
## 제품 확인 필요(코드 아님)
- **조직 전체 읽기 모델**: 인증 사용자는 모든 저장소·활동·업무를 READ 가능(단일 조직 의도). 저장소를 비공개로 가둘 의도면 읽기에도 멤버십 게이트 필요 — 의도 확인.
## 보완 배치(권장 순서)
- **A(HIGH·우선)**: H1 OAuth 이메일검증 + 안전연동 / H3 refresh tokenVersion 폐기 / H2 OAuth state CSRF
- **B(MEDIUM)**: M1 업로드 MIME+attachment+nosniff / M2 인증 throttle / M3 첨부삭제 권한 / M4 토큰로그 prod 게이트 / (M5는 M2로 완화)
- **C(LOW 하드닝)**: L1 에이전트 escape / L2 DTO 바운드 / L3 비번정책+bcrypt / L4 그랜드페더링 컷오프 / L5 multer 매핑 / L6 CORS·Swagger prod
## 적용 현황 (2026-06-19) — 배치 A+B 완료
사용자 결정: **A+B 적용**, 조직 전체 읽기 모델은 **의도된 설계로 유지**(읽기 게이트 미적용).
- **H1 ✅** 각 전략이 제공자 이메일 검증 플래그를 읽어 `OAuthProfile.emailVerified` 전달, `validateOrCreateOAuthUser` 가 미검증이면 403 거부(검증 이메일일 때만 매칭/생성/미인증 로컬 승격). Google `_json.email_verified`, Kakao `kakao_account.is_email_verified`.
- **H2 ✅** 세션 없는 구조라 `CookieStateStore`(passport-oauth2 store 인터페이스) 신설 — 인가 시작 시 httpOnly 1회용 난수 state 쿠키 발급, 콜백에서 일치 검증. 두 전략에 `store` 주입(새 의존성 0). *런타임은 실제 제공자 콜백으로만 최종 확인 가능.*
- **H3 ✅ → 완전 회전+재사용 탐지로 강화(2026-06-19)**: 초기엔 `tokenVersion` 폐기 수준이었으나, **세션(패밀리) 테이블 기반 회전형 refresh** 로 교체. `RefreshSession`(id=fid, currentJti, prevJti+prevAt, expiresAt) 한 로그인당 1행. `/auth/refresh` 가 현재 jti면 새 jti로 **회전**, 이미 회전된 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 전체 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight로 방지. logout=해당 패밀리만 폐기, `revokeAllSessions`(전 기기) 메서드 준비(비번 재설정 시 사용). `tokenVersion`/`jwt-refresh.strategy`/`JwtRefreshGuard` 제거(refresh는 가드 없이 서비스에서 직접 처리).
- **M1 ✅** 업로드 `fileFilter` MIME 화이트리스트(html/svg 등 차단) + 거부 시 400, 다운로드 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`.
- **M2 ✅** `@Throttle` 라우트별 — login 10/분, signup 5/분, resend 3/분(IP 기준). 계정 잠금/CAPTCHA·이메일별 쿨다운은 후속.
- **M3 ✅** `removeFile` 가 업로더 본인 또는 admin 만 허용(`getAttachmentOrThrow` 에 uploader 로드).
- **M4 ✅** `MailService` 가 prod 에서는 인증 링크(원문 토큰)를 로그에 남기지 않고 수신자만 기록(dev 는 링크 출력 유지).
- **M5** 완화: M2 throttle 로 가입 열거 자동화 비용 상승(메시지는 UX 위해 유지).
**검증**: 백엔드 build/lint 0 · 20 tests pass. 프론트 변경 없음(전부 서버측).
## 적용 현황 (2026-06-19) — 배치 C (L2~L6) 완료, L1 보류
사용자 지시: 배치 C 진행하되 **에이전트 관련(L1)은 아직 미작업이라 제외**.
- **L1 ⏸ 보류**: AI 에이전트 터미널 v-html escape (에이전트 패널 자체가 미연동 영역이라 사용자 요청으로 제외).
- **L2 ✅** DTO 길이 바운드 — task `content` `@ArrayMaxSize(200)`+요소 `@MaxLength(5000)`, `assigneeIds` `@ArrayMaxSize(50)`, `checklist` `@ArrayMaxSize(100)`(create/update 양쪽), `UpdateRepoDto.description` `@MaxLength(300)`.
- **L3 ✅** 비밀번호 정책 — `@MaxLength(72)`(bcrypt 72바이트 한계) + `@Matches` 영문·숫자 포함, bcrypt rounds 10→12.
- **L4 ✅** 그랜드페더링을 컷오프(`2026-06-20`) 이전 생성 계정으로 한정(`createdAt LessThan`) — 이후 가입은 토큰 상태 무관하게 정상 인증.
- **L5 ✅** multer 오류(크기초과 등)를 전역 필터에서 400(VAL_001)로 매핑(기존 일반 500 → 명확한 검증 오류).
- **L6 ✅** CORS `localhost:3000` 을 비운영 한정, Swagger(`/api-docs`)를 비운영 한정 노출.
**검증**: 백엔드 build/lint 0 · 20 tests. 프론트 변경 없음.
**남은 항목**: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(~~토큰 완전회전~~ **완료** / 계정잠금·CAPTCHA / M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). access 토큰 즉시 폐기(denylist)는 여전히 미적용(15분 잔존, 수용). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요(특히 회전형 refresh의 동시성·재사용 탐지 동작).
+7
View File
@@ -0,0 +1,7 @@
# ==========================================
# 프론트엔드 env (development)
# VITE_ 변수는 docker-compose 가 루트 .env.development 값을 build arg 로 주입한다:
# VITE_API_BASE_URL ← BACKEND_API_URL
# VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS
# 따라서 이 파일에 별도로 작성할 값은 없다.
# ==========================================

Some files were not shown because too many files have changed in this diff Show More